feat: add provider login flow #4
@@ -48,10 +48,6 @@ fun LoginRoute(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
|
||||||
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val switchProvider = {
|
|
||||||
viewModel.clearSensitiveState()
|
|
||||||
onSwitchProvider()
|
|
||||||
}
|
|
||||||
|
|
|||||||
LoginScreen(
|
LoginScreen(
|
||||||
profile = profile,
|
profile = profile,
|
||||||
state = state.copy(
|
state = state.copy(
|
||||||
@@ -62,7 +58,7 @@ fun LoginRoute(
|
|||||||
onRememberUsernameChange = viewModel::setRememberUsername,
|
onRememberUsernameChange = viewModel::setRememberUsername,
|
||||||
onAutoLoginChange = viewModel::setAutoLogin,
|
onAutoLoginChange = viewModel::setAutoLogin,
|
||||||
onSubmit = viewModel::submit,
|
onSubmit = viewModel::submit,
|
||||||
onSwitchProvider = switchProvider,
|
onSwitchProvider = onSwitchProvider,
|
||||||
switchingProvider = actionInProgress,
|
switchingProvider = actionInProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
private var autoLoginEdited = false
|
private var autoLoginEdited = false
|
||||||
|
|
||||||
fun loadProfile(value: ProviderProfile) {
|
fun loadProfile(value: ProviderProfile) {
|
||||||
val profileChanged = profile?.id != value.id
|
if (profile?.id == value.id && loadingJob?.isActive == true) return
|
||||||
if (!profileChanged && loadingJob != null) return
|
|
||||||
|
pr-agent-bot
commented
状态丢失
**状态丢失**
`LoginRoute` 重新进入组合时会再次调用 `loadProfile`。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的 `LoginUiState` 覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。
<!-- pr-agent-dedup: 3e11ec9ce7f1 -->
<!-- pr-agent-key-issue-location: 75605d605f67 -->
|
|||||||
loadingJob?.cancel()
|
loadingJob?.cancel()
|
||||||
profile = value
|
profile = value
|
||||||
usernameEdited = false
|
usernameEdited = false
|
||||||
@@ -41,6 +40,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
autoLoginEdited = false
|
autoLoginEdited = false
|
||||||
_uiState.value = LoginUiState(isLoadingPreferences = true)
|
_uiState.value = LoginUiState(isLoadingPreferences = true)
|
||||||
loadingJob = viewModelScope.launch {
|
loadingJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
val preferences = profileStore.loginPreferences(value.id)
|
val preferences = profileStore.loginPreferences(value.id)
|
||||||
if (profile?.id != value.id) return@launch
|
if (profile?.id != value.id) return@launch
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -61,6 +61,9 @@ class LoginViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
isLoadingPreferences = false,
|
isLoadingPreferences = false,
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
loadingJob = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
@@ -13,19 +14,34 @@ 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.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
class ProviderProfileStoreImpl(
|
class ProviderProfileStoreImpl(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) : ProviderProfileStore {
|
) : ProviderProfileStore {
|
||||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom)
|
private val safeData: Flow<Preferences>
|
||||||
|
get() = dataStore.data
|
||||||
|
.retryWhen { cause, attempt ->
|
||||||
|
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
||||||
|
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
||||||
|
retry
|
||||||
|
}
|
||||||
|
.catch { error ->
|
||||||
|
if (error is IOException) emit(emptyPreferences()) else throw error
|
||||||
|
}
|
||||||
|
|
||||||
override fun observeActiveProfile(): Flow<ProviderProfile?> = dataStore.data.map(::activeProfileFrom)
|
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||||
|
|
||||||
|
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
|
||||||
|
|
||||||
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
|
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
|
||||||
dataStore.data.map { preferencesFrom(it, profileId) }
|
safeData.map { preferencesFrom(it, profileId) }
|
||||||
|
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 -->
|
|||||||
|
|
||||||
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
|
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
|
||||||
|
|
||||||
@@ -99,6 +115,8 @@ class ProviderProfileStoreImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val MAX_READ_RETRIES = 2L
|
||||||
|
private const val READ_RETRY_DELAY_MILLIS = 50L
|
||||||
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
|
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
|
||||||
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
||||||
|
|
||||||
|
|||||||
+3
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.session.profile
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
|
||||||
private const val SESSION_DATA_STORE_NAME = "provider_session"
|
private const val SESSION_DATA_STORE_NAME = "provider_session"
|
||||||
|
|
||||||
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
|
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
name = SESSION_DATA_STORE_NAME,
|
name = SESSION_DATA_STORE_NAME,
|
||||||
|
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||||
)
|
)
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.flagship.abox.manager.session
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProviderProfileStoreFailureTest {
|
||||||
|
@Test
|
||||||
|
fun ioFailureFallsBackToEmptyPreferences() = runTest {
|
||||||
|
val store = ProviderProfileStoreImpl(FailingReadDataStore())
|
||||||
|
|
||||||
|
assertEquals(emptyList<Any>(), store.observeProfiles().first())
|
||||||
|
assertEquals(null, store.observeActiveProfile().first())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FailingReadDataStore : DataStore<Preferences> {
|
||||||
|
override val data: Flow<Preferences> = flow {
|
||||||
|
throw IOException("disk unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateData(
|
||||||
|
transform: suspend (t: Preferences) -> Preferences,
|
||||||
|
): Preferences = transform(emptyPreferences())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
状态丢失
DisposableEffect的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的LoginViewModel会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。