From 1c5caf5a6b4a71e01e05874ddd63e02c18023704 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:33:21 +0800 Subject: [PATCH] fix: enforce provider availability on restore --- .../abox/manager/ui/login/LoginViewModel.kt | 10 +++++ .../manager/ui/session/AppSessionViewModel.kt | 14 +++++- .../profile/ProviderProfileStoreImpl.kt | 31 ++++++++++--- .../ProviderProfileStoreFailureTest.kt | 45 +++++++++++++------ 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index ab21e02..7f9a0cd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.Username import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -61,6 +62,15 @@ class LoginViewModel @Inject constructor( }, isLoadingPreferences = false, ) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + if (profile?.id == value.id) { + _uiState.value = _uiState.value.copy( + isLoadingPreferences = false, + errorMessage = "Unable to load saved login settings", + ) + } } finally { loadingJob = null } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index a123d38..39d43f0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.ui.provider.Availability +import com.flagship.abox.manager.ui.provider.ProviderAvailability import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +21,8 @@ import kotlinx.coroutines.launch @HiltViewModel class AppSessionViewModel @Inject constructor( private val coordinator: SessionCoordinator, - profileStore: ProviderProfileStore, + private val profileStore: ProviderProfileStore, + private val availability: ProviderAvailability, ) : ViewModel() { private val bootstrapResult = MutableStateFlow?>(null) @@ -38,7 +41,13 @@ class AppSessionViewModel @Inject constructor( init { viewModelScope.launch { - bootstrapResult.value = coordinator.bootstrap() + val activeProfile = profileStore.activeProfile() + if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { + coordinator.switchProvider() + bootstrapResult.value = DomainResult.Success(null) + } else { + bootstrapResult.value = coordinator.bootstrap() + } } } @@ -48,6 +57,7 @@ class AppSessionViewModel @Inject constructor( bootstrapError: String?, ): AppSessionUiState = when { profile == null -> AppSessionUiState.ProviderSelection + availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection session != null -> AppSessionUiState.Authenticated(profile) else -> AppSessionUiState.Login(profile, bootstrapError) } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index e4fba9d..2baed17 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -18,20 +18,34 @@ import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.retryWhen +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { + private val refresh = MutableSharedFlow(extraBufferCapacity = 1) private val safeData: Flow - get() = dataStore.data.retryWhen { cause, attempt -> - if (cause !is IOException) return@retryWhen false - if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences()) - delay(READ_RETRY_DELAY_MILLIS) - true + get() = refresh + .onStart { emit(Unit) } + .flatMapLatest { readDataOnce() } + .distinctUntilChanged() + + private fun readDataOnce(): Flow = dataStore.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < MAX_READ_RETRIES + if (retry) delay(READ_RETRY_DELAY_MILLIS) + retry } + .catchIOExceptionAsEmpty() override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) @@ -53,6 +67,7 @@ class ProviderProfileStoreImpl( preferences[profileTypeKey(profile.id)] = profile.type.name preferences[profileNameKey(profile.id)] = profile.displayName } + refresh.tryEmit(Unit) } override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult = operation { @@ -60,6 +75,7 @@ class ProviderProfileStoreImpl( if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) else preferences[ACTIVE_PROFILE_ID] = profileId.value } + refresh.tryEmit(Unit) } override suspend fun saveLoginPreferences( @@ -73,6 +89,7 @@ class ProviderProfileStoreImpl( if (username == null) values.remove(usernameKey(profileId)) else values[usernameKey(profileId)] = username } + refresh.tryEmit(Unit) } private fun profilesFrom(preferences: Preferences): List = @@ -135,3 +152,7 @@ class ProviderProfileStoreImpl( booleanPreferencesKey("profile_${id.value}_auto_login") } } + +private fun Flow.catchIOExceptionAsEmpty(): Flow = catch { error -> + if (error is IOException) emit(emptyPreferences()) else throw error +} diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt index 1aa30ad..a767f07 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -1,11 +1,11 @@ package com.flagship.abox.manager.session import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey +import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import java.io.IOException import kotlinx.coroutines.flow.Flow @@ -13,6 +13,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -29,14 +32,29 @@ class ProviderProfileStoreFailureTest { assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) } + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Test - fun observerContinuesAfterStorageRecovers() = runTest { + fun observerResubscribesAfterSuccessfulWrite() = runTest { val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) + val emissions = mutableListOf() + val collector = backgroundScope.launch { + store.observeActiveProfile().take(2).toList(emissions) + } + advanceTimeBy(200) + advanceUntilIdle() + assertEquals(listOf(null), emissions) - val emissions = store.observeActiveProfile().take(2).toList() + val profile = ProviderProfile( + ProviderProfileId("recovered"), + ProviderType.LOCAL_EXPERIENCE, + "Recovered", + ) + store.saveProfile(profile) + store.setActiveProfile(profile.id) + advanceUntilIdle() + collector.join() - assertEquals(null, emissions[0]) - assertEquals("recovered", emissions[1]?.id?.value) + assertEquals(listOf(null, profile), emissions) } private class FailingReadDataStore : DataStore { @@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest { private class RecoveringReadDataStore : DataStore { private var attempts = 0 + private var current: Preferences = emptyPreferences() + override val data: Flow get() = flow { if (attempts++ < 3) throw IOException("temporarily unavailable") - val values: MutablePreferences = emptyPreferences().toMutablePreferences() - values[stringPreferencesKey("active_profile_id")] = "recovered" - values[androidx.datastore.preferences.core.stringSetPreferencesKey("profile_ids")] = - setOf("recovered") - values[stringPreferencesKey("profile_recovered_type")] = "LOCAL_EXPERIENCE" - values[stringPreferencesKey("profile_recovered_name")] = "Recovered" - emit(values) + emit(current) } override suspend fun updateData( transform: suspend (t: Preferences) -> Preferences, - ): Preferences = transform(emptyPreferences()) + ): Preferences { + current = transform(current) + return current + } } }