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 18 additions and 3 deletions
Showing only changes of commit 8788fe6ec5 - Show all commits
@@ -21,13 +21,17 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -47,6 +51,9 @@ fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
Outdated
Review

敏感残留

LoginRoute 直接调用 hiltViewModel(),在当前没有导航条目作为 owner 的结构下,LoginViewModel 会由 Activity 持有。切换提供方离开登录界面时不会清除它,而新增的 clearSensitiveState() 也没有调用点,因此已输入的明文密码会在提供方选择页期间继续保存在内存中,直到再次加载配置或 Activity 销毁。请在路由退出时显式清理敏感状态,或将 ViewModel 作用域绑定到登录路由。

**敏感残留** `LoginRoute` 直接调用 `hiltViewModel()`,在当前没有导航条目作为 owner 的结构下,`LoginViewModel` 会由 Activity 持有。切换提供方离开登录界面时不会清除它,而新增的 `clearSensitiveState()` 也没有调用点,因此已输入的明文密码会在提供方选择页期间继续保存在内存中,直到再次加载配置或 Activity 销毁。请在路由退出时显式清理敏感状态,或将 ViewModel 作用域绑定到登录路由。 <!-- pr-agent-dedup: 799e2db7ca16 --> <!-- pr-agent-key-issue-location: 69b9875f2098 -->
) { ) {
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 -->
DisposableEffect(profile.id) {
onDispose(viewModel::clearSensitiveState)
}
val state = viewModel.uiState.collectAsStateWithLifecycle().value val state = viewModel.uiState.collectAsStateWithLifecycle().value
LoginScreen( LoginScreen(
profile = profile, profile = profile,
@@ -146,6 +153,11 @@ fun LoginScreen(
onValueChange = onPasswordChange, onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) }, label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrect = false,
),
enabled = formEnabled, enabled = formEnabled,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -43,12 +43,12 @@ class ProviderProfileStoreImpl(
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> = override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
safeData.map { preferencesFrom(it, profileId) } safeData.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first()) override suspend fun profiles(): List<ProviderProfile> = profilesFrom(safeData.first())
Outdated
Review

读取容错

一次性读取 profiles()、activeProfile() 和 loginPreferences() 时直接收集 dataStore.data,绕过了本类已有的 safeData 重试和 IOException 降级。存储首次读取发生临时 IOException 时,登录偏好加载会异常退出;LoginViewModel 未捕获该异常且仍保持 isLoadingPreferences=true,导致登录表单和返回操作持续禁用,默认协程异常处理下还可能使应用崩溃。请改用 safeData.first() 或在调用层恢复状态。

**读取容错** 一次性读取 `profiles()`、`activeProfile()` 和 `loginPreferences()` 时直接收集 `dataStore.data`,绕过了本类已有的 `safeData` 重试和 IOException 降级。存储首次读取发生临时 `IOException` 时,登录偏好加载会异常退出;`LoginViewModel` 未捕获该异常且仍保持 `isLoadingPreferences=true`,导致登录表单和返回操作持续禁用,默认协程异常处理下还可能使应用崩溃。请改用 `safeData.first()` 或在调用层恢复状态。 <!-- pr-agent-dedup: 0d6f549115d7 --> <!-- pr-agent-key-issue-location: 9433a5573de7 -->
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(dataStore.data.first()) override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first())
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences = override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferencesFrom(dataStore.data.first(), profileId) preferencesFrom(safeData.first(), profileId)
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation { override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
dataStore.edit { preferences -> dataStore.edit { preferences ->
@@ -19,6 +19,9 @@ class ProviderProfileStoreFailureTest {
assertEquals(emptyList<Any>(), store.observeProfiles().first()) assertEquals(emptyList<Any>(), store.observeProfiles().first())
assertEquals(null, store.observeActiveProfile().first()) 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)
} }
private class FailingReadDataStore : DataStore<Preferences> { private class FailingReadDataStore : DataStore<Preferences> {