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 {
Outdated
Review

密钥失效

KeyPermanentlyInvalidatedException 被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。

**密钥失效** `KeyPermanentlyInvalidatedException` 被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。 <!-- pr-agent-dedup: 4aa1d912b6ba --> <!-- pr-agent-key-issue-location: aa0611306fa7 -->
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 {
Outdated
Review

取消被吞

catch (Exception) 会捕获 CancellationException。当登录或凭据持久化协程因 ViewModel 销毁等原因被取消时,loadCredentials() 和 operation() 会把取消转换成普通 DomainResult.Failure,使上层协调器可能继续执行后续状态或持久化步骤。应优先重新抛出 CancellationException。

**取消被吞** `catch (Exception)` 会捕获 `CancellationException`。当登录或凭据持久化协程因 ViewModel 销毁等原因被取消时,`loadCredentials()` 和 `operation()` 会把取消转换成普通 `DomainResult.Failure`,使上层协调器可能继续执行后续状态或持久化步骤。应优先重新抛出 `CancellationException`。 <!-- pr-agent-dedup: f1252c6e85af --> <!-- pr-agent-key-issue-location: 1fd8caaa1d86 -->
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) {
Outdated
Review

凭据误删

isPermanentCredentialFailure() 将所有 KeyStoreException、UnrecoverableKeyException 和 InvalidKeyException 都视为永久损坏,但这些异常也可能包装 Android Keystore 的瞬时服务或硬件错误。此时 loadCredentials() 会删除唯一密文并返回无凭据,导致一次暂时故障永久清除自动登录信息。仅应对格式损坏、认证标签失败或已确认永久失效的密钥执行清理。

**凭据误删** `isPermanentCredentialFailure()` 将所有 `KeyStoreException`、`UnrecoverableKeyException` 和 `InvalidKeyException` 都视为永久损坏,但这些异常也可能包装 Android Keystore 的瞬时服务或硬件错误。此时 `loadCredentials()` 会删除唯一密文并返回无凭据,导致一次暂时故障永久清除自动登录信息。仅应对格式损坏、认证标签失败或已确认永久失效的密钥执行清理。 <!-- pr-agent-dedup: 1ace7f5bdcc7 --> <!-- pr-agent-key-issue-location: 3992d1af8bc4 -->
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
Outdated
Review

启动失败

observeActiveProfile() 等流直接暴露 DataStore.data。Preferences DataStore 在发生 I/O 错误或数据损坏时会抛出异常,而启动状态通过 stateIn 收集且没有 catch 或重试;一次读取失败便会终止启动流,甚至成为未捕获的协程异常,使应用无法进入提供方选择或登录页。应配置数据损坏处理,并对可恢复的读取错误进行重试或映射。

**启动失败** `observeActiveProfile()` 等流直接暴露 `DataStore.data`。Preferences DataStore 在发生 I/O 错误或数据损坏时会抛出异常,而启动状态通过 `stateIn` 收集且没有 `catch` 或重试;一次读取失败便会终止启动流,甚至成为未捕获的协程异常,使应用无法进入提供方选择或登录页。应配置数据损坏处理,并对可恢复的读取错误进行重试或映射。 <!-- pr-agent-dedup: f54a64dadb27 --> <!-- pr-agent-key-issue-location: 477f7fbd6ced -->
@@ -26,14 +26,11 @@ 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
Outdated
Review

无限重试

达到 MAX_READ_RETRIES 后虽然发射了空配置,但谓词仍返回 true,因此持续性 IOException 会让每个长期订阅者以约 50 毫秒的间隔无限重读并反复发射空配置,造成电量消耗和会话界面抖动。达到上限后应停止重试,并通过 catch 等方式仅发射一次回退值。

**无限重试** 达到 `MAX_READ_RETRIES` 后虽然发射了空配置,但谓词仍返回 `true`,因此持续性 `IOException` 会让每个长期订阅者以约 50 毫秒的间隔无限重读并反复发射空配置,造成电量消耗和会话界面抖动。达到上限后应停止重试,并通过 `catch` 等方式仅发射一次回退值。 <!-- pr-agent-dedup: 0b718cc7f1c2 --> <!-- pr-agent-key-issue-location: 701272b74db5 -->
.retryWhen { cause, attempt ->
Outdated
Review

观察流终止

当 DataStore 的读取 IOException 超过重试上限时,catch 发出一次空首选项后会结束整个冷流。observeActiveProfile() 等长期订阅不会在存储恢复后继续接收更新;例如用户重新选择提供方且写入成功,应用仍可能停在提供方选择界面,直到进程重启。应让观察流继续重试或重新订阅,而不是以空值结束。

**观察流终止** 当 `DataStore` 的读取 `IOException` 超过重试上限时,`catch` 发出一次空首选项后会结束整个冷流。`observeActiveProfile()` 等长期订阅不会在存储恢复后继续接收更新;例如用户重新选择提供方且写入成功,应用仍可能停在提供方选择界面,直到进程重启。应让观察流继续重试或重新订阅,而不是以空值结束。 <!-- pr-agent-dedup: 1ff2c2415377 --> <!-- pr-agent-key-issue-location: cf47b7bb3191 -->
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())
}
}