feat: add provider login flow #4
@@ -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}"
|
||||||
|
}
|
||||||
|
|||||||
+29
-6
@@ -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
|
||||||
|
|
|||||||
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(
|
||||||
|
pr-agent-bot
commented
可用性绕过
**可用性绕过**
`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
|
||||||
|
pr-agent-bot
commented
错误丢失 当已有活动提供方且 **错误丢失**
当已有活动提供方且 `coordinator.bootstrap()` 抛出异常时,失败结果被错误地关联到空 `profileId`。随后 `errorFor(activeProfile.id)` 会过滤该错误,监听协程还可能将其覆盖为成功,导致自动登录或会话恢复失败时没有任何提示。应保留已读取的活动提供方 ID,并将异常绑定到该 ID。
<!-- pr-agent-dedup: 1cdb056256da -->
<!-- pr-agent-key-issue-location: 67d3f8e01002 -->
|
|||||||
|
|||||||
Reference in New Issue
Block a user
错误串线
bootstrapResult是全局一次性状态,其失败信息会被复用于之后选择的所有提供方。若启动时 ABox 引导因网络问题失败,随后用户切换到本地体验,新登录页仍会显示前一个提供方的错误。应按提供方关联引导结果,或在活动提供方变化时清除旧错误。