From 9165d26b72d765ef8d2ff84df9c345d94e8d4663 Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 6 Apr 2026 22:20:13 +0200 Subject: [PATCH] refactor(auth): token store, Apollo auth, and email UI Update ApiTokenStore, AuthServiceImpl, and interceptor; adjust login/register screens, dashboard, chart, and Firebase auth tests. Made-with: Cursor --- .../airmq/features/common/chart/AirMQChart.kt | 9 ++-- .../features/dashboard/DashboardViewModel.kt | 30 +++++++++++-- .../airmq/features/login/EmailLoginScreen.kt | 13 +++++- .../features/login/EmailRegisterScreen.kt | 28 +++++++++--- .../org/db3/airmq/sdk/auth/ApiTokenStore.kt | 5 +++ .../org/db3/airmq/sdk/auth/AuthServiceImpl.kt | 17 ++++---- .../auth/SharedPreferencesApiTokenStore.kt | 19 ++++++-- .../sdk/network/ApolloAuthInterceptor.kt | 2 + .../airmq/sdk/auth/FirebaseAuthServiceTest.kt | 43 +++++++++++++++++++ 9 files changed, 140 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt index ae14621..79db17c 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt @@ -162,13 +162,14 @@ fun AirMQChart( Canvas(modifier = Modifier.fillMaxSize()) { val w = size.width val h = size.height - val left = with(density) { ChartPaddingLeftDp.toPx() } - val right = w - with(density) { ChartPaddingRightDp.toPx() } + val innerLeft = with(density) { ChartPaddingLeftDp.toPx() } + val innerRight = w - with(density) { ChartPaddingRightDp.toPx() } + val innerWidth = innerRight - innerLeft val rad = with(density) { 8.dp.toPx() } drawRoundRect( color = config.backgroundColor, - topLeft = Offset(0f, 0f), - size = Size(w, h), + topLeft = Offset(innerLeft, 0f), + size = Size(innerWidth, h), cornerRadius = CornerRadius(rad, rad) ) } diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt index a6c8fac..5403c55 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt @@ -13,12 +13,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.db3.airmq.R import org.db3.airmq.features.common.chart.ChartConfig import org.db3.airmq.features.common.chart.ChartDataset import org.db3.airmq.features.common.metric.SensorType +import org.db3.airmq.sdk.auth.ApiTokenStore import org.db3.airmq.sdk.city.CityService import org.db3.airmq.sdk.city.DashboardCityContext import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository @@ -34,6 +36,7 @@ import androidx.compose.ui.graphics.Color class DashboardViewModel @Inject constructor( @ApplicationContext private val context: Context, private val cityService: CityService, + private val apiTokenStore: ApiTokenStore, private val dashboardMetricsRepository: DashboardMetricsRepository, ) : ViewModel() { @@ -47,10 +50,14 @@ class DashboardViewModel @Inject constructor( init { viewModelScope.launch { - cityService.observeDashboardCityContext().collectLatest { _ -> - val ctx = cityService.getResolvedDashboardCityContext() - loadDashboardData(ctx) - } + combine( + cityService.observeDashboardCityContext(), + apiTokenStore.observeToken(), + ) { _, _ -> } + .collectLatest { + val ctx = cityService.getResolvedDashboardCityContext() + loadDashboardData(ctx) + } } } @@ -88,6 +95,21 @@ class DashboardViewModel @Inject constructor( } private suspend fun loadDashboardData(ctx: DashboardCityContext) { + if (apiTokenStore.getToken().isNullOrBlank()) { + cachedAverageRows = emptyList() + val sensor = _uiState.value.selectedSensor + _uiState.update { state -> + state.copy( + city = ctx.displayName, + gaugeValues = SensorType.entries.associateWith { null }, + chartData = DashboardChartMapper.chartDataset(emptyList(), sensor), + chartConfig = chartConfigFor(sensor), + chartSensorLabel = chartLabelFor(sensor), + ) + } + return + } + val result = dashboardMetricsRepository.fetchCityDashboard(ctx) val data = result.getOrNull() cachedAverageRows = data?.averageRows.orEmpty() diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt index c1e6430..a18f3e6 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -32,6 +33,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -150,7 +153,11 @@ private fun EmailLoginScreenContent( value = uiState.email, onValueChange = { onEvent(Event.EmailChanged(it)) }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentType = ContentType.Username + ContentType.EmailAddress + }, placeholder = { Text( stringResource(id = R.string.hint_email), @@ -176,7 +183,9 @@ private fun EmailLoginScreenContent( value = uiState.password, onValueChange = { onEvent(Event.PasswordChanged(it)) }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.Password }, placeholder = { Text( stringResource(id = R.string.hint_password), diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt index 0018947..5c5d70f 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailRegisterScreen.kt @@ -25,9 +25,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -35,6 +37,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -60,11 +64,15 @@ fun EmailRegisterScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + val autofillManager = LocalAutofillManager.current LaunchedEffect(viewModel) { viewModel.actions.collectLatest { action -> when (action) { - Action.OpenManage -> onRegisterSuccess() + Action.OpenManage -> { + autofillManager?.commit() + onRegisterSuccess() + } is Action.ShowMessage -> { Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() } @@ -151,7 +159,9 @@ private fun EmailRegisterScreenContent( onValueChange = { onEvent(Event.NameChanged(it)) }, singleLine = true, enabled = fieldEnabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.PersonFullName }, isError = uiState.nameError != null, supportingText = uiState.nameError?.let { err -> { Text(err, color = Color.White.copy(alpha = 0.9f)) } @@ -174,7 +184,11 @@ private fun EmailRegisterScreenContent( onValueChange = { onEvent(Event.EmailChanged(it)) }, singleLine = true, enabled = fieldEnabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentType = ContentType.NewUsername + ContentType.EmailAddress + }, isError = uiState.emailError != null, supportingText = uiState.emailError?.let { err -> { Text(err, color = Color.White.copy(alpha = 0.9f)) } @@ -197,7 +211,9 @@ private fun EmailRegisterScreenContent( onValueChange = { onEvent(Event.PasswordChanged(it)) }, singleLine = true, enabled = fieldEnabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.NewPassword }, isError = uiState.passwordError != null, supportingText = uiState.passwordError?.let { err -> { Text(err, color = Color.White.copy(alpha = 0.9f)) } @@ -221,7 +237,9 @@ private fun EmailRegisterScreenContent( onValueChange = { onEvent(Event.PasswordConfirmChanged(it)) }, singleLine = true, enabled = fieldEnabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.NewPassword }, isError = uiState.passwordConfirmError != null, supportingText = uiState.passwordConfirmError?.let { err -> { Text(err, color = Color.White.copy(alpha = 0.9f)) } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt index f758744..41f30a8 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/ApiTokenStore.kt @@ -1,7 +1,12 @@ package org.db3.airmq.sdk.auth +import kotlinx.coroutines.flow.Flow + interface ApiTokenStore { fun getToken(): String? fun saveToken(token: String): Result fun clearToken(): Result + + /** Emits the current token and whenever it changes (including after process start). */ + fun observeToken(): Flow } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt index 408c579..6dbd019 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt @@ -21,15 +21,16 @@ class FirebaseAuthService @Inject constructor( ) : AuthService { override suspend fun getUser(): User? { - firebaseSessionManager.getUser()?.let { return it } if (apiTokenStore.getToken().isNullOrBlank()) return null - val profile = localEmailAuthStore.getProfile() ?: return null - return User( - userId = profile.userId, - email = profile.email, - displayName = profile.displayName, - isAuthenticated = true - ) + localEmailAuthStore.getProfile()?.let { profile -> + return User( + userId = profile.userId, + email = profile.email, + displayName = profile.displayName, + isAuthenticated = true + ) + } + return firebaseSessionManager.getUser() } override suspend fun isAuthenticated(): Boolean { diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt index 645fd2d..4f44478 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt @@ -4,6 +4,9 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow @Singleton class SharedPreferencesApiTokenStore @Inject constructor( @@ -13,17 +16,27 @@ class SharedPreferencesApiTokenStore @Inject constructor( private val sharedPreferences = context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE) - override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null) + private val tokenFlow = MutableStateFlow(sharedPreferences.getString(KEY_API_TOKEN, null)) + + override fun getToken(): String? = tokenFlow.value override fun saveToken(token: String): Result = runCatching { require(token.isNotBlank()) { "API token cannot be blank." } - sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply() + if (!sharedPreferences.edit().putString(KEY_API_TOKEN, token).commit()) { + error("Failed to persist API token.") + } + tokenFlow.value = token } override fun clearToken(): Result = runCatching { - sharedPreferences.edit().remove(KEY_API_TOKEN).apply() + if (!sharedPreferences.edit().remove(KEY_API_TOKEN).commit()) { + error("Failed to clear API token.") + } + tokenFlow.value = null } + override fun observeToken(): Flow = tokenFlow.asStateFlow() + private companion object { private const val KEY_API_TOKEN = "api_token" } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt index c4cf7d4..5e36583 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/network/ApolloAuthInterceptor.kt @@ -1,5 +1,6 @@ package org.db3.airmq.sdk.network +import android.util.Log import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.Operation @@ -16,6 +17,7 @@ class ApolloAuthInterceptor( chain: ApolloInterceptorChain ): Flow> { val token = apiTokenStore.getToken() + Log.d("DEBUG", token.toString()) val requestWithAuth = if (token.isNullOrBlank()) { request } else { diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt index 3cd714c..b4a5ec6 100644 --- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -4,6 +4,8 @@ import com.apollographql.apollo.ApolloClient import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.runTest import org.db3.airmq.sdk.AuthGoogleNewMutation import org.db3.airmq.sdk.auth.model.AuthProvider @@ -87,6 +89,41 @@ class FirebaseAuthServiceTest { assertTrue(service.isAuthenticated()) } + @Test + fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest { + val sessionManager = FakeFirebaseSessionManager( + signInResult = FirebaseSessionUser( + user = User("uid-fb", "fb@test.dev", "FB", true), + firebaseAccessToken = "ft" + ) + ) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = null) + val localEmailStore = FakeLocalEmailAuthStore() + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) + + assertNull(service.getUser()) + } + + @Test + fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest { + val sessionManager = FakeFirebaseSessionManager( + signInResult = FirebaseSessionUser( + user = User("uid-fb", "fb@test.dev", "FB", true), + firebaseAccessToken = "ft" + ) + ) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = "api-token") + val localEmailStore = FakeLocalEmailAuthStore() + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) + + val user = service.getUser() + assertNotNull(user) + assertEquals("uid-fb", user!!.userId) + assertEquals("fb@test.dev", user.email) + } + @Test fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest { val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) @@ -153,18 +190,24 @@ private class FakeApiTokenStore( var storedToken: String? = null, private val saveError: Throwable? = null ) : ApiTokenStore { + private val tokenFlow = MutableStateFlow(storedToken) + override fun getToken(): String? = storedToken override fun saveToken(token: String): Result { saveError?.let { return Result.failure(it) } storedToken = token + tokenFlow.value = token return Result.success(Unit) } override fun clearToken(): Result { storedToken = null + tokenFlow.value = null return Result.success(Unit) } + + override fun observeToken() = tokenFlow.asStateFlow() } private class FakeLocalEmailAuthStore(