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
5 changed files with 83 additions and 30 deletions
Showing only changes of commit 7cb0480a7a - Show all commits
@@ -48,10 +48,6 @@ fun LoginRoute(
) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
Outdated
Review

状态失效

切换提供方前立即执行了 clearSensitiveState()。当 switchProvider() 因 DataStore 写入等原因失败且活动提供方未改变时,LaunchedEffect(profile.id) 的键也不会变化,因此不会重新加载;LoginViewModel 内部的 profile 已被置空,之后点击登录会在 submit() 中静默返回。应仅在切换成功后清理,或在失败时恢复并重新加载当前 profile。

**状态失效** 切换提供方前立即执行了 `clearSensitiveState()`。当 `switchProvider()` 因 DataStore 写入等原因失败且活动提供方未改变时,`LaunchedEffect(profile.id)` 的键也不会变化,因此不会重新加载;`LoginViewModel` 内部的 `profile` 已被置空,之后点击登录会在 `submit()` 中静默返回。应仅在切换成功后清理,或在失败时恢复并重新加载当前 profile。 <!-- pr-agent-dedup: 6eb79e02e8da --> <!-- pr-agent-key-issue-location: 18d2c966c6d1 -->
val state = viewModel.uiState.collectAsStateWithLifecycle().value
val switchProvider = {
viewModel.clearSensitiveState()
onSwitchProvider()
}
Review

状态丢失

DisposableEffect 的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的 LoginViewModel 会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。

**状态丢失** `DisposableEffect` 的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的 `LoginViewModel` 会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。 <!-- pr-agent-dedup: 15fff7c847b0 --> <!-- pr-agent-key-issue-location: 1343b434afe8 -->
LoginScreen(
profile = profile,
state = state.copy(
@@ -62,7 +58,7 @@ fun LoginRoute(
onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit,
onSwitchProvider = switchProvider,
onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress,
)
}
@@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor(
private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) {
Outdated
Review

隐私残留

loadingJob 完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在 finally 中清空任务并按持久化偏好重置表单。

**隐私残留** `loadingJob` 完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在 `finally` 中清空任务并按持久化偏好重置表单。 <!-- pr-agent-dedup: 1cf17df19303 --> <!-- pr-agent-key-issue-location: 727831ad4d47 -->
val profileChanged = profile?.id != value.id
if (!profileChanged && loadingJob != null) return
Review

状态丢失

LoginRoute 重新进入组合时会再次调用 loadProfile。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的 LoginUiState 覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。

**状态丢失** `LoginRoute` 重新进入组合时会再次调用 `loadProfile`。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的 `LoginUiState` 覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。 <!-- pr-agent-dedup: 3e11ec9ce7f1 --> <!-- pr-agent-key-issue-location: 75605d605f67 -->
if (profile?.id == value.id && loadingJob?.isActive == true) return
loadingJob?.cancel()
profile = value
Outdated
Review

输入被覆盖

loginPreferences 异步读取完成后会直接替换整个 LoginUiState。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。

**输入被覆盖** `loginPreferences` 异步读取完成后会直接替换整个 `LoginUiState`。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。 <!-- pr-agent-dedup: 0d8efc2f0a77 --> <!-- pr-agent-key-issue-location: 043e23a2b991 -->
usernameEdited = false
@@ -41,26 +40,30 @@ class LoginViewModel @Inject constructor(
autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true)
Outdated
Review

界面锁死

loginPreferences 抛出非 IOException 的存储或数据解析异常时没有被捕获,协程可能导致应用崩溃;即使异常被全局处理,isLoadingPreferences 也会保持为 true,从而永久禁用表单和返回提供方入口。异常路径应恢复加载状态并发布可重试的错误。

**界面锁死** `loginPreferences` 抛出非 `IOException` 的存储或数据解析异常时没有被捕获,协程可能导致应用崩溃;即使异常被全局处理,`isLoadingPreferences` 也会保持为 `true`,从而永久禁用表单和返回提供方入口。异常路径应恢复加载状态并发布可重试的错误。 <!-- pr-agent-dedup: 66bba505d076 --> <!-- pr-agent-key-issue-location: 0e0d323640c8 -->
loadingJob = viewModelScope.launch {
val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy(
username = if (usernameEdited) {
_uiState.value.username
} else {
preferences.rememberedUsername?.value.orEmpty()
},
rememberUsername = if (rememberUsernameEdited) {
_uiState.value.rememberUsername
} else {
preferences.rememberUsername
},
autoLogin = if (autoLoginEdited) {
_uiState.value.autoLogin
} else {
preferences.autoLoginEnabled
},
isLoadingPreferences = false,
)
try {
val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy(
username = if (usernameEdited) {
_uiState.value.username
} else {
preferences.rememberedUsername?.value.orEmpty()
},
rememberUsername = if (rememberUsernameEdited) {
_uiState.value.rememberUsername
} else {
preferences.rememberUsername
},
autoLogin = if (autoLoginEdited) {
_uiState.value.autoLogin
} else {
preferences.autoLoginEnabled
},
isLoadingPreferences = false,
)
} finally {
loadingJob = null
}
}
}
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
@@ -13,19 +14,34 @@ import com.flagship.abox.manager.domain.ProviderProfileId
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.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
class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore {
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom)
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 -->
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
}
override fun observeActiveProfile(): Flow<ProviderProfile?> = dataStore.data.map(::activeProfileFrom)
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
dataStore.data.map { preferencesFrom(it, profileId) }
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 -->
safeData.map { preferencesFrom(it, profileId) }
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 -->
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
Outdated
Review

观察流终止

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

**观察流终止** 当 `DataStore` 的读取 `IOException` 超过重试上限时,`catch` 发出一次空首选项后会结束整个冷流。`observeActiveProfile()` 等长期订阅不会在存储恢复后继续接收更新;例如用户重新选择提供方且写入成功,应用仍可能停在提供方选择界面,直到进程重启。应让观察流继续重试或重新订阅,而不是以空值结束。 <!-- pr-agent-dedup: 1ff2c2415377 --> <!-- pr-agent-key-issue-location: cf47b7bb3191 -->
@@ -99,6 +115,8 @@ class ProviderProfileStoreImpl(
}
companion object {
private const val MAX_READ_RETRIES = 2L
private const val READ_RETRY_DELAY_MILLIS = 50L
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.session.profile
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
@@ -0,0 +1,33 @@
package com.flagship.abox.manager.session
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
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.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ProviderProfileStoreFailureTest {
@Test
fun ioFailureFallsBackToEmptyPreferences() = runTest {
val store = ProviderProfileStoreImpl(FailingReadDataStore())
assertEquals(emptyList<Any>(), store.observeProfiles().first())
assertEquals(null, store.observeActiveProfile().first())
}
private class FailingReadDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
throw IOException("disk unavailable")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences())
}
}