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
6 changed files with 76 additions and 4 deletions
Showing only changes of commit 3764acb7ec - Show all commits
@@ -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 {
val preferences = profileStore.loginPreferences(value.id) val preferences = profileStore.loginPreferences(value.id)
_uiState.value = _uiState.value.copy( if (profile?.id != value.id) return@launch
Review

状态丢失

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

**状态丢失** `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,
autoLogin = preferences.autoLoginEnabled, autoLogin = preferences.autoLoginEnabled,
@@ -41,6 +45,13 @@ class LoginViewModel @Inject constructor(
} }
} }
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)
} }
@@ -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))
}
} }
@@ -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
} }
} }
@@ -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()