fix: enforce provider availability on restore

This commit is contained in:
2026-08-24 00:33:21 +08:00
parent 03bf28afbc
commit 1c5caf5a6b
4 changed files with 79 additions and 21 deletions
@@ -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
}
@@ -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<DomainResult<Session?>?>(null)
@@ -38,9 +41,15 @@ class AppSessionViewModel @Inject constructor(
init {
viewModelScope.launch {
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()
}
}
}
private fun stateFor(
profile: ProviderProfile?,
@@ -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)
}
@@ -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<Preferences>,
) : ProviderProfileStore {
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val safeData: Flow<Preferences>
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<Preferences> = 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<List<ProviderProfile>> = 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<Unit> = 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<ProviderProfile> =
@@ -135,3 +152,7 @@ class ProviderProfileStoreImpl(
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->
if (error is IOException) emit(emptyPreferences()) else throw error
}
@@ -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<ProviderProfile?>()
val collector = backgroundScope.launch {
store.observeActiveProfile().take(2).toList(emissions)
}
advanceTimeBy(200)
advanceUntilIdle()
assertEquals(listOf<ProviderProfile?>(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<Preferences> {
@@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest {
private class RecoveringReadDataStore : DataStore<Preferences> {
private var attempts = 0
private var current: Preferences = emptyPreferences()
override val data: Flow<Preferences>
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
}
}
}