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
2 changed files with 43 additions and 9 deletions
Showing only changes of commit 40bbc2f30b - Show all commits
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -26,11 +27,14 @@ fun ABoxManagerApp(
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
) { ) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
}
ABoxTheme { ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) { ABoxSurface(modifier = Modifier.fillMaxSize()) {
when ( when (val state = sessionState) {
val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
) {
AppSessionUiState.Loading -> Box( AppSessionUiState.Loading -> Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -58,3 +62,10 @@ fun ABoxManagerApp(
} }
} }
} }
private fun AppSessionUiState.screenKey(): String = when (this) {
AppSessionUiState.Loading -> "loading"
AppSessionUiState.ProviderSelection -> "providers"
is AppSessionUiState.Login -> "login:${profile.id.value}"
is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}"
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
@@ -24,15 +25,19 @@ class AppSessionViewModel @Inject constructor(
private val profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
private val availability: ProviderAvailability, private val availability: ProviderAvailability,
) : ViewModel() { ) : ViewModel() {
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null) private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
val uiState: StateFlow<AppSessionUiState> = combine( val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult, bootstrapResult,
profileStore.observeActiveProfile(), profileStore.observeActiveProfile(),
coordinator.observeActiveSession(), coordinator.observeActiveSession(),
) { result, profile, session -> ) { bootstrap, profile, session ->
if (result == null) AppSessionUiState.Loading if (bootstrap == null) AppSessionUiState.Loading
Outdated
Review

错误串线

bootstrapResult 是全局一次性状态,其失败信息会被复用于之后选择的所有提供方。若启动时 ABox 引导因网络问题失败,随后用户切换到本地体验,新登录页仍会显示前一个提供方的错误。应按提供方关联引导结果,或在活动提供方变化时清除旧错误。

**错误串线** `bootstrapResult` 是全局一次性状态,其失败信息会被复用于之后选择的所有提供方。若启动时 ABox 引导因网络问题失败,随后用户切换到本地体验,新登录页仍会显示前一个提供方的错误。应按提供方关联引导结果,或在活动提供方变化时清除旧错误。 <!-- pr-agent-dedup: 86538ca9bf02 --> <!-- pr-agent-key-issue-location: 8aef5e707049 -->
else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail) else stateFor(
profile = profile,
session = session,
bootstrapError = bootstrap.errorFor(profile?.id),
)
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
@@ -44,9 +49,15 @@ class AppSessionViewModel @Inject constructor(
val activeProfile = profileStore.activeProfile() val activeProfile = profileStore.activeProfile()
if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) {
coordinator.switchProvider() coordinator.switchProvider()
bootstrapResult.value = DomainResult.Success(null) bootstrapResult.value = ProviderBootstrapResult(
profileId = null,
result = DomainResult.Success(null),
)
} else { } else {
bootstrapResult.value = coordinator.bootstrap() bootstrapResult.value = ProviderBootstrapResult(
Outdated
Review

可用性绕过

stateFor 未校验当前构建中的 ProviderAvailability。如果设备保留了来自允许 ABox 的构建或旧版本的活动配置,release 构建仍会直接进入该提供方的登录页并允许发起登录,而不是按禁用状态返回提供方选择。应在启动时拒绝或清除当前构建中已禁用的活动配置。

**可用性绕过** `stateFor` 未校验当前构建中的 `ProviderAvailability`。如果设备保留了来自允许 ABox 的构建或旧版本的活动配置,release 构建仍会直接进入该提供方的登录页并允许发起登录,而不是按禁用状态返回提供方选择。应在启动时拒绝或清除当前构建中已禁用的活动配置。 <!-- pr-agent-dedup: 19fa23f0789e --> <!-- pr-agent-key-issue-location: ba588bc35666 -->
profileId = activeProfile?.id,
result = coordinator.bootstrap(),
)
} }
} }
} }
@@ -63,6 +74,18 @@ class AppSessionViewModel @Inject constructor(
} }
} }
private data class ProviderBootstrapResult(
val profileId: ProviderProfileId?,
val result: DomainResult<Session?>,
) {
fun errorFor(currentProfileId: ProviderProfileId?): String? =
if (profileId == currentProfileId) {
(result as? DomainResult.Failure)?.error?.detail
} else {
null
}
}
sealed interface AppSessionUiState { sealed interface AppSessionUiState {
data object Loading : AppSessionUiState data object Loading : AppSessionUiState
data object ProviderSelection : AppSessionUiState data object ProviderSelection : AppSessionUiState
Review

错误丢失

当已有活动提供方且 coordinator.bootstrap() 抛出异常时,失败结果被错误地关联到空 profileId。随后 errorFor(activeProfile.id) 会过滤该错误,监听协程还可能将其覆盖为成功,导致自动登录或会话恢复失败时没有任何提示。应保留已读取的活动提供方 ID,并将异常绑定到该 ID。

**错误丢失** 当已有活动提供方且 `coordinator.bootstrap()` 抛出异常时,失败结果被错误地关联到空 `profileId`。随后 `errorFor(activeProfile.id)` 会过滤该错误,监听协程还可能将其覆盖为成功,导致自动登录或会话恢复失败时没有任何提示。应保留已读取的活动提供方 ID,并将异常绑定到该 ID。 <!-- pr-agent-dedup: 1cdb056256da --> <!-- pr-agent-key-issue-location: 67d3f8e01002 -->