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
This commit is contained in:
2026-04-06 22:20:13 +02:00
parent d34b3bf70e
commit 9165d26b72
9 changed files with 140 additions and 26 deletions

View File

@@ -162,13 +162,14 @@ fun AirMQChart(
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val left = with(density) { ChartPaddingLeftDp.toPx() } val innerLeft = with(density) { ChartPaddingLeftDp.toPx() }
val right = w - with(density) { ChartPaddingRightDp.toPx() } val innerRight = w - with(density) { ChartPaddingRightDp.toPx() }
val innerWidth = innerRight - innerLeft
val rad = with(density) { 8.dp.toPx() } val rad = with(density) { 8.dp.toPx() }
drawRoundRect( drawRoundRect(
color = config.backgroundColor, color = config.backgroundColor,
topLeft = Offset(0f, 0f), topLeft = Offset(innerLeft, 0f),
size = Size(w, h), size = Size(innerWidth, h),
cornerRadius = CornerRadius(rad, rad) cornerRadius = CornerRadius(rad, rad)
) )
} }

View File

@@ -13,12 +13,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartConfig import org.db3.airmq.features.common.chart.ChartConfig
import org.db3.airmq.features.common.chart.ChartDataset import org.db3.airmq.features.common.chart.ChartDataset
import org.db3.airmq.features.common.metric.SensorType 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.CityService
import org.db3.airmq.sdk.city.DashboardCityContext import org.db3.airmq.sdk.city.DashboardCityContext
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
@@ -34,6 +36,7 @@ import androidx.compose.ui.graphics.Color
class DashboardViewModel @Inject constructor( class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val cityService: CityService, private val cityService: CityService,
private val apiTokenStore: ApiTokenStore,
private val dashboardMetricsRepository: DashboardMetricsRepository, private val dashboardMetricsRepository: DashboardMetricsRepository,
) : ViewModel() { ) : ViewModel() {
@@ -47,7 +50,11 @@ class DashboardViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
cityService.observeDashboardCityContext().collectLatest { _ -> combine(
cityService.observeDashboardCityContext(),
apiTokenStore.observeToken(),
) { _, _ -> }
.collectLatest {
val ctx = cityService.getResolvedDashboardCityContext() val ctx = cityService.getResolvedDashboardCityContext()
loadDashboardData(ctx) loadDashboardData(ctx)
} }
@@ -88,6 +95,21 @@ class DashboardViewModel @Inject constructor(
} }
private suspend fun loadDashboardData(ctx: DashboardCityContext) { 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 result = dashboardMetricsRepository.fetchCityDashboard(ctx)
val data = result.getOrNull() val data = result.getOrNull()
cachedAverageRows = data?.averageRows.orEmpty() cachedAverageRows = data?.averageRows.orEmpty()

View File

@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -150,7 +153,11 @@ private fun EmailLoginScreenContent(
value = uiState.email, value = uiState.email,
onValueChange = { onEvent(Event.EmailChanged(it)) }, onValueChange = { onEvent(Event.EmailChanged(it)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics {
contentType = ContentType.Username + ContentType.EmailAddress
},
placeholder = { placeholder = {
Text( Text(
stringResource(id = R.string.hint_email), stringResource(id = R.string.hint_email),
@@ -176,7 +183,9 @@ private fun EmailLoginScreenContent(
value = uiState.password, value = uiState.password,
onValueChange = { onEvent(Event.PasswordChanged(it)) }, onValueChange = { onEvent(Event.PasswordChanged(it)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.Password },
placeholder = { placeholder = {
Text( Text(
stringResource(id = R.string.hint_password), stringResource(id = R.string.hint_password),

View File

@@ -25,9 +25,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -60,11 +64,15 @@ fun EmailRegisterScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val autofillManager = LocalAutofillManager.current
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action -> viewModel.actions.collectLatest { action ->
when (action) { when (action) {
Action.OpenManage -> onRegisterSuccess() Action.OpenManage -> {
autofillManager?.commit()
onRegisterSuccess()
}
is Action.ShowMessage -> { is Action.ShowMessage -> {
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
} }
@@ -151,7 +159,9 @@ private fun EmailRegisterScreenContent(
onValueChange = { onEvent(Event.NameChanged(it)) }, onValueChange = { onEvent(Event.NameChanged(it)) },
singleLine = true, singleLine = true,
enabled = fieldEnabled, enabled = fieldEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.PersonFullName },
isError = uiState.nameError != null, isError = uiState.nameError != null,
supportingText = uiState.nameError?.let { err -> supportingText = uiState.nameError?.let { err ->
{ Text(err, color = Color.White.copy(alpha = 0.9f)) } { Text(err, color = Color.White.copy(alpha = 0.9f)) }
@@ -174,7 +184,11 @@ private fun EmailRegisterScreenContent(
onValueChange = { onEvent(Event.EmailChanged(it)) }, onValueChange = { onEvent(Event.EmailChanged(it)) },
singleLine = true, singleLine = true,
enabled = fieldEnabled, enabled = fieldEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics {
contentType = ContentType.NewUsername + ContentType.EmailAddress
},
isError = uiState.emailError != null, isError = uiState.emailError != null,
supportingText = uiState.emailError?.let { err -> supportingText = uiState.emailError?.let { err ->
{ Text(err, color = Color.White.copy(alpha = 0.9f)) } { Text(err, color = Color.White.copy(alpha = 0.9f)) }
@@ -197,7 +211,9 @@ private fun EmailRegisterScreenContent(
onValueChange = { onEvent(Event.PasswordChanged(it)) }, onValueChange = { onEvent(Event.PasswordChanged(it)) },
singleLine = true, singleLine = true,
enabled = fieldEnabled, enabled = fieldEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword },
isError = uiState.passwordError != null, isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { err -> supportingText = uiState.passwordError?.let { err ->
{ Text(err, color = Color.White.copy(alpha = 0.9f)) } { Text(err, color = Color.White.copy(alpha = 0.9f)) }
@@ -221,7 +237,9 @@ private fun EmailRegisterScreenContent(
onValueChange = { onEvent(Event.PasswordConfirmChanged(it)) }, onValueChange = { onEvent(Event.PasswordConfirmChanged(it)) },
singleLine = true, singleLine = true,
enabled = fieldEnabled, enabled = fieldEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics { contentType = ContentType.NewPassword },
isError = uiState.passwordConfirmError != null, isError = uiState.passwordConfirmError != null,
supportingText = uiState.passwordConfirmError?.let { err -> supportingText = uiState.passwordConfirmError?.let { err ->
{ Text(err, color = Color.White.copy(alpha = 0.9f)) } { Text(err, color = Color.White.copy(alpha = 0.9f)) }

View File

@@ -1,7 +1,12 @@
package org.db3.airmq.sdk.auth package org.db3.airmq.sdk.auth
import kotlinx.coroutines.flow.Flow
interface ApiTokenStore { interface ApiTokenStore {
fun getToken(): String? fun getToken(): String?
fun saveToken(token: String): Result<Unit> fun saveToken(token: String): Result<Unit>
fun clearToken(): Result<Unit> fun clearToken(): Result<Unit>
/** Emits the current token and whenever it changes (including after process start). */
fun observeToken(): Flow<String?>
} }

View File

@@ -21,9 +21,8 @@ class FirebaseAuthService @Inject constructor(
) : AuthService { ) : AuthService {
override suspend fun getUser(): User? { override suspend fun getUser(): User? {
firebaseSessionManager.getUser()?.let { return it }
if (apiTokenStore.getToken().isNullOrBlank()) return null if (apiTokenStore.getToken().isNullOrBlank()) return null
val profile = localEmailAuthStore.getProfile() ?: return null localEmailAuthStore.getProfile()?.let { profile ->
return User( return User(
userId = profile.userId, userId = profile.userId,
email = profile.email, email = profile.email,
@@ -31,6 +30,8 @@ class FirebaseAuthService @Inject constructor(
isAuthenticated = true isAuthenticated = true
) )
} }
return firebaseSessionManager.getUser()
}
override suspend fun isAuthenticated(): Boolean { override suspend fun isAuthenticated(): Boolean {
if (apiTokenStore.getToken().isNullOrBlank()) return false if (apiTokenStore.getToken().isNullOrBlank()) return false

View File

@@ -4,6 +4,9 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@Singleton @Singleton
class SharedPreferencesApiTokenStore @Inject constructor( class SharedPreferencesApiTokenStore @Inject constructor(
@@ -13,16 +16,26 @@ class SharedPreferencesApiTokenStore @Inject constructor(
private val sharedPreferences = private val sharedPreferences =
context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE) 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<Unit> = runCatching { override fun saveToken(token: String): Result<Unit> = runCatching {
require(token.isNotBlank()) { "API token cannot be blank." } 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<Unit> = runCatching { override fun clearToken(): Result<Unit> = 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<String?> = tokenFlow.asStateFlow()
private companion object { private companion object {
private const val KEY_API_TOKEN = "api_token" private const val KEY_API_TOKEN = "api_token"

View File

@@ -1,5 +1,6 @@
package org.db3.airmq.sdk.network package org.db3.airmq.sdk.network
import android.util.Log
import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Operation import com.apollographql.apollo.api.Operation
@@ -16,6 +17,7 @@ class ApolloAuthInterceptor(
chain: ApolloInterceptorChain chain: ApolloInterceptorChain
): Flow<ApolloResponse<D>> { ): Flow<ApolloResponse<D>> {
val token = apiTokenStore.getToken() val token = apiTokenStore.getToken()
Log.d("DEBUG", token.toString())
val requestWithAuth = if (token.isNullOrBlank()) { val requestWithAuth = if (token.isNullOrBlank()) {
request request
} else { } else {

View File

@@ -4,6 +4,8 @@ import com.apollographql.apollo.ApolloClient
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.db3.airmq.sdk.AuthGoogleNewMutation import org.db3.airmq.sdk.AuthGoogleNewMutation
import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.AuthProvider
@@ -87,6 +89,41 @@ class FirebaseAuthServiceTest {
assertTrue(service.isAuthenticated()) 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<ApolloClient>()
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<ApolloClient>()
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 @Test
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest { fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
@@ -153,18 +190,24 @@ private class FakeApiTokenStore(
var storedToken: String? = null, var storedToken: String? = null,
private val saveError: Throwable? = null private val saveError: Throwable? = null
) : ApiTokenStore { ) : ApiTokenStore {
private val tokenFlow = MutableStateFlow<String?>(storedToken)
override fun getToken(): String? = storedToken override fun getToken(): String? = storedToken
override fun saveToken(token: String): Result<Unit> { override fun saveToken(token: String): Result<Unit> {
saveError?.let { return Result.failure(it) } saveError?.let { return Result.failure(it) }
storedToken = token storedToken = token
tokenFlow.value = token
return Result.success(Unit) return Result.success(Unit)
} }
override fun clearToken(): Result<Unit> { override fun clearToken(): Result<Unit> {
storedToken = null storedToken = null
tokenFlow.value = null
return Result.success(Unit) return Result.success(Unit)
} }
override fun observeToken() = tokenFlow.asStateFlow()
} }
private class FakeLocalEmailAuthStore( private class FakeLocalEmailAuthStore(