fix: preserve login input during preference load

This commit is contained in:
2026-08-23 23:02:01 +08:00
parent 3764acb7ec
commit 344fa6dafa
4 changed files with 99 additions and 15 deletions
@@ -80,7 +80,8 @@ fun LoginScreen(
onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false,
) {
val navigationEnabled = !state.isSubmitting && !switchingProvider
val formEnabled = !state.isSubmitting && !state.isLoadingPreferences
val navigationEnabled = formEnabled && !switchingProvider
BackHandler {
if (navigationEnabled) onSwitchProvider()
}
@@ -140,7 +141,7 @@ fun LoginScreen(
value = state.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.login_username)) },
enabled = !state.isSubmitting,
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -149,29 +150,31 @@ fun LoginScreen(
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
enabled = !state.isSubmitting,
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ToggleRow(
label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername,
enabled = !state.isSubmitting,
enabled = formEnabled,
onCheckedChange = onRememberUsernameChange,
)
ToggleRow(
label = stringResource(R.string.login_auto_login),
checked = state.autoLogin,
enabled = !state.isSubmitting,
enabled = formEnabled,
onCheckedChange = onAutoLoginChange,
)
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onSubmit,
enabled = !state.isSubmitting && !switchingProvider,
enabled = formEnabled && !switchingProvider,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting || switchingProvider) ABoxLoadingIndicator()
if (state.isSubmitting || state.isLoadingPreferences || switchingProvider) {
ABoxLoadingIndicator()
}
else Text(stringResource(R.string.action_login))
}
ABoxButton(
@@ -27,20 +27,39 @@ class LoginViewModel @Inject constructor(
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private var profile: ProviderProfile? = null
private var loadingJob: Job? = null
private var usernameEdited = false
private var rememberUsernameEdited = false
private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) {
val profileChanged = profile?.id != value.id
if (!profileChanged && loadingJob != null) return
loadingJob?.cancel()
profile = value
_uiState.value = LoginUiState()
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true)
loadingJob = viewModelScope.launch {
val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch
_uiState.value = LoginUiState(
username = preferences.rememberedUsername?.value.orEmpty(),
rememberUsername = preferences.rememberUsername,
autoLogin = preferences.autoLoginEnabled,
_uiState.value = _uiState.value.copy(
username = if (usernameEdited) {
_uiState.value.username
} 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 = null
profile = null
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState()
}
fun setUsername(value: String) {
usernameEdited = true
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
}
@@ -61,17 +84,23 @@ class LoginViewModel @Inject constructor(
}
fun setRememberUsername(value: Boolean) {
rememberUsernameEdited = true
_uiState.value = _uiState.value.copy(rememberUsername = value)
}
fun setAutoLogin(value: Boolean) {
autoLoginEdited = true
_uiState.value = _uiState.value.copy(autoLogin = value)
}
fun submit() {
val activeProfile = profile ?: return
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")
return
}
@@ -104,6 +133,7 @@ data class LoginUiState(
val password: String = "",
val rememberUsername: Boolean = false,
val autoLogin: Boolean = false,
val isLoadingPreferences: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
)
@@ -44,8 +44,16 @@ class KeystoreCredentialStore(
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
try {
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} catch (cleanupError: Exception) {
DomainResult.Failure(
DomainError.Unknown(
detail = cleanupError.message ?: cleanupError::class.java.simpleName,
),
)
}
} else {
DomainResult.Failure(
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
}
}