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
5 changed files with 28 additions and 85 deletions
Showing only changes of commit cf2c59a493 - Show all commits
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -45,8 +47,9 @@ fun AuthenticatedPlaceholderScreen(
) {
Column(
Review

内容不可达

认证页的内容列没有滚动能力。在横屏、小高度窗口或大字体模式下,描述、错误信息和两个操作按钮可能超出可用高度,导致底部按钮无法显示或点击。应为内容添加垂直滚动,或改用能处理溢出的响应式布局。

**内容不可达** 认证页的内容列没有滚动能力。在横屏、小高度窗口或大字体模式下,描述、错误信息和两个操作按钮可能超出可用高度,导致底部按钮无法显示或点击。应为内容添加垂直滚动,或改用能处理溢出的响应式布局。 <!-- pr-agent-dedup: 146bd02f9d65 --> <!-- pr-agent-key-issue-location: bf65edf014f7 -->
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 520.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -118,8 +118,8 @@ fun LoginScreen(
) {
Column(
modifier = Modifier
Review

宽度失效

先应用 fillMaxWidth() 会把子布局宽度固定为父容器全宽,后续的 widthIn(max = 520.dp) 无法再收窄约束,因此平板和横屏上的表单仍会铺满屏幕。应交换这两个修饰符的顺序;提供方选择页和认证占位页也存在相同问题。

**宽度失效** 先应用 `fillMaxWidth()` 会把子布局宽度固定为父容器全宽,后续的 `widthIn(max = 520.dp)` 无法再收窄约束,因此平板和横屏上的表单仍会铺满屏幕。应交换这两个修饰符的顺序;提供方选择页和认证占位页也存在相同问题。 <!-- pr-agent-dedup: bffc56d7c283 --> <!-- pr-agent-key-issue-location: a2a13b58e997 -->
.fillMaxWidth()
.widthIn(max = 520.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -65,8 +65,8 @@ fun ProviderSelectionScreen(
) {
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.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.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
@@ -18,34 +17,25 @@ 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
Review

无限重试

safeData 遇到任何 IOException 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,activeProfile() 和 loginPreferences() 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。

**无限重试** `safeData` 遇到任何 `IOException` 都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,`activeProfile()` 和 `loginPreferences()` 将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。 <!-- pr-agent-dedup: 4389fff2f610 --> <!-- pr-agent-key-issue-location: b2da98684d7a -->
Review

启动失败

safeData 在两次重试后仍会向调用方传播 IOException,而启动流程中的 activeProfile() 和 observeActiveProfile() 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,bootstrapResult 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。

**启动失败** `safeData` 在两次重试后仍会向调用方传播 `IOException`,而启动流程中的 `activeProfile()` 和 `observeActiveProfile()` 均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,`bootstrapResult` 也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。 <!-- pr-agent-dedup: 7302d3a589b8 --> <!-- pr-agent-key-issue-location: db2a740c074a -->
Review

启动卡死

当 DataStore 连续读取失败并耗尽重试后,safeData 会继续抛出 IOException。observeActiveProfile() 是 combine(...).stateIn(...) 的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。

**启动卡死** 当 DataStore 连续读取失败并耗尽重试后,`safeData` 会继续抛出 `IOException`。`observeActiveProfile()` 是 `combine(...).stateIn(...)` 的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。 <!-- pr-agent-dedup: 31fd4d3bef7b --> <!-- pr-agent-key-issue-location: 2275a87bf139 -->
import kotlinx.coroutines.flow.retryWhen
@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>
get() = refresh
.onStart { emit(Unit) }
.flatMapLatest { readDataOnce() }
.distinctUntilChanged()
private fun readDataOnce(): Flow<Preferences> = dataStore.data
get() = dataStore.data
.retryWhen { cause, attempt ->
val retry = cause is IOException && attempt < MAX_READ_RETRIES
if (retry) delay(READ_RETRY_DELAY_MILLIS)
retry
if (cause !is IOException) return@retryWhen false
val delayMillis = (READ_RETRY_DELAY_MILLIS shl
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)
@@ -67,7 +57,6 @@ 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 {
@@ -75,7 +64,6 @@ class ProviderProfileStoreImpl(
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value
}
refresh.tryEmit(Unit)
}
override suspend fun saveLoginPreferences(
@@ -89,7 +77,6 @@ class ProviderProfileStoreImpl(
if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username
}
refresh.tryEmit(Unit)
}
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
@@ -131,8 +118,9 @@ class ProviderProfileStoreImpl(
}
companion object {
private const val MAX_READ_RETRIES = 2L
private const val READ_RETRY_DELAY_MILLIS = 50L
private const val READ_RETRY_DELAY_MILLIS = 100L
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 ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
@@ -152,7 +140,3 @@ class ProviderProfileStoreImpl(
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->
Review

状态误判

catchIOExceptionAsEmpty() 会将重试后的临时 IOException 当成真实空仓库,并结束当前 DataStore 订阅。启动时若存储短暂不可用,应用会误判为没有活跃提供方并跳回提供方选择;存储恢复后也不会自动重新订阅,除非后续写操作触发 refresh。应继续重试、主动重新订阅或显式暴露读取失败,而不是发出 emptyPreferences()。

**状态误判** `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.preferences.core.Preferences
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.ProviderType
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.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 kotlinx.coroutines.withTimeout
import org.junit.Assert.assertFalse
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())
assertEquals(emptyList<Any>(), store.profiles())
assertEquals(null, store.activeProfile())
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Test
fun observerResubscribesAfterSuccessfulWrite() = runTest {
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
val emissions = mutableListOf<ProviderProfile?>()
val collector = backgroundScope.launch {
store.observeActiveProfile().take(2).toList(emissions)
fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest {
val store = ProviderProfileStoreImpl(FailingReadDataStore())
var completed = false
backgroundScope.launch {
runCatching { withTimeout(250) { store.activeProfile() } }
completed = true
}
advanceTimeBy(200)
advanceUntilIdle()
assertEquals(listOf<ProviderProfile?>(null), emissions)
val profile = ProviderProfile(
ProviderProfileId("recovered"),
ProviderType.LOCAL_EXPERIENCE,
"Recovered",
)
store.saveProfile(profile)
store.setActiveProfile(profile.id)
advanceUntilIdle()
collector.join()
assertEquals(listOf(null, profile), emissions)
assertFalse(completed)
}
private class FailingReadDataStore : DataStore<Preferences> {
@@ -66,22 +40,4 @@ class ProviderProfileStoreFailureTest {
transform: suspend (t: Preferences) -> Preferences,
): 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
}
}
}