From cf2c59a49381e6235cc6725e2239773ce47fa79f Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 02:17:56 +0800 Subject: [PATCH] fix: keep provider state during storage outages --- .../ui/authenticated/AuthenticatedScreen.kt | 5 +- .../abox/manager/ui/login/LoginScreen.kt | 2 +- .../ui/provider/ProviderSelectionScreen.kt | 2 +- .../profile/ProviderProfileStoreImpl.kt | 40 ++++-------- .../ProviderProfileStoreFailureTest.kt | 64 +++---------------- 5 files changed, 28 insertions(+), 85 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt index 3015930..1df3284 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -45,8 +47,9 @@ fun AuthenticatedPlaceholderScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 520.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index c0adf70..39b2055 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -118,8 +118,8 @@ fun LoginScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 520.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt index aaeabdb..28528d0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -65,8 +65,8 @@ fun ProviderSelectionScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 640.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), 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 2baed17..f3333b9 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 @@ -4,7 +4,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flagship.abox.manager.domain.DomainResult @@ -18,35 +17,26 @@ 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() = refresh - .onStart { emit(Unit) } - .flatMapLatest { readDataOnce() } + get() = dataStore.data + .retryWhen { cause, attempt -> + if (cause !is IOException) return@retryWhen false + val delayMillis = (READ_RETRY_DELAY_MILLIS shl + attempt.coerceAtMost(MAX_BACKOFF_SHIFT).toInt()) + .coerceAtMost(MAX_READ_RETRY_DELAY_MILLIS) + delay(delayMillis) + true + } .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) override fun observeActiveProfile(): Flow = safeData.map(::activeProfileFrom) @@ -67,7 +57,6 @@ 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 { @@ -75,7 +64,6 @@ class ProviderProfileStoreImpl( if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) else preferences[ACTIVE_PROFILE_ID] = profileId.value } - refresh.tryEmit(Unit) } override suspend fun saveLoginPreferences( @@ -89,7 +77,6 @@ class ProviderProfileStoreImpl( if (username == null) values.remove(usernameKey(profileId)) else values[usernameKey(profileId)] = username } - refresh.tryEmit(Unit) } private fun profilesFrom(preferences: Preferences): List = @@ -131,8 +118,9 @@ class ProviderProfileStoreImpl( } companion object { - private const val MAX_READ_RETRIES = 2L - private const val READ_RETRY_DELAY_MILLIS = 50L + private const val READ_RETRY_DELAY_MILLIS = 100L + private const val MAX_READ_RETRY_DELAY_MILLIS = 5_000L + private const val MAX_BACKOFF_SHIFT = 6L private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") @@ -152,7 +140,3 @@ 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 a767f07..64e6a22 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 @@ -3,58 +3,32 @@ package com.flagship.abox.manager.session import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences -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 -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 kotlinx.coroutines.withTimeout +import org.junit.Assert.assertFalse import org.junit.Test class ProviderProfileStoreFailureTest { - @Test - fun ioFailureFallsBackToEmptyPreferences() = runTest { - val store = ProviderProfileStoreImpl(FailingReadDataStore()) - - assertEquals(emptyList(), store.observeProfiles().first()) - assertEquals(null, store.observeActiveProfile().first()) - assertEquals(emptyList(), store.profiles()) - assertEquals(null, store.activeProfile()) - assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) - } - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Test - fun observerResubscribesAfterSuccessfulWrite() = runTest { - val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) - val emissions = mutableListOf() - val collector = backgroundScope.launch { - store.observeActiveProfile().take(2).toList(emissions) + fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest { + val store = ProviderProfileStoreImpl(FailingReadDataStore()) + var completed = false + + backgroundScope.launch { + runCatching { withTimeout(250) { store.activeProfile() } } + completed = true } advanceTimeBy(200) - advanceUntilIdle() - assertEquals(listOf(null), emissions) - val profile = ProviderProfile( - ProviderProfileId("recovered"), - ProviderType.LOCAL_EXPERIENCE, - "Recovered", - ) - store.saveProfile(profile) - store.setActiveProfile(profile.id) - advanceUntilIdle() - collector.join() - - assertEquals(listOf(null, profile), emissions) + assertFalse(completed) } private class FailingReadDataStore : DataStore { @@ -66,22 +40,4 @@ class ProviderProfileStoreFailureTest { transform: suspend (t: Preferences) -> Preferences, ): Preferences = transform(emptyPreferences()) } - - 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") - emit(current) - } - - override suspend fun updateData( - transform: suspend (t: Preferences) -> Preferences, - ): Preferences { - current = transform(current) - return current - } - } }