feat: add provider login flow #4
@@ -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,
|
||||||
|
|
|||||||
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()
|
||||||
|
pr-agent-bot
commented
隐私残留
**隐私残留**
`loadingJob` 完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在 `finally` 中清空任务并按持久化偏好重置表单。
<!-- pr-agent-dedup: 1cf17df19303 -->
<!-- pr-agent-key-issue-location: 727831ad4d47 -->
|
|||||||
profile = value
|
profile = value
|
||||||
_uiState.value = LoginUiState()
|
usernameEdited = false
|
||||||
|
pr-agent-bot
commented
状态丢失
**状态丢失**
`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)
|
||||||
|
pr-agent-bot
commented
输入被覆盖
**输入被覆盖**
`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
|
||||||
|
pr-agent-bot
commented
界面锁死
**界面锁死**
`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)) }
|
||||||
|
pr-agent-bot
commented
密钥失效
**密钥失效**
`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
|
||||||
|
}
|
||||||
|
}
|
||||||
密码输入
PasswordVisualTransformation只负责遮罩显示,不会将 AndroidEditorInfo标记为密码输入。当前字段使用默认键盘类型,启用学习或建议功能的第三方 IME 可能将密码作为普通文本缓存或显示为建议;请配置KeyboardOptions(keyboardType = KeyboardType.Password)并关闭自动更正。