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
3 changed files with 68 additions and 18 deletions
Showing only changes of commit 547146792f - Show all commits
@@ -28,12 +28,15 @@ 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 submitJob: Job? = null
private var usernameEdited = false private var usernameEdited = false
private var rememberUsernameEdited = false private var rememberUsernameEdited = false
private var autoLoginEdited = false private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) { fun loadProfile(value: ProviderProfile) {
if (profile?.id == value.id && loadingJob?.isActive == true) return if (profile?.id == value.id &&
Review

状态丢失

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

**状态丢失** `LoginRoute` 重新进入组合时会再次调用 `loadProfile`。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的 `LoginUiState` 覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。 <!-- pr-agent-dedup: 3e11ec9ce7f1 --> <!-- pr-agent-key-issue-location: 75605d605f67 -->
(loadingJob?.isActive == true || submitJob?.isActive == true)
) return
loadingJob?.cancel() loadingJob?.cancel()
profile = value profile = value
usernameEdited = false usernameEdited = false
@@ -80,6 +83,14 @@ class LoginViewModel @Inject constructor(
fun clearSensitiveState() { fun clearSensitiveState() {
loadingJob?.cancel() loadingJob?.cancel()
loadingJob = null loadingJob = null
if (submitJob?.isActive == true) {
_uiState.value = _uiState.value.copy(
password = "",
errorMessage = null,
isSubmitting = true,
)
return
}
profile = null profile = null
usernameEdited = false usernameEdited = false
rememberUsernameEdited = false rememberUsernameEdited = false
@@ -107,6 +118,7 @@ class LoginViewModel @Inject constructor(
} }
fun submit() { fun submit() {
if (submitJob?.isActive == true) return
val activeProfile = profile ?: return val activeProfile = profile ?: return
val state = _uiState.value val state = _uiState.value
if (state.username.isBlank() || if (state.username.isBlank() ||
@@ -117,19 +129,23 @@ class LoginViewModel @Inject constructor(
_uiState.value = state.copy(errorMessage = "Username and password are required") _uiState.value = state.copy(errorMessage = "Username and password are required")
return return
} }
viewModelScope.launch { submitJob = viewModelScope.launch {
_uiState.value = state.copy(isSubmitting = true, errorMessage = null) try {
val result = coordinator.login( _uiState.value = state.copy(isSubmitting = true, errorMessage = null)
activeProfile.id, val result = coordinator.login(
LoginCredentials(Username(state.username.trim()), Password(state.password)), activeProfile.id,
rememberUsername = state.rememberUsername, LoginCredentials(Username(state.username.trim()), Password(state.password)),
autoLogin = state.autoLogin, rememberUsername = state.rememberUsername,
) autoLogin = state.autoLogin,
_uiState.value = _uiState.value.copy( )
password = "", _uiState.value = _uiState.value.copy(
isSubmitting = false, password = "",
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), isSubmitting = false,
) errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
)
} finally {
submitJob = null
}
} }
} }
@@ -71,8 +71,9 @@ class KeystoreCredentialStore(
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output -> private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
DataOutputStream(output).use { data -> DataOutputStream(output).use { data ->
data.writeUTF(credentials.username.value) data.writeByte(CREDENTIAL_FORMAT_VERSION)
data.writeUTF(credentials.password.value) data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8))
data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8))
} }
output.toByteArray() output.toByteArray()
} }
@@ -80,8 +81,11 @@ class KeystoreCredentialStore(
private fun decode(value: ByteArray): LoginCredentials = DataInputStream( private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
ByteArrayInputStream(value), ByteArrayInputStream(value),
).use { data -> ).use { data ->
val username = data.readUTF() require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) {
val password = data.readUTF() "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" } require(data.available() == 0) { "Unexpected credential data" }
LoginCredentials(Username(username), Password(password)) LoginCredentials(Username(username), Password(password))
} }
@@ -98,11 +102,26 @@ class KeystoreCredentialStore(
} }
companion object { companion object {
private const val CREDENTIAL_FORMAT_VERSION = 1
private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576
private fun credentialKey(profileId: ProviderProfileId) = private fun credentialKey(profileId: ProviderProfileId) =
stringPreferencesKey("profile_${profileId.value}_credential") 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) { private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException, is AEADBadTagException,
is KeyPermanentlyInvalidatedException, is KeyPermanentlyInvalidatedException,
@@ -33,6 +33,21 @@ class CredentialStoreTest {
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) 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 @Test
fun malformedCredentialIsRemoved() = runTest { fun malformedCredentialIsRemoved() = runTest {
val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() } val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() }