feat: add provider login flow #4
@@ -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 &&
|
||||||
|
pr-agent-bot
commented
状态丢失
**状态丢失**
`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
|
||||||
|
pr-agent-bot
commented
输入被覆盖
**输入被覆盖**
`loginPreferences` 异步读取完成后会直接替换整个 `LoginUiState`。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。
<!-- pr-agent-dedup: 0d8efc2f0a77 -->
<!-- pr-agent-key-issue-location: 043e23a2b991 -->
|
|||||||
usernameEdited = false
|
usernameEdited = false
|
||||||
@@ -80,6 +83,14 @@ class LoginViewModel @Inject constructor(
|
|||||||
fun clearSensitiveState() {
|
fun clearSensitiveState() {
|
||||||
|
pr-agent-bot
commented
并发登录
**并发登录**
`LoginRoute` 在组合销毁(包括屏幕旋转)时调用 `clearSensitiveState`,但这里仅取消偏好加载任务,未处理 `submit()` 启动的登录任务。Hilt ViewModel 会跨配置变更存活,因此慢速登录期间旋转会将 `isSubmitting` 重置为 false,使新界面能够再次提交或切换提供方,而旧请求仍可能完成并写入会话或新界面状态。应跟踪提交任务,并区分配置变更与实际离开登录流程。
<!-- pr-agent-dedup: 160460b36140 -->
<!-- pr-agent-key-issue-location: c5d20cd04cee -->
|
|||||||
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,7 +129,8 @@ 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 {
|
||||||
|
try {
|
||||||
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
|
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
|
||||||
val result = coordinator.login(
|
val result = coordinator.login(
|
||||||
activeProfile.id,
|
activeProfile.id,
|
||||||
@@ -130,6 +143,9 @@ class LoginViewModel @Inject constructor(
|
|||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
|
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)
|
||||||
|
pr-agent-bot
commented
长度上限
**长度上限**
`DataOutputStream.writeUTF` 的编码长度上限为 65,535 字节,但登录输入未限制用户名或密码长度。用户粘贴超长凭据并启用自动登录时,保存会抛出 `UTFDataFormatException`,导致登录凭据无法持久化,甚至使已成功的登录被报告为失败。建议改用显式长度前缀的 UTF-8 编码,或在提交前实施一致的长度校验。
<!-- pr-agent-dedup: 19a17af651b8 -->
<!-- pr-agent-key-issue-location: da60d039cac9 -->
|
|||||||
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))
|
||||||
|
pr-agent-bot
commented
取消被吞
**取消被吞**
`catch (Exception)` 会捕获 `CancellationException`。当登录或凭据持久化协程因 ViewModel 销毁等原因被取消时,`loadCredentials()` 和 `operation()` 会把取消转换成普通 `DomainResult.Failure`,使上层协调器可能继续执行后续状态或持久化步骤。应优先重新抛出 `CancellationException`。
<!-- pr-agent-dedup: f1252c6e85af -->
<!-- pr-agent-key-issue-location: 1fd8caaa1d86 -->
|
|||||||
}
|
}
|
||||||
@@ -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) =
|
||||||
|
pr-agent-bot
commented
凭据误删
**凭据误删**
`isPermanentCredentialFailure()` 将所有 `KeyStoreException`、`UnrecoverableKeyException` 和 `InvalidKeyException` 都视为永久损坏,但这些异常也可能包装 Android Keystore 的瞬时服务或硬件错误。此时 `loadCredentials()` 会删除唯一密文并返回无凭据,导致一次暂时故障永久清除自动登录信息。仅应对格式损坏、认证标签失败或已确认永久失效的密钥执行清理。
<!-- pr-agent-dedup: 1ace7f5bdcc7 -->
<!-- pr-agent-key-issue-location: 3992d1af8bc4 -->
|
|||||||
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() }
|
||||||
|
|||||||
隐私残留
loadingJob完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在finally中清空任务并按持久化偏好重置表单。