feat: add provider login flow #4
+9
-6
@@ -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<Unit> = 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
|
||||
|
||||
|
||||
+8
-9
@@ -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,14 +26,11 @@ class ProviderProfileStoreImpl(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ProviderProfileStore {
|
||||
private val safeData: Flow<Preferences>
|
||||
|
|
||||
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<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||
@@ -106,6 +103,8 @@ class ProviderProfileStoreImpl(
|
||||
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
|
||||
block()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
com.flagship.abox.manager.domain.DomainError.Unknown(
|
||||
|
||||
+35
-1
@@ -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<Any>(), 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<Preferences> {
|
||||
@@ -33,4 +48,23 @@ class ProviderProfileStoreFailureTest {
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): 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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
无限重试
safeData遇到任何IOException都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,activeProfile()和loginPreferences()将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。启动失败
safeData在两次重试后仍会向调用方传播IOException,而启动流程中的activeProfile()和observeActiveProfile()均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,bootstrapResult也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。启动卡死
当 DataStore 连续读取失败并耗尽重试后,
safeData会继续抛出IOException。observeActiveProfile()是combine(...).stateIn(...)的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。