feat: add provider login flow #4
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.SessionCoordinator
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -61,6 +62,15 @@ class LoginViewModel @Inject constructor(
|
||||
},
|
||||
isLoadingPreferences = false,
|
||||
)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
if (profile?.id == value.id) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoadingPreferences = false,
|
||||
errorMessage = "Unable to load saved login settings",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
loadingJob = null
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.Session
|
||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||
import com.flagship.abox.manager.ui.provider.Availability
|
||||
import com.flagship.abox.manager.ui.provider.ProviderAvailability
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,7 +21,8 @@ import kotlinx.coroutines.launch
|
||||
@HiltViewModel
|
||||
class AppSessionViewModel @Inject constructor(
|
||||
private val coordinator: SessionCoordinator,
|
||||
profileStore: ProviderProfileStore,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val availability: ProviderAvailability,
|
||||
) : ViewModel() {
|
||||
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
|
||||
|
||||
@@ -38,9 +41,15 @@ class AppSessionViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val activeProfile = profileStore.activeProfile()
|
||||
if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) {
|
||||
coordinator.switchProvider()
|
||||
bootstrapResult.value = DomainResult.Success(null)
|
||||
} else {
|
||||
bootstrapResult.value = coordinator.bootstrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateFor(
|
||||
profile: ProviderProfile?,
|
||||
@@ -48,6 +57,7 @@ class AppSessionViewModel @Inject constructor(
|
||||
bootstrapError: String?,
|
||||
): AppSessionUiState = when {
|
||||
|
|
||||
profile == null -> AppSessionUiState.ProviderSelection
|
||||
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
|
||||
session != null -> AppSessionUiState.Authenticated(profile)
|
||||
else -> AppSessionUiState.Login(profile, bootstrapError)
|
||||
}
|
||||
|
||||
@@ -18,20 +18,34 @@ import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
|
pr-agent-bot
commented
启动失败
**启动失败**
`observeActiveProfile()` 等流直接暴露 `DataStore.data`。Preferences DataStore 在发生 I/O 错误或数据损坏时会抛出异常,而启动状态通过 `stateIn` 收集且没有 `catch` 或重试;一次读取失败便会终止启动流,甚至成为未捕获的协程异常,使应用无法进入提供方选择或登录页。应配置数据损坏处理,并对可恢复的读取错误进行重试或映射。
<!-- pr-agent-dedup: f54a64dadb27 -->
<!-- pr-agent-key-issue-location: 477f7fbd6ced -->
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class ProviderProfileStoreImpl(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ProviderProfileStore {
|
||||
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
private val safeData: Flow<Preferences>
|
||||
|
pr-agent-bot
commented
无限重试
**无限重试**
`safeData` 遇到任何 `IOException` 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,`activeProfile()` 和 `loginPreferences()` 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。
<!-- pr-agent-dedup: 4389fff2f610 -->
<!-- pr-agent-key-issue-location: b2da98684d7a -->
pr-agent-bot
commented
启动失败
**启动失败**
`safeData` 在两次重试后仍会向调用方传播 `IOException`,而启动流程中的 `activeProfile()` 和 `observeActiveProfile()` 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,`bootstrapResult` 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。
<!-- pr-agent-dedup: 7302d3a589b8 -->
<!-- pr-agent-key-issue-location: db2a740c074a -->
pr-agent-bot
commented
启动卡死 当 DataStore 连续读取失败并耗尽重试后, **启动卡死**
当 DataStore 连续读取失败并耗尽重试后,`safeData` 会继续抛出 `IOException`。`observeActiveProfile()` 是 `combine(...).stateIn(...)` 的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。
<!-- pr-agent-dedup: 31fd4d3bef7b -->
<!-- pr-agent-key-issue-location: 2275a87bf139 -->
|
||||
get() = dataStore.data.retryWhen { cause, attempt ->
|
||||
|
pr-agent-bot
commented
无限重试 达到 **无限重试**
达到 `MAX_READ_RETRIES` 后虽然发射了空配置,但谓词仍返回 `true`,因此持续性 `IOException` 会让每个长期订阅者以约 50 毫秒的间隔无限重读并反复发射空配置,造成电量消耗和会话界面抖动。达到上限后应停止重试,并通过 `catch` 等方式仅发射一次回退值。
<!-- pr-agent-dedup: 0b718cc7f1c2 -->
<!-- pr-agent-key-issue-location: 701272b74db5 -->
|
||||
if (cause !is IOException) return@retryWhen false
|
||||
|
pr-agent-bot
commented
观察流终止 当 **观察流终止**
当 `DataStore` 的读取 `IOException` 超过重试上限时,`catch` 发出一次空首选项后会结束整个冷流。`observeActiveProfile()` 等长期订阅不会在存储恢复后继续接收更新;例如用户重新选择提供方且写入成功,应用仍可能停在提供方选择界面,直到进程重启。应让观察流继续重试或重新订阅,而不是以空值结束。
<!-- pr-agent-dedup: 1ff2c2415377 -->
<!-- pr-agent-key-issue-location: cf47b7bb3191 -->
|
||||
if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences())
|
||||
delay(READ_RETRY_DELAY_MILLIS)
|
||||
true
|
||||
get() = refresh
|
||||
.onStart { emit(Unit) }
|
||||
.flatMapLatest { readDataOnce() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
private fun readDataOnce(): Flow<Preferences> = dataStore.data
|
||||
.retryWhen { cause, attempt ->
|
||||
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
||||
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
||||
retry
|
||||
}
|
||||
.catchIOExceptionAsEmpty()
|
||||
|
||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||
|
||||
@@ -53,6 +67,7 @@ class ProviderProfileStoreImpl(
|
||||
preferences[profileTypeKey(profile.id)] = profile.type.name
|
||||
preferences[profileNameKey(profile.id)] = profile.displayName
|
||||
}
|
||||
refresh.tryEmit(Unit)
|
||||
}
|
||||
|
||||
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
||||
@@ -60,6 +75,7 @@ class ProviderProfileStoreImpl(
|
||||
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
||||
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
||||
}
|
||||
refresh.tryEmit(Unit)
|
||||
}
|
||||
|
||||
override suspend fun saveLoginPreferences(
|
||||
@@ -73,6 +89,7 @@ class ProviderProfileStoreImpl(
|
||||
if (username == null) values.remove(usernameKey(profileId))
|
||||
else values[usernameKey(profileId)] = username
|
||||
}
|
||||
refresh.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
||||
@@ -135,3 +152,7 @@ class ProviderProfileStoreImpl(
|
||||
booleanPreferencesKey("profile_${id.value}_auto_login")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->
|
||||
if (error is IOException) emit(emptyPreferences()) else throw error
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.flagship.abox.manager.session
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -13,6 +13,9 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -29,14 +32,29 @@ class ProviderProfileStoreFailureTest {
|
||||
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
|
||||
}
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun observerContinuesAfterStorageRecovers() = runTest {
|
||||
fun observerResubscribesAfterSuccessfulWrite() = runTest {
|
||||
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
|
||||
val emissions = mutableListOf<ProviderProfile?>()
|
||||
val collector = backgroundScope.launch {
|
||||
store.observeActiveProfile().take(2).toList(emissions)
|
||||
}
|
||||
advanceTimeBy(200)
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf<ProviderProfile?>(null), emissions)
|
||||
|
||||
val emissions = store.observeActiveProfile().take(2).toList()
|
||||
val profile = ProviderProfile(
|
||||
ProviderProfileId("recovered"),
|
||||
ProviderType.LOCAL_EXPERIENCE,
|
||||
"Recovered",
|
||||
)
|
||||
store.saveProfile(profile)
|
||||
store.setActiveProfile(profile.id)
|
||||
advanceUntilIdle()
|
||||
collector.join()
|
||||
|
||||
assertEquals(null, emissions[0])
|
||||
assertEquals("recovered", emissions[1]?.id?.value)
|
||||
assertEquals(listOf(null, profile), emissions)
|
||||
}
|
||||
|
||||
private class FailingReadDataStore : DataStore<Preferences> {
|
||||
@@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest {
|
||||
|
||||
private class RecoveringReadDataStore : DataStore<Preferences> {
|
||||
private var attempts = 0
|
||||
private var current: Preferences = emptyPreferences()
|
||||
|
||||
override val data: Flow<Preferences>
|
||||
get() = flow {
|
||||
if (attempts++ < 3) throw IOException("temporarily unavailable")
|
||||
val values: MutablePreferences = emptyPreferences().toMutablePreferences()
|
||||
values[stringPreferencesKey("active_profile_id")] = "recovered"
|
||||
values[androidx.datastore.preferences.core.stringSetPreferencesKey("profile_ids")] =
|
||||
setOf("recovered")
|
||||
values[stringPreferencesKey("profile_recovered_type")] = "LOCAL_EXPERIENCE"
|
||||
values[stringPreferencesKey("profile_recovered_name")] = "Recovered"
|
||||
emit(values)
|
||||
emit(current)
|
||||
}
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences = transform(emptyPreferences())
|
||||
): Preferences {
|
||||
current = transform(current)
|
||||
return current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
可用性绕过
stateFor未校验当前构建中的ProviderAvailability。如果设备保留了来自允许 ABox 的构建或旧版本的活动配置,release 构建仍会直接进入该提供方的登录页并允许发起登录,而不是按禁用状态返回提供方选择。应在启动时拒绝或清除当前构建中已禁用的活动配置。