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) } 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 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( LoginScreen(
profile = profile, profile = profile,
state = state.copy( state = state.copy(
@@ -62,7 +58,7 @@ fun LoginRoute(
onRememberUsernameChange = viewModel::setRememberUsername, onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin, onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit, onSubmit = viewModel::submit,
onSwitchProvider = switchProvider, onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress, switchingProvider = actionInProgress,
) )
} }
@@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor(
private var autoLoginEdited = false private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) { 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 (profile?.id == value.id && loadingJob?.isActive == true) return
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 -->
loadingJob?.cancel() loadingJob?.cancel()
profile = value profile = value
Outdated
Review

输入被覆盖

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

**输入被覆盖** `loginPreferences` 异步读取完成后会直接替换整个 `LoginUiState`。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。 <!-- pr-agent-dedup: 0d8efc2f0a77 --> <!-- pr-agent-key-issue-location: 043e23a2b991 -->
usernameEdited = false usernameEdited = false
@@ -41,6 +40,7 @@ class LoginViewModel @Inject constructor(
autoLoginEdited = false autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true) _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 { loadingJob = viewModelScope.launch {
try {
val preferences = profileStore.loginPreferences(value.id) val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -61,6 +61,9 @@ class LoginViewModel @Inject constructor(
}, },
isLoadingPreferences = false, 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.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult 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.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 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
class ProviderProfileStoreImpl( class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore { ) : ProviderProfileStore {
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom) private val safeData: Flow<Preferences>
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 -->
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> = override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
dataStore.data.map { preferencesFrom(it, profileId) } safeData.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 -->
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()) 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 { 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 PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") 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 android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session" private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore( val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME, 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())
}
}