fix: preserve session storage through transient failures

This commit is contained in:
2026-08-24 00:06:59 +08:00
parent 8788fe6ec5
commit 03bf28afbc
3 changed files with 53 additions and 17 deletions
@@ -15,11 +15,10 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.DataInputStream import java.io.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.security.InvalidKeyException import android.security.keystore.KeyPermanentlyInvalidatedException
import java.security.KeyStoreException
import java.security.UnrecoverableKeyException
import java.util.Base64 import java.util.Base64
import javax.crypto.AEADBadTagException import javax.crypto.AEADBadTagException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
class KeystoreCredentialStore( class KeystoreCredentialStore(
@@ -42,11 +41,15 @@ class KeystoreCredentialStore(
?: return DomainResult.Success(null) ?: return DomainResult.Success(null)
val encrypted = Base64.getDecoder().decode(value) val encrypted = Base64.getDecoder().decode(value)
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: CancellationException) {
throw error
} catch (error: Exception) { } catch (error: Exception) {
if (error.isPermanentCredentialFailure()) { if (error.isPermanentCredentialFailure()) {
try { try {
dataStore.edit { it.remove(credentialKey(profileId)) } dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null) DomainResult.Success(null)
} catch (cleanupError: CancellationException) {
throw cleanupError
} catch (cleanupError: Exception) { } catch (cleanupError: Exception) {
DomainResult.Failure( DomainResult.Failure(
DomainError.Unknown( DomainError.Unknown(
@@ -86,6 +89,8 @@ class KeystoreCredentialStore(
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try { private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block() block()
DomainResult.Success(Unit) DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) { } catch (error: Exception) {
DomainResult.Failure( DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
@@ -100,9 +105,7 @@ class KeystoreCredentialStore(
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException, is AEADBadTagException,
is InvalidKeyException, is KeyPermanentlyInvalidatedException,
is KeyStoreException,
is UnrecoverableKeyException,
is IllegalArgumentException, is IllegalArgumentException,
-> true -> true
@@ -15,9 +15,9 @@ import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import java.io.IOException import java.io.IOException
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.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
@@ -26,14 +26,11 @@ class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore { ) : ProviderProfileStore {
private val safeData: Flow<Preferences> private val safeData: Flow<Preferences>
get() = dataStore.data get() = dataStore.data.retryWhen { cause, attempt ->
.retryWhen { cause, attempt -> if (cause !is IOException) return@retryWhen false
val retry = cause is IOException && attempt < MAX_READ_RETRIES if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences())
if (retry) delay(READ_RETRY_DELAY_MILLIS) delay(READ_RETRY_DELAY_MILLIS)
retry true
}
.catch { error ->
if (error is IOException) emit(emptyPreferences()) else throw error
} }
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom) override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
@@ -106,6 +103,8 @@ class ProviderProfileStoreImpl(
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try { private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block() block()
DomainResult.Success(Unit) DomainResult.Success(Unit)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) { } catch (error: Exception) {
DomainResult.Failure( DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown( com.flagship.abox.manager.domain.DomainError.Unknown(
@@ -1,13 +1,18 @@
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.ProviderProfileId
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
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
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
@@ -21,7 +26,17 @@ class ProviderProfileStoreFailureTest {
assertEquals(null, store.observeActiveProfile().first()) assertEquals(null, store.observeActiveProfile().first())
assertEquals(emptyList<Any>(), store.profiles()) assertEquals(emptyList<Any>(), store.profiles())
assertEquals(null, store.activeProfile()) assertEquals(null, store.activeProfile())
assertEquals(false, store.loginPreferences(com.flagship.abox.manager.domain.ProviderProfileId("profile")).autoLoginEnabled) assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
}
@Test
fun observerContinuesAfterStorageRecovers() = runTest {
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
val emissions = store.observeActiveProfile().take(2).toList()
assertEquals(null, emissions[0])
assertEquals("recovered", emissions[1]?.id?.value)
} }
private class FailingReadDataStore : DataStore<Preferences> { private class FailingReadDataStore : DataStore<Preferences> {
@@ -33,4 +48,23 @@ class ProviderProfileStoreFailureTest {
transform: suspend (t: Preferences) -> Preferences, transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences()) ): Preferences = transform(emptyPreferences())
} }
private class RecoveringReadDataStore : DataStore<Preferences> {
private var attempts = 0
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)
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences())
}
} }