feat: add provider login flow #4
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -45,8 +47,9 @@ fun AuthenticatedPlaceholderScreen(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
|
||||||
.widthIn(max = 520.dp)
|
.widthIn(max = 520.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ fun LoginScreen(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
pr-agent-bot
commented
宽度失效 先应用 **宽度失效**
先应用 `fillMaxWidth()` 会把子布局宽度固定为父容器全宽,后续的 `widthIn(max = 520.dp)` 无法再收窄约束,因此平板和横屏上的表单仍会铺满屏幕。应交换这两个修饰符的顺序;提供方选择页和认证占位页也存在相同问题。
<!-- pr-agent-dedup: bffc56d7c283 -->
<!-- pr-agent-key-issue-location: a2a13b58e997 -->
|
|||||||
.fillMaxWidth()
|
|
||||||
.widthIn(max = 520.dp)
|
.widthIn(max = 520.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ fun ProviderSelectionScreen(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
|
||||||
.widthIn(max = 640.dp)
|
.widthIn(max = 640.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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
|
||||||
@@ -18,34 +17,25 @@ import java.io.IOException
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
|
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 -->
|
|||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
class ProviderProfileStoreImpl(
|
class ProviderProfileStoreImpl(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) : ProviderProfileStore {
|
) : ProviderProfileStore {
|
||||||
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
|
||||||
private val safeData: Flow<Preferences>
|
private val safeData: Flow<Preferences>
|
||||||
get() = refresh
|
get() = dataStore.data
|
||||||
.onStart { emit(Unit) }
|
|
||||||
.flatMapLatest { readDataOnce() }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
|
|
||||||
private fun readDataOnce(): Flow<Preferences> = dataStore.data
|
|
||||||
.retryWhen { cause, attempt ->
|
.retryWhen { cause, attempt ->
|
||||||
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
if (cause !is IOException) return@retryWhen false
|
||||||
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
val delayMillis = (READ_RETRY_DELAY_MILLIS shl
|
||||||
retry
|
attempt.coerceAtMost(MAX_BACKOFF_SHIFT).toInt())
|
||||||
|
.coerceAtMost(MAX_READ_RETRY_DELAY_MILLIS)
|
||||||
|
delay(delayMillis)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
.catchIOExceptionAsEmpty()
|
.distinctUntilChanged()
|
||||||
|
|
||||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||||
|
|
||||||
@@ -67,7 +57,6 @@ class ProviderProfileStoreImpl(
|
|||||||
preferences[profileTypeKey(profile.id)] = profile.type.name
|
preferences[profileTypeKey(profile.id)] = profile.type.name
|
||||||
preferences[profileNameKey(profile.id)] = profile.displayName
|
preferences[profileNameKey(profile.id)] = profile.displayName
|
||||||
}
|
}
|
||||||
refresh.tryEmit(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
||||||
@@ -75,7 +64,6 @@ class ProviderProfileStoreImpl(
|
|||||||
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
||||||
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
||||||
}
|
}
|
||||||
refresh.tryEmit(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveLoginPreferences(
|
override suspend fun saveLoginPreferences(
|
||||||
@@ -89,7 +77,6 @@ class ProviderProfileStoreImpl(
|
|||||||
if (username == null) values.remove(usernameKey(profileId))
|
if (username == null) values.remove(usernameKey(profileId))
|
||||||
else values[usernameKey(profileId)] = username
|
else values[usernameKey(profileId)] = username
|
||||||
}
|
}
|
||||||
refresh.tryEmit(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
||||||
@@ -131,8 +118,9 @@ class ProviderProfileStoreImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_READ_RETRIES = 2L
|
private const val READ_RETRY_DELAY_MILLIS = 100L
|
||||||
private const val READ_RETRY_DELAY_MILLIS = 50L
|
private const val MAX_READ_RETRY_DELAY_MILLIS = 5_000L
|
||||||
|
private const val MAX_BACKOFF_SHIFT = 6L
|
||||||
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")
|
||||||
|
|
||||||
@@ -152,7 +140,3 @@ class ProviderProfileStoreImpl(
|
|||||||
booleanPreferencesKey("profile_${id.value}_auto_login")
|
booleanPreferencesKey("profile_${id.value}_auto_login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->
|
|
||||||
|
pr-agent-bot
commented
状态误判
**状态误判**
`catchIOExceptionAsEmpty()` 会将重试后的临时 `IOException` 当成真实空仓库,并结束当前 DataStore 订阅。启动时若存储短暂不可用,应用会误判为没有活跃提供方并跳回提供方选择;存储恢复后也不会自动重新订阅,除非后续写操作触发 `refresh`。应继续重试、主动重新订阅或显式暴露读取失败,而不是发出 `emptyPreferences()`。
<!-- pr-agent-dedup: 7ef37455761e -->
<!-- pr-agent-key-issue-location: 6b0209c52bcf -->
|
|||||||
if (error is IOException) emit(emptyPreferences()) else throw error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,58 +3,32 @@ package com.flagship.abox.manager.session
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.emptyPreferences
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.take
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ProviderProfileStoreFailureTest {
|
class ProviderProfileStoreFailureTest {
|
||||||
@Test
|
|
||||||
fun ioFailureFallsBackToEmptyPreferences() = runTest {
|
|
||||||
val store = ProviderProfileStoreImpl(FailingReadDataStore())
|
|
||||||
|
|
||||||
assertEquals(emptyList<Any>(), store.observeProfiles().first())
|
|
||||||
assertEquals(null, store.observeActiveProfile().first())
|
|
||||||
assertEquals(emptyList<Any>(), store.profiles())
|
|
||||||
assertEquals(null, store.activeProfile())
|
|
||||||
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun observerResubscribesAfterSuccessfulWrite() = runTest {
|
fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest {
|
||||||
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
|
val store = ProviderProfileStoreImpl(FailingReadDataStore())
|
||||||
val emissions = mutableListOf<ProviderProfile?>()
|
var completed = false
|
||||||
val collector = backgroundScope.launch {
|
|
||||||
store.observeActiveProfile().take(2).toList(emissions)
|
backgroundScope.launch {
|
||||||
|
runCatching { withTimeout(250) { store.activeProfile() } }
|
||||||
|
completed = true
|
||||||
}
|
}
|
||||||
advanceTimeBy(200)
|
advanceTimeBy(200)
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(listOf<ProviderProfile?>(null), emissions)
|
|
||||||
|
|
||||||
val profile = ProviderProfile(
|
assertFalse(completed)
|
||||||
ProviderProfileId("recovered"),
|
|
||||||
ProviderType.LOCAL_EXPERIENCE,
|
|
||||||
"Recovered",
|
|
||||||
)
|
|
||||||
store.saveProfile(profile)
|
|
||||||
store.setActiveProfile(profile.id)
|
|
||||||
advanceUntilIdle()
|
|
||||||
collector.join()
|
|
||||||
|
|
||||||
assertEquals(listOf(null, profile), emissions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FailingReadDataStore : DataStore<Preferences> {
|
private class FailingReadDataStore : DataStore<Preferences> {
|
||||||
@@ -66,22 +40,4 @@ class ProviderProfileStoreFailureTest {
|
|||||||
transform: suspend (t: Preferences) -> Preferences,
|
transform: suspend (t: Preferences) -> Preferences,
|
||||||
): Preferences = transform(emptyPreferences())
|
): Preferences = transform(emptyPreferences())
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
emit(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateData(
|
|
||||||
transform: suspend (t: Preferences) -> Preferences,
|
|
||||||
): Preferences {
|
|
||||||
current = transform(current)
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
内容不可达
认证页的内容列没有滚动能力。在横屏、小高度窗口或大字体模式下,描述、错误信息和两个操作按钮可能超出可用高度,导致底部按钮无法显示或点击。应为内容添加垂直滚动,或改用能处理溢出的响应式布局。