From 03bf28afbc6aed8c11cf936bf1cb92fbd9d6a2da Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:06:59 +0800 Subject: [PATCH] fix: preserve session storage through transient failures --- .../credential/KeystoreCredentialStore.kt | 15 ++++---- .../profile/ProviderProfileStoreImpl.kt | 19 +++++----- .../ProviderProfileStoreFailureTest.kt | 36 ++++++++++++++++++- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index 111915f..53f15b0 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -15,11 +15,10 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream -import java.security.InvalidKeyException -import java.security.KeyStoreException -import java.security.UnrecoverableKeyException +import android.security.keystore.KeyPermanentlyInvalidatedException import java.util.Base64 import javax.crypto.AEADBadTagException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first class KeystoreCredentialStore( @@ -42,11 +41,15 @@ class KeystoreCredentialStore( ?: return DomainResult.Success(null) val encrypted = Base64.getDecoder().decode(value) DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { if (error.isPermanentCredentialFailure()) { try { dataStore.edit { it.remove(credentialKey(profileId)) } DomainResult.Success(null) + } catch (cleanupError: CancellationException) { + throw cleanupError } catch (cleanupError: Exception) { DomainResult.Failure( DomainError.Unknown( @@ -86,6 +89,8 @@ class KeystoreCredentialStore( private suspend fun operation(block: suspend () -> Unit): DomainResult = try { block() DomainResult.Success(Unit) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { DomainResult.Failure( DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), @@ -100,9 +105,7 @@ class KeystoreCredentialStore( private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { is AEADBadTagException, - is InvalidKeyException, - is KeyStoreException, - is UnrecoverableKeyException, + is KeyPermanentlyInvalidatedException, is IllegalArgumentException, -> true 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 23ac9e9..e4fba9d 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 @@ -15,9 +15,9 @@ import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.Username 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.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen @@ -26,15 +26,12 @@ class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { private val safeData: Flow - get() = dataStore.data - .retryWhen { cause, attempt -> - val retry = cause is IOException && attempt < MAX_READ_RETRIES - if (retry) delay(READ_RETRY_DELAY_MILLIS) - retry - } - .catch { error -> - if (error is IOException) emit(emptyPreferences()) else throw error - } + 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 + } override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) @@ -106,6 +103,8 @@ class ProviderProfileStoreImpl( private suspend fun operation(block: suspend () -> Unit): DomainResult = try { block() DomainResult.Success(Unit) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { DomainResult.Failure( com.flagship.abox.manager.domain.DomainError.Unknown( 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 779e572..1aa30ad 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,13 +1,18 @@ 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.ProviderProfileId 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.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -21,7 +26,17 @@ class ProviderProfileStoreFailureTest { assertEquals(null, store.observeActiveProfile().first()) assertEquals(emptyList(), store.profiles()) 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 { @@ -33,4 +48,23 @@ class ProviderProfileStoreFailureTest { transform: suspend (t: Preferences) -> Preferences, ): Preferences = transform(emptyPreferences()) } + + private class RecoveringReadDataStore : DataStore { + private var attempts = 0 + 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) + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(emptyPreferences()) + } }