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
4 changed files with 99 additions and 15 deletions
Showing only changes of commit 344fa6dafa - Show all commits
@@ -80,7 +80,8 @@ fun LoginScreen(
onSwitchProvider: () -> Unit, onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false, switchingProvider: Boolean = false,
) { ) {
val navigationEnabled = !state.isSubmitting && !switchingProvider val formEnabled = !state.isSubmitting && !state.isLoadingPreferences
val navigationEnabled = formEnabled && !switchingProvider
BackHandler { BackHandler {
if (navigationEnabled) onSwitchProvider() if (navigationEnabled) onSwitchProvider()
} }
@@ -140,7 +141,7 @@ fun LoginScreen(
value = state.username, value = state.username,
onValueChange = onUsernameChange, onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.login_username)) }, label = { Text(stringResource(R.string.login_username)) },
enabled = !state.isSubmitting, enabled = formEnabled,
singleLine = true, singleLine = true,
Outdated
Review

密码输入

PasswordVisualTransformation 只负责遮罩显示,不会将 Android EditorInfo 标记为密码输入。当前字段使用默认键盘类型,启用学习或建议功能的第三方 IME 可能将密码作为普通文本缓存或显示为建议;请配置 KeyboardOptions(keyboardType = KeyboardType.Password) 并关闭自动更正。

