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
8 changed files with 26 additions and 5 deletions
Showing only changes of commit a0f55b041a - Show all commits
@@ -16,6 +16,7 @@ import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
@@ -25,12 +26,16 @@ import com.flagship.abox.manager.ui.session.SessionActionsViewModel
fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
loginViewModel: LoginViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
if (sessionState !is AppSessionUiState.Login) {
loginViewModel.clearSensitiveState()
}
}
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
@@ -50,6 +55,7 @@ fun ABoxManagerApp(
actionError = actionState.errorMessage,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
profile = state.profile,
@@ -21,7 +21,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -51,9 +50,6 @@ fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel(),
) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
DisposableEffect(profile.id) {
Review

状态丢失

DisposableEffect 的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的 LoginViewModel 会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。

**状态丢失** `DisposableEffect` 的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的 `LoginViewModel` 会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。 <!-- pr-agent-dedup: 15fff7c847b0 --> <!-- pr-agent-key-issue-location: 1343b434afe8 -->
onDispose(viewModel::clearSensitiveState)
}
val state = viewModel.uiState.collectAsStateWithLifecycle().value
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
?: actionError
@@ -98,7 +98,7 @@ class AppSessionViewModel @Inject constructor(
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
session != null -> AppSessionUiState.Authenticated(profile)
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
@@ -40,6 +40,11 @@ class AndroidKeystoreCredentialCipher(
return cipher.doFinal(ciphertext)
}
override fun resetPermanentlyInvalidatedKey() {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
@@ -6,4 +6,6 @@ interface CredentialCipher {
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
fun resetPermanentlyInvalidatedKey()
}
@@ -46,6 +46,9 @@ class KeystoreCredentialStore(
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
try {
if (error.hasPermanentlyInvalidatedKey()) {
cipher.resetPermanentlyInvalidatedKey()
}
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} catch (cleanupError: CancellationException) {
@@ -122,6 +125,9 @@ private fun DataInputStream.readByteArray(maxSize: Int): ByteArray {
return ByteArray(length).also(::readFully)
}
private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean =
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException,
is KeyPermanentlyInvalidatedException,
@@ -39,5 +39,7 @@ class CredentialCleanupFailureTest {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
override fun resetPermanentlyInvalidatedKey() = Unit
}
}
@@ -69,6 +69,8 @@ class CredentialStoreTest {
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
throw IllegalArgumentException("Credential payload is truncated")
}
override fun resetPermanentlyInvalidatedKey() = Unit
}
private class ReversingCipher : CredentialCipher {
@@ -81,5 +83,7 @@ class CredentialStoreTest {
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
return decoded.copyOfRange(delimiter + 1, decoded.size)
}
override fun resetPermanentlyInvalidatedKey() = Unit
}
}