feat: add provider login flow #4
+23
@@ -53,6 +53,29 @@ class LoginScreenTest {
|
|||||||
assertEquals(2, switches)
|
assertEquals(2, switches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun systemBackIsConsumedWhileSubmitting() {
|
||||||
|
var switches = 0
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
LoginScreen(
|
||||||
|
profile = profile,
|
||||||
|
state = LoginUiState(isSubmitting = true),
|
||||||
|
onUsernameChange = {},
|
||||||
|
onPasswordChange = {},
|
||||||
|
onRememberUsernameChange = {},
|
||||||
|
onAutoLoginChange = {},
|
||||||
|
onSubmit = {},
|
||||||
|
onSwitchProvider = { switches++ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.activity.onBackPressedDispatcher.onBackPressed()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
assertEquals(0, switches)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun backControlsAreDisabledWhileSubmitting() {
|
fun backControlsAreDisabledWhileSubmitting() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ fun LoginRoute(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
||||||
|
|
|||||||
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val switchProvider = {
|
||||||
|
viewModel.clearSensitiveState()
|
||||||
|
onSwitchProvider()
|
||||||
|
}
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
profile = profile,
|
profile = profile,
|
||||||
state = state.copy(
|
state = state.copy(
|
||||||
@@ -58,7 +62,7 @@ fun LoginRoute(
|
|||||||
onRememberUsernameChange = viewModel::setRememberUsername,
|
onRememberUsernameChange = viewModel::setRememberUsername,
|
||||||
onAutoLoginChange = viewModel::setAutoLogin,
|
onAutoLoginChange = viewModel::setAutoLogin,
|
||||||
onSubmit = viewModel::submit,
|
onSubmit = viewModel::submit,
|
||||||
onSwitchProvider = onSwitchProvider,
|
onSwitchProvider = switchProvider,
|
||||||
switchingProvider = actionInProgress,
|
switchingProvider = actionInProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,7 +81,9 @@ fun LoginScreen(
|
|||||||
switchingProvider: Boolean = false,
|
switchingProvider: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val navigationEnabled = !state.isSubmitting && !switchingProvider
|
val navigationEnabled = !state.isSubmitting && !switchingProvider
|
||||||
BackHandler(enabled = navigationEnabled, onBack = onSwitchProvider)
|
BackHandler {
|
||||||
|
if (navigationEnabled) onSwitchProvider()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|||||||
@@ -29,11 +29,15 @@ class LoginViewModel @Inject constructor(
|
|||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
|
||||||
fun loadProfile(value: ProviderProfile) {
|
fun loadProfile(value: ProviderProfile) {
|
||||||
if (profile?.id == value.id && loadingJob != null) return
|
val profileChanged = profile?.id != value.id
|
||||||
|
if (!profileChanged && loadingJob != null) return
|
||||||
|
loadingJob?.cancel()
|
||||||
profile = value
|
profile = value
|
||||||
|
_uiState.value = LoginUiState()
|
||||||
loadingJob = viewModelScope.launch {
|
loadingJob = viewModelScope.launch {
|
||||||
|
pr-agent-bot
commented
隐私残留
**隐私残留**
`loadingJob` 完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在 `finally` 中清空任务并按持久化偏好重置表单。
<!-- pr-agent-dedup: 1cf17df19303 -->
<!-- pr-agent-key-issue-location: 727831ad4d47 -->
|
|||||||
val preferences = profileStore.loginPreferences(value.id)
|
val preferences = profileStore.loginPreferences(value.id)
|
||||||
_uiState.value = _uiState.value.copy(
|
if (profile?.id != value.id) return@launch
|
||||||
|
pr-agent-bot
commented
状态丢失
**状态丢失**
`LoginRoute` 重新进入组合时会再次调用 `loadProfile`。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的 `LoginUiState` 覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。
<!-- pr-agent-dedup: 3e11ec9ce7f1 -->
<!-- pr-agent-key-issue-location: 75605d605f67 -->
|
|||||||
|
_uiState.value = LoginUiState(
|
||||||
username = preferences.rememberedUsername?.value.orEmpty(),
|
username = preferences.rememberedUsername?.value.orEmpty(),
|
||||||
rememberUsername = preferences.rememberUsername,
|
rememberUsername = preferences.rememberUsername,
|
||||||
|
pr-agent-bot
commented
输入被覆盖
**输入被覆盖**
`loginPreferences` 异步读取完成后会直接替换整个 `LoginUiState`。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。
<!-- pr-agent-dedup: 0d8efc2f0a77 -->
<!-- pr-agent-key-issue-location: 043e23a2b991 -->
|
|||||||
autoLogin = preferences.autoLoginEnabled,
|
autoLogin = preferences.autoLoginEnabled,
|
||||||
@@ -41,6 +45,13 @@ class LoginViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pr-agent-bot
commented
界面锁死
**界面锁死**
`loginPreferences` 抛出非 `IOException` 的存储或数据解析异常时没有被捕获,协程可能导致应用崩溃;即使异常被全局处理,`isLoadingPreferences` 也会保持为 `true`,从而永久禁用表单和返回提供方入口。异常路径应恢复加载状态并发布可重试的错误。
<!-- pr-agent-dedup: 66bba505d076 -->
<!-- pr-agent-key-issue-location: 0e0d323640c8 -->
|
|||||||
|
|
||||||
|
fun clearSensitiveState() {
|
||||||
|
loadingJob?.cancel()
|
||||||
|
loadingJob = null
|
||||||
|
profile = null
|
||||||
|
_uiState.value = LoginUiState()
|
||||||
|
}
|
||||||
|
|
||||||
fun setUsername(value: String) {
|
fun setUsername(value: String) {
|
||||||
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
|
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -24,4 +24,11 @@ class AndroidKeystoreCredentialCipherTest {
|
|||||||
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
|
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
|
||||||
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
|
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun rejectsTruncatedPayloadBeforeReadingHeader() {
|
||||||
|
AndroidKeystoreCredentialCipher(
|
||||||
|
keyAlias = "abox_manager_test_${System.nanoTime()}",
|
||||||
|
).decrypt(ProviderProfileId("profile"), byteArrayOf(1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -27,6 +27,7 @@ class AndroidKeystoreCredentialCipher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||||
|
require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" }
|
||||||
val buffer = ByteBuffer.wrap(encrypted)
|
val buffer = ByteBuffer.wrap(encrypted)
|
||||||
require(buffer.get() == VERSION) { "Unsupported credential format" }
|
require(buffer.get() == VERSION) { "Unsupported credential format" }
|
||||||
val ivLength = buffer.get().toInt() and 0xFF
|
val ivLength = buffer.get().toInt() and 0xFF
|
||||||
@@ -63,6 +64,7 @@ class AndroidKeystoreCredentialCipher(
|
|||||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
private const val KEY_SIZE_BITS = 256
|
private const val KEY_SIZE_BITS = 256
|
||||||
private const val GCM_TAG_BITS = 128
|
private const val GCM_TAG_BITS = 128
|
||||||
|
private const val HEADER_SIZE = 2
|
||||||
private const val VERSION: Byte = 1
|
private const val VERSION: Byte = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -33,6 +33,29 @@ class CredentialStoreTest {
|
|||||||
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
|
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedCredentialIsRemoved() = runTest {
|
||||||
|
val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() }
|
||||||
|
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val profile = ProviderProfileId("profile")
|
||||||
|
val store = KeystoreCredentialStore(dataStore, RejectingCipher())
|
||||||
|
store.saveCredentials(
|
||||||
|
profile,
|
||||||
|
LoginCredentials(Username("demo"), Password("secret")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
|
||||||
|
assertNull((store.loadCredentials(profile) as DomainResult.Success).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RejectingCipher : CredentialCipher {
|
||||||
|
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = byteArrayOf(1)
|
||||||
|
|
||||||
|
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||||
|
throw IllegalArgumentException("Credential payload is truncated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class ReversingCipher : CredentialCipher {
|
private class ReversingCipher : CredentialCipher {
|
||||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
|
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
|
||||||
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
|
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
|
||||||
|
|||||||
Reference in New Issue
Block a user
状态失效
切换提供方前立即执行了
clearSensitiveState()。当switchProvider()因 DataStore 写入等原因失败且活动提供方未改变时,LaunchedEffect(profile.id)的键也不会变化,因此不会重新加载;LoginViewModel内部的profile已被置空,之后点击登录会在submit()中静默返回。应仅在切换成功后清理,或在失败时恢复并重新加载当前 profile。