**密码输入** `PasswordVisualTransformation` 只负责遮罩显示,不会将 Android `EditorInfo` 标记为密码输入。当前字段使用默认键盘类型,启用学习或建议功能的第三方 IME 可能将密码作为普通文本缓存或显示为建议;请配置 `KeyboardOptions(keyboardType = KeyboardType.Password)` 并关闭自动更正。 <!-- pr-agent-dedup: 8f975e2d65eb --> <!-- pr-agent-key-issue-location: 8a30b6d499a0 -->
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -149,29 +150,31 @@ fun LoginScreen(
onValueChange = onPasswordChange, onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) }, label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
enabled = !state.isSubmitting, enabled = formEnabled,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
ToggleRow( ToggleRow(
label = stringResource(R.string.login_remember_username), label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername, checked = state.rememberUsername,
enabled = !state.isSubmitting, enabled = formEnabled,
onCheckedChange = onRememberUsernameChange, onCheckedChange = onRememberUsernameChange,
) )
ToggleRow( ToggleRow(
label = stringResource(R.string.login_auto_login), label = stringResource(R.string.login_auto_login),
checked = state.autoLogin, checked = state.autoLogin,
enabled = !state.isSubmitting, enabled = formEnabled,
onCheckedChange = onAutoLoginChange, onCheckedChange = onAutoLoginChange,
) )
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton( ABoxButton(
onClick = onSubmit, onClick = onSubmit,
enabled = !state.isSubmitting && !switchingProvider, enabled = formEnabled && !switchingProvider,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (state.isSubmitting || switchingProvider) ABoxLoadingIndicator() if (state.isSubmitting || state.isLoadingPreferences || switchingProvider) {
ABoxLoadingIndicator()
}
else Text(stringResource(R.string.action_login)) else Text(stringResource(R.string.action_login))
} }
ABoxButton( ABoxButton(
@@ -27,20 +27,39 @@ class LoginViewModel @Inject constructor(
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private var profile: ProviderProfile? = null private var profile: ProviderProfile? = null
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var usernameEdited = false
private var rememberUsernameEdited = false
private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) { fun loadProfile(value: ProviderProfile) {
val profileChanged = profile?.id != value.id val profileChanged = profile?.id != value.id
if (!profileChanged && loadingJob != null) return if (!profileChanged && loadingJob != null) return
loadingJob?.cancel() loadingJob?.cancel()
Outdated
Review

隐私残留

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

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

状态丢失

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

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

输入被覆盖

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

**输入被覆盖** `loginPreferences` 异步读取完成后会直接替换整个 `LoginUiState`。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。 <!-- pr-agent-dedup: 0d8efc2f0a77 --> <!-- pr-agent-key-issue-location: 043e23a2b991 -->
if (profile?.id != value.id) return@launch if (profile?.id != value.id) return@launch
_uiState.value = LoginUiState( _uiState.value = _uiState.value.copy(
username = preferences.rememberedUsername?.value.orEmpty(), username = if (usernameEdited) {
rememberUsername = preferences.rememberUsername, _uiState.value.username
Outdated
Review

界面锁死

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

**界面锁死** `loginPreferences` 抛出非 `IOException` 的存储或数据解析异常时没有被捕获,协程可能导致应用崩溃;即使异常被全局处理,`isLoadingPreferences` 也会保持为 `true`,从而永久禁用表单和返回提供方入口。异常路径应恢复加载状态并发布可重试的错误。 <!-- pr-agent-dedup: 66bba505d076 --> <!-- pr-agent-key-issue-location: 0e0d323640c8 -->
autoLogin = preferences.autoLoginEnabled, } 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,
) )
} }
} }
@@ -49,10 +68,14 @@ class LoginViewModel @Inject constructor(
loadingJob?.cancel() loadingJob?.cancel()
loadingJob = null loadingJob = null
profile = null profile = null
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState() _uiState.value = LoginUiState()
} }
fun setUsername(value: String) { fun setUsername(value: String) {
usernameEdited = true
_uiState.value = _uiState.value.copy(username = value, errorMessage = null) _uiState.value = _uiState.value.copy(username = value, errorMessage = null)
} }
@@ -61,17 +84,23 @@ class LoginViewModel @Inject constructor(
} }
fun setRememberUsername(value: Boolean) { fun setRememberUsername(value: Boolean) {
rememberUsernameEdited = true
_uiState.value = _uiState.value.copy(rememberUsername = value) _uiState.value = _uiState.value.copy(rememberUsername = value)
} }
fun setAutoLogin(value: Boolean) { fun setAutoLogin(value: Boolean) {
autoLoginEdited = true
_uiState.value = _uiState.value.copy(autoLogin = value) _uiState.value = _uiState.value.copy(autoLogin = value)
} }
fun submit() { fun submit() {
val activeProfile = profile ?: return val activeProfile = profile ?: return
val state = _uiState.value val state = _uiState.value
if (state.username.isBlank() || state.password.isEmpty() || state.isSubmitting) { if (state.username.isBlank() ||
state.password.isEmpty() ||
state.isSubmitting ||
state.isLoadingPreferences
) {
_uiState.value = state.copy(errorMessage = "Username and password are required") _uiState.value = state.copy(errorMessage = "Username and password are required")
return return
} }
@@ -104,6 +133,7 @@ data class LoginUiState(
val password: String = "", val password: String = "",
val rememberUsername: Boolean = false, val rememberUsername: Boolean = false,
val autoLogin: Boolean = false, val autoLogin: Boolean = false,
val isLoadingPreferences: Boolean = false,
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
) )
@@ -44,8 +44,16 @@ class KeystoreCredentialStore(
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: Exception) { } catch (error: Exception) {
if (error.isPermanentCredentialFailure()) { if (error.isPermanentCredentialFailure()) {
try {
dataStore.edit { it.remove(credentialKey(profileId)) } dataStore.edit { it.remove(credentialKey(profileId)) }
Outdated
Review

密钥失效

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

**密钥失效** `KeyPermanentlyInvalidatedException` 被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。 <!-- pr-agent-dedup: 4aa1d912b6ba --> <!-- pr-agent-key-issue-location: aa0611306fa7 -->
DomainResult.Success(null) DomainResult.Success(null)
} catch (cleanupError: Exception) {
DomainResult.Failure(
DomainError.Unknown(
detail = cleanupError.message ?: cleanupError::class.java.simpleName,
),
)
}
} else { } else {
DomainResult.Failure( DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
@@ -0,0 +1,43 @@
package com.flagship.abox.manager.session
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CredentialCleanupFailureTest {
@Test
fun cleanupFailureReturnsDomainFailureInsteadOfThrowing() = runTest {
val store = KeystoreCredentialStore(FailingCleanupDataStore(), UnusedCipher())
val result = store.loadCredentials(ProviderProfileId("profile"))
assertTrue(result is DomainResult.Failure)
assertEquals("disk unavailable", (result as DomainResult.Failure).error.detail)
}
private class FailingCleanupDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
throw IllegalArgumentException("corrupt credential")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw IOException("disk unavailable")
}
private class UnusedCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
}
}