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:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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<Unit>
|
||||
fun clearToken(): Result<Unit>
|
||||
|
||||
/** Emits the current token and whenever it changes (including after process start). */
|
||||
fun observeToken(): Flow<String?>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Unit> = 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<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 const val KEY_API_TOKEN = "api_token"
|
||||
}
|
||||
|
||||
@@ -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<ApolloResponse<D>> {
|
||||
val token = apiTokenStore.getToken()
|
||||
Log.d("DEBUG", token.toString())
|
||||
val requestWithAuth = if (token.isNullOrBlank()) {
|
||||
request
|
||||
} else {
|
||||
|
||||
@@ -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<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
|
||||
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<String?>(storedToken)
|
||||
|
||||
override fun getToken(): String? = storedToken
|
||||
|
||||
override fun saveToken(token: String): Result<Unit> {
|
||||
saveError?.let { return Result.failure(it) }
|
||||
storedToken = token
|
||||
tokenFlow.value = token
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override fun clearToken(): Result<Unit> {
|
||||
storedToken = null
|
||||
tokenFlow.value = null
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override fun observeToken() = tokenFlow.asStateFlow()
|
||||
}
|
||||
|
||||
private class FakeLocalEmailAuthStore(
|
||||
|
||||
Reference in New Issue
Block a user