feat(settings): 退出登录加二次确认;保活那行补系统上限并给「去系统设置」入口

两件事都在这一个切片里,都只碰 ui/settings/**。

一、「退出登录」的确认框
- 现状:W52C 把它改成整行可点的 BasicComponent(红色标题 + Remove 图标),而它紧挨着
  「版本」那一行,两条长得几乎一样 ⇒ 误触代价是一次真登出(AuthManager.logout 会吊销
  服务端 refresh 并把本机凭据整份清空,没有撤销)。W52C 自己把这条风险标了出来。
- 改成:点条目只立一个本页的 remember 状态(不进任何 VM),弹出的确认框里
  「取消 / 点框外 / 系统返回键」三个出口都只是关掉框,一个字都不改;只有确认那一路
  才调 onLogout —— 行为链路与加框之前完全一致。
- 控件用的是 Miuix 自己的 OverlayDialog(top.yukonga.miuix.kmp.overlay),由本页 Scaffold
  自带的 MiuixPopupHost 承载:标题 / 说明 / 内容分槽,正是为这种事准备的。
  (注意 Miuix 0.9.x 并没有 extra.* 包,对话框只在这两个包里,别照旧版 import 去猜。)
- 说明文案如实带上代价:退出后本机登录信息整份清空,服务器地址也要重填。

二、「后台保持连接」那行
- 补事实:dataSync 型前台服务在 targetSdk 37 下,24 小时内累计最多 6 小时会被系统回调
  onTimeout(KeepAliveService 到点自停以免 ANR)⇒「全天候保活」做不到。原先这句只写在
  KeepAliveService 的代码注释里,用户看不到;现在写进开关下面那行字,且落在「关 / 开」
  两档——只写在「已开」那一档等于默认所有人都已经开过一遍,而默认态恰恰是关的。
- 补入口:权限把功能挡住的两档(刚被拒 / 开关开着但权限被撤)原先只说「到系统设置里允许」,
  用户得自己摸进去找。现在这两档下面多一个「去系统设置」按钮,直接跳到本 App 的通知设置页
  (ACTION_APP_NOTIFICATION_SETTINGS + EXTRA_APP_PACKAGE;厂商砍了这个页面就退到应用详情页,
  两次都失败就什么都不做,绝不崩)。按钮出现的条件与那两句文案一一对应,两处不会分叉。
- 顺手复核:点了按钮从系统设置回来时,由 ActivityResult 回调现读一次真实授权,把过期的
  「刚刚被拒」清掉 —— 比挂生命周期钩子直接,也不需要它。
- 测试:KeepAliveSummaryTest 原有的 4 条断言一条没动(新增事实挂在「关 / 开」两档,正好
  不吃「已开」与「起不来」那两条);新增两条 —— ① 关 / 开两档都必须写明 24 小时窗口与
  6 小时上限;② 权限挡住的那两档都必须把用户指向系统设置(与按钮的判据同源)。

验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest
     BUILD SUCCESSFUL;101 suite / 1272 tests / 0 失败(基线 101/1270)。
This commit is contained in:
AskaEth committed 2026-10-06 18:52:31 +08:00
1 parent f8ac95ceed
commit bbd02fc279
3 files changed
+183 -14

No files matched your search

@@ -11,7 +11,13 @@ import org.junit.Test
* 这一行的价值全在「说真话」,所以钉的也是真话:
* 1. **开的代价自己说出来**(常驻通知 + 多耗电)——不说的代价会变成用户某天「莫名多了条通知」的意外;
* 2. **跑不了的时候不许说在跑**(通知权限被拒 / 被撤时,一个字都不提「已开」);
* 3. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
* 3. **能跑多久也要说**——系统给 `dataSync` 型前台服务的额度是 24 小时窗口内累计 6 小时
* (`KeepAliveService.onTimeout` 到点自停),所以「全天候保活」做不到;这句必须落在
* 用户据以做决定的「关 / 开」两档里,否则他会以为自己买到了永远;
* 4. **话与按钮指着同一个地方**——权限那两档写着「到系统设置里允许」,设置页那个
* 「去系统设置」按钮就出现在这两档下(`KeepAliveRow` 里算 `settingsShortcut` 的那个条件
* 与之一一对应);
* 5. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
*/
class KeepAliveSummaryTest {
@@ -47,6 +53,36 @@ class KeepAliveSummaryTest {
assertFalse("被拒时不许写成「已开」:$text", text.contains("已开"))
}
/**
* 「关」和「开」两档是用户据以做决定的那两档,系统上限必须出现在这里——
* 只在「已开」那一档说,等于默认所有人都已经开过一遍(而默认态恰恰是关的)。
*/
@Test
fun `both the off and the on state admit the system's ceiling`() {
val off = keepAliveSummary(enabled = false, notificationsAllowed = true, permissionJustDenied = false)
val on = keepAliveSummary(enabled = true, notificationsAllowed = true, permissionJustDenied = false)
listOf("关" to off, "开" to on).forEach { (state, text) ->
assertTrue("「$state」那一档必须写明是 24 小时的窗口:$text", text.contains("24 小时"))
assertTrue("「$state」那一档必须写明累计上限是 6 小时:$text", text.contains("6 小时"))
}
}
/**
* 权限把功能挡住的那两档都写了「到系统设置里允许」,而设置页那个「去系统设置」按钮
* 正好只在 `!allowed && (permissionJustDenied || enabled)` 时出现——正是这两档。
* 钉住这一点是为了防止两边分叉:文案让人去 A、按钮把人送去 B,比没有按钮更糟。
*/
@Test
fun `the permission-blocked states point at the system settings`() {
val revoked = keepAliveSummary(enabled = true, notificationsAllowed = false, permissionJustDenied = false)
val denied = keepAliveSummary(enabled = false, notificationsAllowed = false, permissionJustDenied = true)
listOf("权限被撤" to revoked, "刚被拒" to denied).forEach { (state, text) ->
assertTrue("「$state」那一档得把用户指向系统设置:$text", text.contains("系统设置"))
}
}
@Test
fun `the four states are told apart`() {
val texts = listOf(