feat: add provider login flow #4

Merged
KremeCN merged 26 commits from feat/provider-login into main 2026-08-25 01:59:00 +08:00
3 changed files with 53 additions and 17 deletions
Showing only changes of commit 03bf28afbc - Show all commits
@@ -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
@@ -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<Preferences>,
) : ProviderProfileStore {
private val safeData: Flow<Preferences>
Review

无限重试

safeData 遇到任何 IOException 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,activeProfile() 和 loginPreferences() 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。

**无限重试** `safeData` 遇到任何 `IOException` 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,`activeProfile()` 和 `loginPreferences()` 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。 <!-- pr-agent-dedup: 4389fff2f610 --> <!-- pr-agent-key-issue-location: b2da98684d7a -->
Review

启动失败

safeData 在两次重试后仍会向调用方传播 IOException,而启动流程中的 activeProfile() 和 observeActiveProfile() 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,bootstrapResult 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。

**启动失败** `safeData` 在两次重试后仍会向调用方传播 `IOException`,而启动流程中的 `activeProfile()` 和 `observeActiveProfile()` 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,`bootstrapResult` 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。 <!-- pr-agent-dedup: 7302d3a589b8 --> <!-- pr-agent-key-issue-location: db2a740c074a -->
Review

启动卡死

当 DataStore 连续读取失败并耗尽重试后,safeData 会继续抛出 IOException。observeActiveProfile() 是 combine(...).stateIn(...) 的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。

**启动卡死** 当 DataStore 连续读取失败并耗尽重试后,`safeData` 会继续抛出 `IOException`。`observeActiveProfile()` 是 `combine(...).stateIn(...)` 的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。 <!-- pr-agent-dedup: 31fd4d3bef7b --> <!-- pr-agent-key-issue-location: 2275a87bf139 -->
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(
@@ -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())
}
}