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.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
@@ -26,11 +27,14 @@ fun ABoxManagerApp(
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
}
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
when (
val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
) {
when (val state = sessionState) {
AppSessionUiState.Loading -> Box(
modifier = Modifier
.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 com.flagship.abox.manager.domain.DomainResult
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.Session
import com.flagship.abox.manager.domain.SessionCoordinator
@@ -24,15 +25,19 @@ class AppSessionViewModel @Inject constructor(
private val profileStore: ProviderProfileStore,
private val availability: ProviderAvailability,
) : ViewModel() {
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult,
profileStore.observeActiveProfile(),
coordinator.observeActiveSession(),
) { result, profile, session ->
if (result == null) AppSessionUiState.Loading
else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail)
) { bootstrap, profile, session ->
if (bootstrap == null) AppSessionUiState.Loading
else stateFor(
profile = profile,
session = session,
bootstrapError = bootstrap.errorFor(profile?.id),
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@@ -44,9 +49,15 @@ class AppSessionViewModel @Inject constructor(
val activeProfile = profileStore.activeProfile()
if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) {
coordinator.switchProvider()
bootstrapResult.value = DomainResult.Success(null)
bootstrapResult.value = ProviderBootstrapResult(
profileId = null,
result = DomainResult.Success(null),
)
} else {
bootstrapResult.value = coordinator.bootstrap()
bootstrapResult.value = ProviderBootstrapResult(
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 {
data object Loading : 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 -->