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

密钥失效

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

**密钥失效** `KeyPermanentlyInvalidatedException` 被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。 <!-- pr-agent-dedup: 4aa1d912b6ba --> <!-- pr-agent-key-issue-location: aa0611306fa7 -->
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 {
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() 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) {
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 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
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>, private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore { ) : ProviderProfileStore {
private val safeData: Flow<Preferences> 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 get() = dataStore.data.retryWhen { cause, attempt ->
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 -> if (cause !is IOException) return@retryWhen false
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 (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())
}
} }