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 com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -61,6 +62,15 @@ class LoginViewModel @Inject constructor(
}, },
isLoadingPreferences = false, 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 { } finally {
loadingJob = null 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.ProviderProfileStore
import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,7 +21,8 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class AppSessionViewModel @Inject constructor( class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator, private val coordinator: SessionCoordinator,
profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
private val availability: ProviderAvailability,
) : ViewModel() { ) : ViewModel() {
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null) private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
@@ -38,9 +41,15 @@ class AppSessionViewModel @Inject constructor(
init { init {
viewModelScope.launch { 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() bootstrapResult.value = coordinator.bootstrap()
} }
} }
}
private fun stateFor( private fun stateFor(
profile: ProviderProfile?, profile: ProviderProfile?,
@@ -48,6 +57,7 @@ class AppSessionViewModel @Inject constructor(
bootstrapError: String?, bootstrapError: String?,
): AppSessionUiState = when { ): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection profile == null -> AppSessionUiState.ProviderSelection
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
session != null -> AppSessionUiState.Authenticated(profile) session != null -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError) else -> AppSessionUiState.Login(profile, bootstrapError)
} }
@@ -18,20 +18,34 @@ import java.io.IOException
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow 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.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class ProviderProfileStoreImpl( class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore { ) : ProviderProfileStore {
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val safeData: Flow<Preferences> private val safeData: Flow<Preferences>
get() = dataStore.data.retryWhen { cause, attempt -> get() = refresh
if (cause !is IOException) return@retryWhen false .onStart { emit(Unit) }
if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences()) .flatMapLatest { readDataOnce() }
delay(READ_RETRY_DELAY_MILLIS) .distinctUntilChanged()
true
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) override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
@@ -53,6 +67,7 @@ class ProviderProfileStoreImpl(
preferences[profileTypeKey(profile.id)] = profile.type.name preferences[profileTypeKey(profile.id)] = profile.type.name
preferences[profileNameKey(profile.id)] = profile.displayName preferences[profileNameKey(profile.id)] = profile.displayName
} }
refresh.tryEmit(Unit)
} }
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation { override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
@@ -60,6 +75,7 @@ class ProviderProfileStoreImpl(
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value else preferences[ACTIVE_PROFILE_ID] = profileId.value
} }
refresh.tryEmit(Unit)
} }
override suspend fun saveLoginPreferences( override suspend fun saveLoginPreferences(
@@ -73,6 +89,7 @@ class ProviderProfileStoreImpl(
if (username == null) values.remove(usernameKey(profileId)) if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username else values[usernameKey(profileId)] = username
} }
refresh.tryEmit(Unit)
} }
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> = private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
@@ -135,3 +152,7 @@ class ProviderProfileStoreImpl(
booleanPreferencesKey("profile_${id.value}_auto_login") 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 package com.flagship.abox.manager.session
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences 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.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -13,6 +13,9 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList 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 kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -29,14 +32,29 @@ class ProviderProfileStoreFailureTest {
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
} }
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test @Test
fun observerContinuesAfterStorageRecovers() = runTest { fun observerResubscribesAfterSuccessfulWrite() = runTest {
val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) 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(listOf(null, profile), emissions)
assertEquals("recovered", emissions[1]?.id?.value)
} }
private class FailingReadDataStore : DataStore<Preferences> { private class FailingReadDataStore : DataStore<Preferences> {
@@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest {
private class RecoveringReadDataStore : DataStore<Preferences> { private class RecoveringReadDataStore : DataStore<Preferences> {
private var attempts = 0 private var attempts = 0
private var current: Preferences = emptyPreferences()
override val data: Flow<Preferences> override val data: Flow<Preferences>
get() = flow { get() = flow {
if (attempts++ < 3) throw IOException("temporarily unavailable") if (attempts++ < 3) throw IOException("temporarily unavailable")
val values: MutablePreferences = emptyPreferences().toMutablePreferences() emit(current)
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)
} }
override suspend fun updateData( override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences, transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences()) ): Preferences {
current = transform(current)
return current
}
} }
} }