diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 8981b99..4dd9133 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -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( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 9ebef78..43f1b07 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -27,20 +27,39 @@ class LoginViewModel @Inject constructor( val uiState: StateFlow = _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, ) diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index 02f801f..111915f 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -44,8 +44,16 @@ class KeystoreCredentialStore( DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) } catch (error: Exception) { if (error.isPermanentCredentialFailure()) { - dataStore.edit { it.remove(credentialKey(profileId)) } - DomainResult.Success(null) + 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), diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt new file mode 100644 index 0000000..6373da6 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt @@ -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 { + override val data: Flow = 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 + } +}