feat: add provider login flow #4
@@ -16,6 +16,7 @@ import com.flagship.abox.manager.designsystem.ABoxSurface
|
|||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
|
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
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.provider.ProviderSelectionRoute
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
@@ -25,12 +26,16 @@ import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
|||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
||||||
|
loginViewModel: LoginViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val screenKey = sessionState.screenKey()
|
val screenKey = sessionState.screenKey()
|
||||||
LaunchedEffect(screenKey) {
|
LaunchedEffect(screenKey) {
|
||||||
sessionActionsViewModel.dismissError()
|
sessionActionsViewModel.dismissError()
|
||||||
|
if (sessionState !is AppSessionUiState.Login) {
|
||||||
|
loginViewModel.clearSensitiveState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ABoxTheme {
|
ABoxTheme {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -50,6 +55,7 @@ fun ABoxManagerApp(
|
|||||||
actionError = actionState.errorMessage,
|
actionError = actionState.errorMessage,
|
||||||
actionInProgress = actionState.inProgress,
|
actionInProgress = actionState.inProgress,
|
||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
|
viewModel = loginViewModel,
|
||||||
)
|
)
|
||||||
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
|
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
|
||||||
profile = state.profile,
|
profile = state.profile,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -51,9 +50,6 @@ fun LoginRoute(
|
|||||||
viewModel: LoginViewModel = hiltViewModel(),
|
viewModel: LoginViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
||||||
DisposableEffect(profile.id) {
|
|
||||||
|
|
|||||||
onDispose(viewModel::clearSensitiveState)
|
|
||||||
}
|
|
||||||
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
|
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
|
||||||
?: actionError
|
?: actionError
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ class AppSessionViewModel @Inject constructor(
|
|||||||
): AppSessionUiState = when {
|
): AppSessionUiState = when {
|
||||||
profile == null -> AppSessionUiState.ProviderSelection
|
profile == null -> AppSessionUiState.ProviderSelection
|
||||||
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
|
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
|
||||||
session != null -> AppSessionUiState.Authenticated(profile)
|
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
|
||||||
|
pr-agent-bot
commented
会话错配
**会话错配**
`stateFor` 只判断 `session != null`,没有验证 `session.profileId == profile.id`。切换提供方时,`observeActiveProfile()` 与 `observeActiveSession()` 是独立流,`combine` 可能短暂收到新配置与旧会话,从而把新提供方错误标记为已认证;认证分支应校验两个 ID 是否一致。
<!-- pr-agent-dedup: 8712bfde0bc7 -->
<!-- pr-agent-key-issue-location: 7d28afc66249 -->
|
|||||||
else -> AppSessionUiState.Login(profile, bootstrapError)
|
else -> AppSessionUiState.Login(profile, bootstrapError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -40,6 +40,11 @@ class AndroidKeystoreCredentialCipher(
|
|||||||
return cipher.doFinal(ciphertext)
|
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 {
|
private fun secretKey(): SecretKey {
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||||
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
|
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
|
||||||
|
|||||||
+2
@@ -6,4 +6,6 @@ interface CredentialCipher {
|
|||||||
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
|
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
|
||||||
|
|
||||||
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
|
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
|
||||||
|
|
||||||
|
fun resetPermanentlyInvalidatedKey()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -46,6 +46,9 @@ class KeystoreCredentialStore(
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
if (error.isPermanentCredentialFailure()) {
|
if (error.isPermanentCredentialFailure()) {
|
||||||
|
pr-agent-bot
commented
密钥失效
**密钥失效**
`KeyPermanentlyInvalidatedException` 被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。
<!-- pr-agent-dedup: 4aa1d912b6ba -->
<!-- pr-agent-key-issue-location: aa0611306fa7 -->
|
|||||||
try {
|
try {
|
||||||
|
if (error.hasPermanentlyInvalidatedKey()) {
|
||||||
|
cipher.resetPermanentlyInvalidatedKey()
|
||||||
|
}
|
||||||
dataStore.edit { it.remove(credentialKey(profileId)) }
|
dataStore.edit { it.remove(credentialKey(profileId)) }
|
||||||
DomainResult.Success(null)
|
DomainResult.Success(null)
|
||||||
} catch (cleanupError: CancellationException) {
|
} catch (cleanupError: CancellationException) {
|
||||||
@@ -122,6 +125,9 @@ private fun DataInputStream.readByteArray(maxSize: Int): ByteArray {
|
|||||||
return ByteArray(length).also(::readFully)
|
return ByteArray(length).also(::readFully)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean =
|
||||||
|
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
|
||||||
|
|
||||||
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
|
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
|
||||||
is AEADBadTagException,
|
is AEADBadTagException,
|
||||||
is KeyPermanentlyInvalidatedException,
|
is KeyPermanentlyInvalidatedException,
|
||||||
|
|||||||
+2
@@ -39,5 +39,7 @@ class CredentialCleanupFailureTest {
|
|||||||
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
|
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext
|
||||||
|
|
||||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
|
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted
|
||||||
|
|
||||||
|
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -69,6 +69,8 @@ class CredentialStoreTest {
|
|||||||
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
|
||||||
throw IllegalArgumentException("Credential payload is truncated")
|
throw IllegalArgumentException("Credential payload is truncated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ReversingCipher : CredentialCipher {
|
private class ReversingCipher : CredentialCipher {
|
||||||
@@ -81,5 +83,7 @@ class CredentialStoreTest {
|
|||||||
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
|
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
|
||||||
return decoded.copyOfRange(delimiter + 1, decoded.size)
|
return decoded.copyOfRange(delimiter + 1, decoded.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
状态丢失
DisposableEffect的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的LoginViewModel会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。