From 547146792f31857122853d5053e734e96caaf130 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:49:42 +0800 Subject: [PATCH] fix: serialize login submission state --- .../abox/manager/ui/login/LoginViewModel.kt | 44 +++++++++++++------ .../credential/KeystoreCredentialStore.kt | 27 ++++++++++-- .../manager/session/CredentialStoreTest.kt | 15 +++++++ 3 files changed, 68 insertions(+), 18 deletions(-) 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 7f9a0cd..a96a46b 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 @@ -28,12 +28,15 @@ class LoginViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var profile: ProviderProfile? = null private var loadingJob: Job? = null + private var submitJob: Job? = null private var usernameEdited = false private var rememberUsernameEdited = false private var autoLoginEdited = false fun loadProfile(value: ProviderProfile) { - if (profile?.id == value.id && loadingJob?.isActive == true) return + if (profile?.id == value.id && + (loadingJob?.isActive == true || submitJob?.isActive == true) + ) return loadingJob?.cancel() profile = value usernameEdited = false @@ -80,6 +83,14 @@ class LoginViewModel @Inject constructor( fun clearSensitiveState() { loadingJob?.cancel() loadingJob = null + if (submitJob?.isActive == true) { + _uiState.value = _uiState.value.copy( + password = "", + errorMessage = null, + isSubmitting = true, + ) + return + } profile = null usernameEdited = false rememberUsernameEdited = false @@ -107,6 +118,7 @@ class LoginViewModel @Inject constructor( } fun submit() { + if (submitJob?.isActive == true) return val activeProfile = profile ?: return val state = _uiState.value if (state.username.isBlank() || @@ -117,19 +129,23 @@ class LoginViewModel @Inject constructor( _uiState.value = state.copy(errorMessage = "Username and password are required") return } - viewModelScope.launch { - _uiState.value = state.copy(isSubmitting = true, errorMessage = null) - val result = coordinator.login( - activeProfile.id, - LoginCredentials(Username(state.username.trim()), Password(state.password)), - rememberUsername = state.rememberUsername, - autoLogin = state.autoLogin, - ) - _uiState.value = _uiState.value.copy( - password = "", - isSubmitting = false, - errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), - ) + submitJob = viewModelScope.launch { + try { + _uiState.value = state.copy(isSubmitting = true, errorMessage = null) + val result = coordinator.login( + activeProfile.id, + LoginCredentials(Username(state.username.trim()), Password(state.password)), + rememberUsername = state.rememberUsername, + autoLogin = state.autoLogin, + ) + _uiState.value = _uiState.value.copy( + password = "", + isSubmitting = false, + errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), + ) + } finally { + submitJob = 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 53f15b0..a989ad0 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 @@ -71,8 +71,9 @@ class KeystoreCredentialStore( private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output -> DataOutputStream(output).use { data -> - data.writeUTF(credentials.username.value) - data.writeUTF(credentials.password.value) + data.writeByte(CREDENTIAL_FORMAT_VERSION) + data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8)) + data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8)) } output.toByteArray() } @@ -80,8 +81,11 @@ class KeystoreCredentialStore( private fun decode(value: ByteArray): LoginCredentials = DataInputStream( ByteArrayInputStream(value), ).use { data -> - val username = data.readUTF() - val password = data.readUTF() + require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) { + "Unsupported credential data format" + } + val username = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8) + val password = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8) require(data.available() == 0) { "Unexpected credential data" } LoginCredentials(Username(username), Password(password)) } @@ -98,11 +102,26 @@ class KeystoreCredentialStore( } companion object { + private const val CREDENTIAL_FORMAT_VERSION = 1 + private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576 + private fun credentialKey(profileId: ProviderProfileId) = stringPreferencesKey("profile_${profileId.value}_credential") } } +private fun DataOutputStream.writeByteArray(value: ByteArray) { + require(value.size <= 1_048_576) { "Credential field is too large" } + writeInt(value.size) + write(value) +} + +private fun DataInputStream.readByteArray(maxSize: Int): ByteArray { + val length = readInt() + require(length in 0..maxSize && length <= available()) { "Invalid credential field length" } + return ByteArray(length).also(::readFully) +} + private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { is AEADBadTagException, is KeyPermanentlyInvalidatedException, diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index dd9a204..c34e1c4 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -33,6 +33,21 @@ class CredentialStoreTest { assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) } + @Test + fun credentialsLargerThanWriteUtfLimitRoundTrip() = runTest { + val file = File.createTempFile("large-credentials", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = KeystoreCredentialStore(dataStore, ReversingCipher()) + val profile = ProviderProfileId("large") + val credentials = LoginCredentials( + Username("u".repeat(70_000)), + Password("p".repeat(70_000)), + ) + + assertEquals(DomainResult.Success(Unit), store.saveCredentials(profile, credentials)) + assertEquals(credentials, (store.loadCredentials(profile) as DomainResult.Success).value) + } + @Test fun malformedCredentialIsRemoved() = runTest { val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() }