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
4 changed files with 99 additions and 15 deletions
Showing only changes of commit 344fa6dafa - Show all commits
@@ -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()
profile = value profile = value
_uiState.value = LoginUiState() usernameEdited = false
Review

状态丢失

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

**状态丢失** `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)
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
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()) {
dataStore.edit { it.remove(credentialKey(profileId)) } try {
DomainResult.Success(null) dataStore.edit { it.remove(credentialKey(profileId)) }
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
}
}