feat: add provider login flow #4
@@ -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 {
|
||||||
|
|
|||||||
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 {
|
||||||
|
pr-agent-bot
commented
取消被吞
**取消被吞**
`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) {
|
||||||
|
pr-agent-bot
commented
凭据误删
**凭据误删**
`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
|
||||||
|
pr-agent-bot
commented
启动失败
**启动失败**
`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>
|
||||||
|
pr-agent-bot
commented
无限重试
**无限重试**
`safeData` 遇到任何 `IOException` 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,`activeProfile()` 和 `loginPreferences()` 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。
<!-- pr-agent-dedup: 4389fff2f610 -->
<!-- pr-agent-key-issue-location: b2da98684d7a -->
pr-agent-bot
commented
启动失败
**启动失败**
`safeData` 在两次重试后仍会向调用方传播 `IOException`,而启动流程中的 `activeProfile()` 和 `observeActiveProfile()` 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,`bootstrapResult` 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。
<!-- pr-agent-dedup: 7302d3a589b8 -->
<!-- pr-agent-key-issue-location: db2a740c074a -->
pr-agent-bot
commented
启动卡死 当 DataStore 连续读取失败并耗尽重试后, **启动卡死**
当 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 ->
|
||||||
|
pr-agent-bot
commented
无限重试 达到 **无限重试**
达到 `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
|
||||||
|
pr-agent-bot
commented
观察流终止 当 **观察流终止**
当 `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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
密钥失效
KeyPermanentlyInvalidatedException被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。