feat: add provider login flow #4
@@ -28,12 +28,15 @@ class LoginViewModel @Inject constructor(
|
||||
val uiState: StateFlow<LoginUiState> = _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,7 +129,8 @@ class LoginViewModel @Inject constructor(
|
||||
_uiState.value = state.copy(errorMessage = "Username and password are required")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
submitJob = viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
|
||||
val result = coordinator.login(
|
||||
activeProfile.id,
|
||||
@@ -130,6 +143,9 @@ class LoginViewModel @Inject constructor(
|
||||
isSubmitting = false,
|
||||
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
|
||||
)
|
||||
} finally {
|
||||
submitJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -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,
|
||||
|
||||
+15
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user
状态丢失
LoginRoute重新进入组合时会再次调用loadProfile。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的LoginUiState覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。