feat(设置): 新增「后台保持连接」开关与前台服务(默认关)

现状是一件保活都没做:Android 12+ 会把 cached 进程冻结,进程级共享的那条 WS 连接
(AppContainer.chatSocket)久了就断。这一批只解决「进程别被冻」这一件事,连接本身的生死
仍然归 SessionsViewModel(登录连、登出关),本服务不碰它、也不订阅任何帧。

- core/keepalive/KeepAliveService:dataSync 型前台服务,职责只有让进程活着。常驻一条 LOW
  级静默通知(不响不震、setOnlyAlertOnce、点一下回 App),渠道只在服务真的起来时才建。
- core/keepalive/KeepAlivePolicy:起停判据抽成纯函数。shouldKeepAliveRun 三个条件缺一不可
  (开关开 / 已登录 / 通知权限在);keepAliveAction 再补上「开关开着但登录态未定(自举中)
  一律不动」——不补的话热启动会看到通知闪一下,而中间那段进程正好没人保护。判据刻意**不**查
  「现在跑着没有」:那份进程内记忆会被「系统收掉服务却不走 onDestroy」写坏,于是该起的时候
  一声不响地没起(保活最不能出的错);重复 start/stop 本身是无害的。
- CyreneApp:全 App 唯一一处按判据起停,开关或登录态一变就重判一次 ⇒「启动时开着且已登录就
  在跑」「登出即停」「关开关即停」三件事不需要各自一条路径。
- 设置页新增一行(默认关),文案如实写清代价(常驻一条通知 + 多耗一点电)与当下真实状态;
  Android 13+ 申请 POST_NOTIFICATIONS,被拒则开关弹回关闭并说明原因,绝不假装开着。
  实现落在新文件 KeepAliveRow.kt:SettingsScreen.kt 只加一行调用(放在「关于」之前,不碰
  最底下的登出块),避开 W52C 正在编辑那一段。
- KeepAliveService.onTimeout:targetSdk 37 下 dataSync 前台服务 24 小时内累计跑满 6 小时会被
  系统回调,不自己停就 ANR 崩掉整个 App —— 照办 stopSelf,不绕(绕它要么换类型要么加心跳,
  都超出本功能边界)。
- Manifest:FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC / POST_NOTIFICATIONS + service
  (exported=false、foregroundServiceType=dataSync)。

测试:新增 3 个文件 13 条 —— 默认关、三条件缺一不可、四入参全表一致、文案四档各不
相同且「跑不了时一个字都不提已开」、渠道重要级只许 LOW/MIN。基线 93 suite/1205 tests 全部保持
通过,本树 96 suite/1218 tests/0 失败。
This commit is contained in:
AskaEth committed 2026-10-06 15:59:28 +08:00
1 parent d0b8c5f2f7
commit 0243fb78d4
10 files changed
+630

No files matched your search

@@ -0,0 +1,61 @@
package top.yeij.cyrene.ui.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 开关下面那行字(纯函数)。
*
* 这一行的价值全在「说真话」,所以钉的也是真话:
* 1. **开的代价自己说出来**(常驻通知 + 多耗电)——不说的代价会变成用户某天「莫名多了条通知」的意外;
* 2. **跑不了的时候不许说在跑**(通知权限被拒 / 被撤时,一个字都不提「已开」);
* 3. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
*/
class KeepAliveSummaryTest {
@Test
fun `the off state says what it costs to stay off`() {
val text = keepAliveSummary(enabled = false, notificationsAllowed = true, permissionJustDenied = false)
assertTrue("关着要说清后果(消息不丢但连接会断):$text", text.contains("消息不会丢"))
assertTrue(text.contains("断开"))
}
@Test
fun `the on state spells out the price`() {
val text = keepAliveSummary(enabled = true, notificationsAllowed = true, permissionJustDenied = false)
assertTrue("必须明说会常驻一条通知:$text", text.contains("通知"))
assertTrue("必须明说会多耗电:$text", text.contains("电"))
}
@Test
fun `an enabled switch without the notification permission never claims to be working`() {
val text = keepAliveSummary(enabled = true, notificationsAllowed = false, permissionJustDenied = false)
assertFalse("权限不在,绝不能写成「已开」:$text", text.contains("已开"))
assertTrue("要说清为什么不起作用:$text", text.contains("起不来"))
}
@Test
fun `a just denied request reports the denial instead of pretending success`() {
val text = keepAliveSummary(enabled = false, notificationsAllowed = false, permissionJustDenied = true)
assertTrue("要说清被拒了:$text", text.contains("拒绝"))
assertFalse("被拒时不许写成「已开」:$text", text.contains("已开"))
}
@Test
fun `the four states are told apart`() {
val texts = listOf(
keepAliveSummary(enabled = false, notificationsAllowed = true, permissionJustDenied = false),
keepAliveSummary(enabled = true, notificationsAllowed = true, permissionJustDenied = false),
keepAliveSummary(enabled = true, notificationsAllowed = false, permissionJustDenied = false),
keepAliveSummary(enabled = false, notificationsAllowed = false, permissionJustDenied = true),
)
assertEquals("四个分支必须四句不同的话", 4, texts.toSet().size)
}
}