provider selection displays ABox and Local experience only
Local experience enters the authenticated route without credentials
Local experience also enters while emulator Wi-Fi/data are disabled
Local Sign out and Switch provider return to provider selection
ABox opens the credential form; empty submission stays on login with the required-fields error
Known constraints and accepted risks
The vendor JAR uses cleartext HTTP and hardcodes a-box.com.cn; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. @CleartextAbsdkTransport, usesCleartextTraffic, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity.
Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed.
The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated feat/moler-infrared-control branch.
FakeDeviceCatalog remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation.
## Summary
- replace the former three-provider model with two pre-login choices:
- **ABox**: authenticate through the vendor ABSDK on the current local network
- **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls
- remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values
- bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations
- add production `:runtime:simulation` implementations and route device operations by the active provider
- retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation
- add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior
- align requirement, plan, architecture, and protocol documentation with LAN ABSDK access
## Verification
```text
./gradlew check testDebugUnitTest test
BUILD SUCCESSFUL (220 actionable tasks)
./gradlew :android:app:assembleDebug :android:app:assembleRelease
BUILD SUCCESSFUL (217 actionable tasks)
./gradlew :android:app:compileDebugAndroidTestKotlin
BUILD SUCCESSFUL
git diff --check
passed
```
Emulator smoke validation:
- provider selection displays ABox and Local experience only
- Local experience enters the authenticated route without credentials
- Local experience also enters while emulator Wi-Fi/data are disabled
- Local Sign out and Switch provider return to provider selection
- ABox opens the credential form; empty submission stays on login with the required-fields error
## Known constraints and accepted risks
- The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity.
- Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed.
- The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch.
- `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation.
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>Security concerns</strong><br><br>
敏感信息残留:未勾选“记住用户名”的用户在登录后退出同一提供方时,旧用户名仍可能被 Activity 级 `LoginViewModel` 保留并再次显示,从而向后续设备使用者暴露账号标识。</td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒Security concerns
敏感信息处理:密码字段未使用 KeyboardType.Password,第三方 IME 可能按普通文本学习或缓存密码;同时 Activity 级 LoginViewModel 在切换提供方后仍会保留明文密码,clearSensitiveState() 未在路由退出时调用。
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>Security concerns</strong><br><br>
敏感信息处理:密码字段未使用 `KeyboardType.Password`,第三方 IME 可能按普通文本学习或缓存密码;同时 Activity 级 `LoginViewModel` 在切换提供方后仍会保留明文密码,`clearSensitiveState()` 未在路由退出时调用。</td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 4 🔵🔵🔵🔵⚪</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
Here are some key observations to aid the review process:
⏱️Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪PR contains tests
🔒No security concerns identified
## PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
<table>
<tr><td>⏱️ <strong>Estimated effort to review</strong>: 5 🔵🔵🔵🔵🔵</td></tr>
<tr><td>🧪 <strong>PR contains tests</strong></td></tr>
<tr><td>🔒 <strong>No security concerns identified</strong></td></tr>
</table>
- Add SessionCoordinator regression test covering switch from an
authenticated ABox profile to Local experience: remote session is
logged out and cleared, only Local is the active/observable session,
credentials are cleared, auto-login disabled, and gateway state stays
isolated (verified to fail when switch cleanup is removed).
- Replace if/else provider dispatch with exhaustive when over
ProviderType in all three routing gateways so future enum additions
fail compilation.
- Silence the known ExperimentalCoroutinesApi opt-in warning in
ProviderSelectionViewModelTest with a class-level @OptIn.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
AbsdkBridgefor ABox session, socket, temperature/humidity, and infrared operations:runtime:simulationimplementations and route device operations by the active providerVerification
Emulator smoke validation:
Known constraints and accepted risks
a-box.com.cn; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision.@CleartextAbsdkTransport,usesCleartextTraffic, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity.feat/moler-infrared-controlbranch.FakeDeviceCatalogremains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation.PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
敏感信息暴露:
LoginViewModel在提供方切换后保留密码字段,可能在另一个提供方的登录页面展示上一提供方输入的密码,违反了本 PR 声明的提供方凭据隔离。应在配置 ID 变化及返回提供方选择时清空密码。凭据泄露
切换提供方时,
loadProfile只覆盖用户名和偏好设置,却保留现有的password。由于该 ViewModel 由 Activity 作用域持有,用户输入密码后切换到其他提供方,旧密码仍会出现在新提供方的登录表单中;重新选择同一提供方时还会因提前返回而完全跳过重置。这破坏了提供方间的凭据隔离,应在配置变化时立即清空密码和错误状态,并取消旧的加载任务。返回失效
提交或切换提供方期间将
BackHandler禁用,会把系统返回事件交给 Activity 默认处理,因此用户仍可通过系统返回键退出当前界面,而不是禁用返回操作。应始终注册返回处理器,并仅在navigationEnabled为真时调用onSwitchProvider,否则消费该事件。损坏凭据
对空或仅一个字节的密文直接调用
buffer.get()会抛出BufferUnderflowException,该异常未被凭据存储识别为永久损坏,因此损坏记录不会被删除,自动登录会在每次启动时持续失败。应先校验载荷至少包含版本和 IV 长度,或将该异常纳入永久凭据失败处理。已处理本轮意见:
LoginViewModel在 profile 变化时立即取消旧加载、清空 password/error/submitting 状态,再加载新 profile 偏好;离开 Login 返回提供方选择前也会调用clearSensitiveState(),不会把上一提供方密码带到下一提供方。BackHandler现在始终注册。提交或切换期间会消费系统返回事件但不执行切换,避免交给 Activity 默认退出;顶部和底部返回控件仍保持禁用。AndroidKeystoreCredentialCipher.decrypt在读取 header 前校验至少 2 字节,空/单字节/截断 payload 统一以IllegalArgumentException标记为永久损坏,由 CredentialStore 删除,避免每次启动重复失败。验证结果:
BUILD SUCCESSFUL修复提交:
3764acb fix: isolate login credentials across providersPR Reviewer Guide 🔍
Here are some key observations to aid the review process:
异常泄漏
检测到永久凭据故障后,清理损坏凭据的
dataStore.edit位于异常处理之外;如果此时写入因磁盘或 I/O 故障失败,loadCredentials会直接抛出异常,而不是返回DomainResult.Failure。在启动自动登录读取到损坏或无法解密的凭据且清理写入失败时,这可能中断会话初始化。应捕获清理操作的异常并转换为失败结果。Inline comment
@@ -34,5 +34,5 @@loadingJob?.cancel()profile = value_uiState.value = LoginUiState()loadingJob = viewModelScope.launch {val preferences = profileStore.loginPreferences(value.id)输入被覆盖
loginPreferences异步读取完成后会直接替换整个LoginUiState。如果用户在 DataStore 读取期间已输入用户名或密码,这些输入会被已保存的偏好无条件清空或覆盖。建议加载期间禁用输入,或仅在对应字段尚未被用户修改时合并偏好值。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理最新两项意见:
dataStore.edit清理现在也被捕获;清理磁盘/I/O 失败会返回DomainResult.Failure,不会从loadCredentials抛出并中断启动。新增CredentialCleanupFailureTest验证该路径。验证结果:
BUILD SUCCESSFUL修复提交:
344fa6d fix: preserve login input during preference load/review
Inline comment
@@ -45,5 +45,5 @@actionInProgress: Boolean = false,onSwitchProvider: () -> Unit,viewModel: LoginViewModel = hiltViewModel(),) {LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }状态失效
切换提供方前立即执行了
clearSensitiveState()。当switchProvider()因 DataStore 写入等原因失败且活动提供方未改变时,LaunchedEffect(profile.id)的键也不会变化,因此不会重新加载;LoginViewModel内部的profile已被置空,之后点击登录会在submit()中静默返回。应仅在切换成功后清理,或在失败时恢复并重新加载当前 profile。Inline comment
@@ -30,5 +30,5 @@private var usernameEdited = falseprivate var rememberUsernameEdited = falseprivate var autoLoginEdited = falsefun loadProfile(value: ProviderProfile) {隐私残留
loadingJob完成后从未置空,却被用作“已经加载”的判断。登录成功后退出到同一提供方时,Activity 级 ViewModel 会被复用,新的路由加载会直接返回;即使用户未选择“记住用户名”,上次输入的用户名仍会重新显示。应判断任务是否仍处于活动状态,或在finally中清空任务并按持久化偏好重置表单。Inline comment
@@ -19,5 +19,5 @@class ProviderProfileStoreImpl(private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom)启动失败
observeActiveProfile()等流直接暴露DataStore.data。Preferences DataStore 在发生 I/O 错误或数据损坏时会抛出异常,而启动状态通过stateIn收集且没有catch或重试;一次读取失败便会终止启动流,甚至成为未捕获的协程异常,使应用无法进入提供方选择或登录页。应配置数据损坏处理,并对可恢复的读取错误进行重试或映射。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
敏感信息残留:未勾选“记住用户名”的用户在登录后退出同一提供方时,旧用户名仍可能被 Activity 级
LoginViewModel保留并再次显示,从而向后续设备使用者暴露账号标识。已处理本轮三项意见:
clearSensitiveState()。切换失败时 ViewModel 保持当前 profile 和表单状态,用户可以继续登录并看到错误;切换成功后页面离开,下一次进入由loadProfile()重置敏感字段。loadingJob现在只在 active 时阻止重复加载,并在finally中置空。重新进入同一 Provider 会重新建立无密码状态并从持久化偏好加载;未勾选记住用户名时 username 为空。ReplaceFileCorruptionHandler { emptyPreferences() };观察流对IOException最多重试两次后回退到空偏好,其他异常继续上抛。新增 I/O 失败回退测试,启动流不会因可恢复存储错误永久终止。验证结果:
BUILD SUCCESSFUL修复提交:
7cb0480 fix: harden provider session restorationInline comment
@@ -42,5 +42,5 @@override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =safeData.map { preferencesFrom(it, profileId) }override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())读取容错
一次性读取
profiles()、activeProfile()和loginPreferences()时直接收集dataStore.data,绕过了本类已有的safeData重试和 IOException 降级。存储首次读取发生临时IOException时,登录偏好加载会异常退出;LoginViewModel未捕获该异常且仍保持isLoadingPreferences=true,导致登录表单和返回操作持续禁用,默认协程异常处理下还可能使应用崩溃。请改用safeData.first()或在调用层恢复状态。Inline comment
@@ -140,5 +140,5 @@enabled = formEnabled,singleLine = true,modifier = Modifier.fillMaxWidth(),)OutlinedTextField(密码输入
PasswordVisualTransformation只负责遮罩显示,不会将 AndroidEditorInfo标记为密码输入。当前字段使用默认键盘类型,启用学习或建议功能的第三方 IME 可能将密码作为普通文本缓存或显示为建议;请配置KeyboardOptions(keyboardType = KeyboardType.Password)并关闭自动更正。Inline comment
@@ -43,5 +43,5 @@bootstrapError: String? = null,actionError: String? = null,actionInProgress: Boolean = false,onSwitchProvider: () -> Unit,viewModel: LoginViewModel = hiltViewModel(),敏感残留
LoginRoute直接调用hiltViewModel(),在当前没有导航条目作为 owner 的结构下,LoginViewModel会由 Activity 持有。切换提供方离开登录界面时不会清除它,而新增的clearSensitiveState()也没有调用点,因此已输入的明文密码会在提供方选择页期间继续保存在内存中,直到再次加载配置或 Activity 销毁。请在路由退出时显式清理敏感状态,或将 ViewModel 作用域绑定到登录路由。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
敏感信息处理:密码字段未使用
KeyboardType.Password,第三方 IME 可能按普通文本学习或缓存密码;同时 Activity 级LoginViewModel在切换提供方后仍会保留明文密码,clearSensitiveState()未在路由退出时调用。已处理最新三项意见:
profiles()、activeProfile()和loginPreferences()现在统一从safeData.first()读取,使用与观察流相同的 IOException 重试和空偏好回退;测试覆盖一次性读取失败路径。KeyboardType.Password、ImeAction.Done并关闭自动更正,使 Android EditorInfo 正确标记为密码输入,降低第三方 IME 学习/建议风险。LoginRoute使用DisposableEffect(profile.id),仅在 Login 实际离开 Composition 时调用clearSensitiveState()。切换失败时路由仍存在且不会清理;切换成功、登录成功或 Activity 页面离开时立即清除 Activity 级 ViewModel 中的明文密码。验证结果:
BUILD SUCCESSFUL修复提交:
8788fe6 fix: secure login route lifecycleInline comment
@@ -26,5 +26,5 @@private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {private val safeData: Flow<Preferences>get() = dataStore.data.retryWhen { cause, attempt ->观察流终止
当
DataStore的读取IOException超过重试上限时,catch发出一次空首选项后会结束整个冷流。observeActiveProfile()等长期订阅不会在存储恢复后继续接收更新;例如用户重新选择提供方且写入成功,应用仍可能停在提供方选择界面,直到进程重启。应让观察流继续重试或重新订阅,而不是以空值结束。Inline comment
@@ -97,5 +97,5 @@stringPreferencesKey("profile_${profileId.value}_credential")}}private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {凭据误删
isPermanentCredentialFailure()将所有KeyStoreException、UnrecoverableKeyException和InvalidKeyException都视为永久损坏,但这些异常也可能包装 Android Keystore 的瞬时服务或硬件错误。此时loadCredentials()会删除唯一密文并返回无凭据,导致一次暂时故障永久清除自动登录信息。仅应对格式损坏、认证标签失败或已确认永久失效的密钥执行清理。Inline comment
@@ -82,5 +82,5 @@require(data.available() == 0) { "Unexpected credential data" }LoginCredentials(Username(username), Password(password))}private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {取消被吞
catch (Exception)会捕获CancellationException。当登录或凭据持久化协程因 ViewModel 销毁等原因被取消时,loadCredentials()和operation()会把取消转换成普通DomainResult.Failure,使上层协调器可能继续执行后续状态或持久化步骤。应优先重新抛出CancellationException。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理最新三项意见:
IllegalArgumentException)、GCM 标签/AAD 失败 (AEADBadTagException) 和 Android 明确的KeyPermanentlyInvalidatedException。通用 KeyStore/InvalidKey/UnrecoverableKey 异常现在返回 Failure 并保留密文以便重试。CancellationException,不再转换为业务失败。验证结果:
BUILD SUCCESSFUL修复提交:
03bf28a fix: preserve session storage through transient failuresInline comment
@@ -25,5 +25,5 @@class ProviderProfileStoreImpl(private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {private val safeData: Flow<Preferences>get() = dataStore.data.retryWhen { cause, attempt ->无限重试
达到
MAX_READ_RETRIES后虽然发射了空配置,但谓词仍返回true,因此持续性IOException会让每个长期订阅者以约 50 毫秒的间隔无限重读并反复发射空配置,造成电量消耗和会话界面抖动。达到上限后应停止重试,并通过catch等方式仅发射一次回退值。Inline comment
@@ -45,5 +45,5 @@private fun stateFor(profile: ProviderProfile?,session: Session?,bootstrapError: String?,): AppSessionUiState = when {可用性绕过
stateFor未校验当前构建中的ProviderAvailability。如果设备保留了来自允许 ABox 的构建或旧版本的活动配置,release 构建仍会直接进入该提供方的登录页并允许发起登录,而不是按禁用状态返回提供方选择。应在启动时拒绝或清除当前构建中已禁用的活动配置。Inline comment
@@ -38,5 +38,5 @@usernameEdited = falserememberUsernameEdited = falseautoLoginEdited = false_uiState.value = LoginUiState(isLoadingPreferences = true)loadingJob = viewModelScope.launch {界面锁死
loginPreferences抛出非IOException的存储或数据解析异常时没有被捕获,协程可能导致应用崩溃;即使异常被全局处理,isLoadingPreferences也会保持为true,从而永久禁用表单和返回提供方入口。异常路径应恢复加载状态并发布可重试的错误。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理最新三项意见:
saveProfile、setActiveProfile或saveLoginPreferences成功后的刷新信号再重新订阅。新增测试验证失败回退后,成功写入会恢复并发出真实活动 profile。AppSessionViewModel注入当前变体的ProviderAvailability。启动时若持久化活动 profile 在当前构建中禁用,会执行switchProvider()清理旧状态并进入选择页;stateFor也防御性拒绝渲染禁用 profile 的登录或已认证页面。LoginViewModel对非取消异常恢复isLoadingPreferences=false并显示加载设置失败信息;用户已编辑字段和 password 保持不变,表单和返回入口恢复可用;CancellationException继续传播。验证结果:
BUILD SUCCESSFUL修复提交:
1c5caf5 fix: enforce provider availability on restoreInline comment
@@ -76,5 +76,5 @@}}}fun clearSensitiveState() {并发登录
LoginRoute在组合销毁(包括屏幕旋转)时调用clearSensitiveState,但这里仅取消偏好加载任务,未处理submit()启动的登录任务。Hilt ViewModel 会跨配置变更存活,因此慢速登录期间旋转会将isSubmitting重置为 false,使新界面能够再次提交或切换提供方,而旧请求仍可能完成并写入会话或新界面状态。应跟踪提交任务,并区分配置变更与实际离开登录流程。Inline comment
@@ -70,5 +70,5 @@}private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->DataOutputStream(output).use { data ->data.writeUTF(credentials.username.value)长度上限
DataOutputStream.writeUTF的编码长度上限为 65,535 字节,但登录输入未限制用户名或密码长度。用户粘贴超长凭据并启用自动登录时,保存会抛出UTFDataFormatException,导致登录凭据无法持久化,甚至使已成功的登录被报告为失败。建议改用显式长度前缀的 UTF-8 编码,或在提交前实施一致的长度校验。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理最新两项意见:
LoginViewModel现在跟踪submitJob,活动登录期间拒绝重复提交;Composition 因旋转短暂退出时clearSensitiveState()会清空 password 但保留isSubmitting=true和活动 profile,重建后不会重新加载/允许第二次提交。登录任务在finally中清除引用。DataOutputStream.writeUTF/readUTF,使用version + Int 长度 + 标准 UTF-8 bytes格式;解码验证版本、非负长度、每字段 1 MiB 上限、剩余数据和尾随数据。新增超过 65,535 字节的用户名/密码往返测试。验证结果:
BUILD SUCCESSFUL修复提交:
5471467 fix: serialize login submission stateInline comment
@@ -30,5 +30,5 @@bootstrapResult,profileStore.observeActiveProfile(),coordinator.observeActiveSession(),) { result, profile, session ->if (result == null) AppSessionUiState.Loading错误串线
bootstrapResult是全局一次性状态,其失败信息会被复用于之后选择的所有提供方。若启动时 ABox 引导因网络问题失败,随后用户切换到本地体验,新登录页仍会显示前一个提供方的错误。应按提供方关联引导结果,或在活动提供方变化时清除旧错误。Inline comment
@@ -44,3 +56,4 @@)}}}状态泄漏
同一个
actionState.errorMessage被直接传给登录页和已认证页,且路由变化时没有清除。切换提供方失败后,如果用户改为登录并成功进入已认证页,之前的切换错误仍会继续显示,并且当前界面没有调用dismissError的途径。应在会话状态变化或其他操作成功时清除该错误,或将错误限定在发起操作的界面。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理两项状态隔离意见:
ProviderProfileId,只有当前活动 profile 与该 ID 一致时才向 Login 页面显示错误;切换到其他 Provider 后旧错误自动失效。LaunchedEffect调用dismissError()。操作失败仍在原页面显示,但登录成功、切换 Provider 或进入其他阶段后旧错误不会跨页面残留。验证结果:
BUILD SUCCESSFUL修复提交:
40bbc2f fix: scope session errors to active screenInline comment
@@ -77,5 +77,5 @@private data class ProviderBootstrapResult(val profileId: ProviderProfileId?,val result: DomainResult<Session?>,) {fun errorFor(currentProfileId: ProviderProfileId?): String? =陈旧错误
ProviderBootstrapResult只按提供方 ID 匹配,且在 ViewModel 生命周期内从未清除。启动自动登录失败后,即使用户手动登录成功再退出,或切换后重新选择同一提供方,登录页仍会显示旧的启动错误。应在启动结果被展示或当前提供方发生变化后清除该错误。Inline comment
@@ -148,5 +148,5 @@}}}private fun DomainError.toUiMessage(): String = when (this) {本地化缺失
toUiMessage()将认证失败、网络不可用和会话过期等用户可见错误硬编码为英文,因此在zh-rCN环境中登录失败仍会显示英文。应返回可本地化的错误标识,并在 UI 层通过字符串资源解析。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理两项意见:
LoginErrorMessage枚举;LoginScreen 通过 string resources 解析。已补齐必填、凭据错误、网络、会话过期、偏好读取和通用失败的英文与简体中文文案。验证结果:
BUILD SUCCESSFUL修复提交:
d893a74 fix: expire and localize login errorsInline comment
@@ -97,5 +97,5 @@bootstrapError: String?,): AppSessionUiState = when {profile == null -> AppSessionUiState.ProviderSelectionavailability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelectionsession != null -> AppSessionUiState.Authenticated(profile)会话错配
stateFor只判断session != null,没有验证session.profileId == profile.id。切换提供方时,observeActiveProfile()与observeActiveSession()是独立流,combine可能短暂收到新配置与旧会话,从而把新提供方错误标记为已认证;认证分支应校验两个 ID 是否一致。Inline comment
@@ -43,5 +43,5 @@DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))} catch (error: CancellationException) {throw error} catch (error: Exception) {if (error.isPermanentCredentialFailure()) {密钥失效
KeyPermanentlyInvalidatedException被当作永久凭据故障处理时,只删除了 DataStore 密文,固定别名下的失效 Keystore 密钥仍会被继续取回。设备永久失效该密钥后,后续保存自动登录凭据会持续失败,直至清除应用数据;需要轮换或删除失效密钥,并同步清理受影响的密文。Inline comment
@@ -50,5 +50,5 @@onSwitchProvider: () -> Unit,viewModel: LoginViewModel = hiltViewModel(),) {LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }DisposableEffect(profile.id) {状态丢失
DisposableEffect的清理操作不仅在真正离开登录页时执行,也会在旋转屏幕等配置变更导致 Composition 重建时执行。由于 Activity 作用域的LoginViewModel会被保留,这里反而会主动清空正在输入的用户名、密码和开关状态;应仅在路由实际离开或提供方发生变化时清理。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理最新三项意见:
session.profileId == activeProfile.id时成立;Flow 切换瞬间的新 profile + 旧 session 组合会保持未认证,不会错误进入已认证页。ABoxManagerApp持有同一 LoginViewModel,并只在 screen key 变为非 Login 时清理敏感状态;屏幕旋转/Composition 重建仍处于同一 Login,不会清空表单;登录成功或切换成功后会立即清理。KeyPermanentlyInvalidatedException时先删除固定 alias,再清理不可恢复密文;下次保存会生成新 master key。其他永久格式/GCM 损坏只清理密文,不误删有效 key。验证结果:
BUILD SUCCESSFUL修复提交:
a0f55b0 fix: align session state across transitionsInline comment
@@ -152,5 +152,5 @@booleanPreferencesKey("profile_${id.value}_auto_login")}}private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->状态误判
catchIOExceptionAsEmpty()会将重试后的临时IOException当成真实空仓库,并结束当前 DataStore 订阅。启动时若存储短暂不可用,应用会误判为没有活跃提供方并跳回提供方选择;存储恢复后也不会自动重新订阅,除非后续写操作触发refresh。应继续重试、主动重新订阅或显式暴露读取失败,而不是发出emptyPreferences()。Inline comment
@@ -116,5 +116,5 @@.padding(contentPadding),contentAlignment = Alignment.TopCenter,) {Column(modifier = Modifier宽度失效
先应用
fillMaxWidth()会把子布局宽度固定为父容器全宽,后续的widthIn(max = 520.dp)无法再收窄约束,因此平板和横屏上的表单仍会铺满屏幕。应交换这两个修饰符的顺序;提供方选择页和认证占位页也存在相同问题。Inline comment
@@ -42,5 +42,5 @@.fillMaxSize().padding(contentPadding),contentAlignment = Alignment.Center,) {Column(内容不可达
认证页的内容列没有滚动能力。在横屏、小高度窗口或大字体模式下,描述、错误信息和两个操作按钮可能超出可用高度,导致底部按钮无法显示或点击。应为内容添加垂直滚动,或改用能处理溢出的响应式布局。
PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理本轮意见:
widthIn(max)现在先于fillMaxWidth()应用,平板/横屏可正确限制内容宽度。验证结果:JVM 测试、lint、debug/release 构建与 check 均
BUILD SUCCESSFUL。修复提交:
cf2c59a fix: keep provider state during storage outagesInline comment
@@ -32,5 +32,5 @@private var usernameEdited = falseprivate var rememberUsernameEdited = falseprivate var autoLoginEdited = falsefun loadProfile(value: ProviderProfile) {状态丢失
LoginRoute重新进入组合时会再次调用loadProfile。当同一提供方已加载完成且当前没有任务时,此实现仍会重置编辑标记并用新的LoginUiState覆盖现有状态。因此 Activity 因旋转等配置变更重建时,虽然 ViewModel 得以保留,用户已输入的用户名和密码仍会被清空。应对已加载的相同配置直接返回,或在刷新时保留当前编辑值。Inline comment
@@ -24,5 +24,5 @@class ProviderProfileStoreImpl(private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {private val safeData: Flow<Preferences>无限重试
safeData遇到任何IOException都会无限重试,没有最大次数或最终错误路径。如果 DataStore 因磁盘或文件系统故障而持续不可读,activeProfile()和loginPreferences()将永远挂起,导致应用一直停留在加载页或登录表单持续禁用,上层异常处理也无法展示错误。应限制重试次数,并在耗尽后抛出错误或执行明确的降级处理。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
已处理复审意见:
loadedProfileId。同一 Provider 已完成加载时,配置变化重组不会再次重置LoginUiState,用户输入和开关得以保留;真正离开 Login 时根状态层清理会重置该标识。验证结果:JVM 测试、lint、debug/release 构建与 check 均
BUILD SUCCESSFUL。修复提交:
d0ad601 fix: bound provider storage retriesInline comment
@@ -24,5 +24,5 @@class ProviderProfileStoreImpl(private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {private val safeData: Flow<Preferences>启动失败
safeData在两次重试后仍会向调用方传播IOException,而启动流程中的activeProfile()和observeActiveProfile()均未处理该异常。持续性存储读取故障会触发未处理异常;即使异常被全局处理,bootstrapResult也可能一直为空,使应用永久停留在加载页。应在重试耗尽后将错误转换为可恢复状态或显式领域错误。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
补充回复
cf2c59a的审查意见:d0ad601中改为两次有限重试后将错误交由上层的类型化启动/表单错误路径处理,不再伪造空仓库状态,也不进行无限高频轮询。widthIn(max)在fillMaxWidth()前生效;宽屏内容受限于 520dp / 640dp。verticalScroll,横屏、小高度或大字体下退出和切换提供方按钮仍可访问。上述修复随提交
d0ad601 fix: bound provider storage retries推送。随后针对登录状态与会话边界的复审意见也已继续处理并推送。Inline comment
@@ -24,5 +24,5 @@class ProviderProfileStoreImpl(private val dataStore: DataStore<Preferences>,) : ProviderProfileStore {private val safeData: Flow<Preferences>启动卡死
当 DataStore 连续读取失败并耗尽重试后,
safeData会继续抛出IOException。observeActiveProfile()是combine(...).stateIn(...)的上游之一,该异常会终止共享协程,导致启动界面永久停留在加载状态或后续一直保留陈旧状态。应在共享前捕获并映射为可恢复错误,或采用不会终止状态流的重试策略。Inline comment
@@ -64,5 +64,5 @@}} catch (error: kotlinx.coroutines.CancellationException) {throw error} catch (error: Exception) {bootstrapResult.value = ProviderBootstrapResult(错误丢失
当已有活动提供方且
coordinator.bootstrap()抛出异常时,失败结果被错误地关联到空profileId。随后errorFor(activeProfile.id)会过滤该错误,监听协程还可能将其覆盖为成功,导致自动登录或会话恢复失败时没有任何提示。应保留已读取的活动提供方 ID,并将异常绑定到该 ID。Inline comment
@@ -40,5 +40,5 @@}}companion object {private val PROFILES = listOf(本地化错误
内置提供方的英文名称被写入
ProviderProfile.displayName并持久化,而认证页面直接展示该字段。在中文区域选择本地体验后,标题会显示为“已连接到 Local experience”。内置提供方应按ProviderType从资源中解析名称,而不是持久化固定的英文展示文案。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
Inline comment
@@ -48,5 +48,5 @@dataStore: DataStore<Preferences>,cipher: CredentialCipher,): CredentialStore = KeystoreCredentialStore(dataStore, cipher)@Provides明文传输
SessionModule位于 main 源集,却显式启用CleartextAbsdkTransport并调用AbsdkBridge.create(),因此包括 release 在内的所有变体都会绑定真实明文桥接。用户选择 ABox 并登录时,凭据可能通过未经批准的明文 ABSDK 通道传输。应将该绑定移至受限的开发变体并使用模拟实现,release 不应构造此桥接。Inline comment
@@ -32,5 +32,5 @@)}}companion object {发布门禁
PROFILES在 main 源集中无条件包含可选择的 ABox,未使用构建变体或提供方可用性配置。release 用户因此仍可选择 ABox、持久化该配置并进入远程登录流程,与“release ABox 保持禁用”的约束不符。应注入按变体区分的可用提供方列表。Inline comment
@@ -40,5 +40,5 @@composeRule.onNodeWithText("ABox").assertIsEnabled()composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()assertEquals(local, selected)composeRule.onNodeWithText("Compatible backend").assertDoesNotExist()测试编译
assertDoesNotExist是 Compose 测试包中的顶层扩展,但当前文件未导入它,androidTest 源集编译时会出现未解析引用。请添加androidx.compose.ui.test.assertDoesNotExist导入。PR Reviewer Guide 🔍
Here are some key observations to aid the review process:
明文传输:main 源集显式启用了
CleartextAbsdkTransport并构造真实AbsdkBridge,同时应用清单通过android:usesCleartextTraffic="true"对所有变体全局允许明文流量。由于 release 中 ABox 仍可选择,用户名、密码及会话流量可能遭到同一局域网内的监听或篡改。