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

+29
View File
@@ -1,6 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
「后台保持连接」用到的三个权限(设置页那个开关,默认关):
- FOREGROUND_SERVICE:起前台服务本身。
- FOREGROUND_SERVICE_DATA_SYNC:前台服务的类型权限(API 34+ 与 android:foregroundServiceType
配合)。选 dataSync 是因为这条服务保的是一条**长连接**——保持与后端的实时同步,
既不是 mediaPlayback 也不是 location,声明成别的类型才是说谎。
- POST_NOTIFICATIONS:前台服务必须有一条通知,而 minSdk 33 ⇒ 它恒是运行时权限。
用户拒绝时开关**打不开**(判据见 core.keepalive.shouldKeepAliveRun),本端不偷偷跑。
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".app.CyreneApplication" android:name=".app.CyreneApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -65,6 +79,21 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<!--
「后台保持连接」的前台服务(设置页开关控制,默认关)。
exported="false":它没有任何对外接口(onBind 返回 null),只由本进程 start/stop。
foregroundServiceType="dataSync":这条服务的职责是让进程活着,从而让那条**进程级共享**
的实时连接(AppContainer.chatSocket)不被 Android 12+ 的 cached 进程冻结掐断——
语义上就是「后台数据同步」。它不自己去建立 / 关闭任何连接,连接的主人是
SessionsViewModel(见 KeepAliveService 的类注释)。
-->
<service
android:name=".core.keepalive.KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>
@@ -5,8 +5,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.core.keepalive.KeepAliveAction
import top.yeij.cyrene.core.keepalive.KeepAliveService
import top.yeij.cyrene.core.keepalive.keepAliveAction
import top.yeij.cyrene.core.keepalive.notificationsAllowed
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.ui.chat.ChatHomeScreen import top.yeij.cyrene.ui.chat.ChatHomeScreen
import top.yeij.cyrene.ui.common.LoadingScreen import top.yeij.cyrene.ui.common.LoadingScreen
import top.yeij.cyrene.ui.login.LoginScreen import top.yeij.cyrene.ui.login.LoginScreen
@@ -14,12 +20,41 @@ import top.yeij.cyrene.ui.login.LoginScreen
@Composable @Composable
fun CyreneApp(container: AppContainer) { fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState() val state by container.authManager.state.collectAsState()
val prefs = LocalAppPrefs.current
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
container.authManager.bootstrap() container.authManager.bootstrap()
} }
/*
* 后台保持连接:**全 App 唯一一处**决定那条前台服务的起停。
*
* 这里是合适的位置,因为它是「开关(App 偏好)与登录态碰头」的最小汇合点:偏好由
* CyreneRoot 读出来经 LocalAppPrefs 下发,登录态本来就订阅在本函数里。判据本身是纯函数
* (core.keepalive.keepAliveAction),两种状态各自变化都会把这一次重跑一遍,于是
* 「启动时开关开着且已登录 ⇒ 服务在跑」「登出 ⇒ 停」「开关关掉 ⇒ 停」都不用另外写路径。
*
* 权限每次**现读**(不缓存在 remember 里):会话自举完成那一刻读到的就是当下的真实授权,
* 而授权变化只有两条路——本端刚申请完(设置页那次写入会连带把这里重跑一遍)、或用户在系统
* 设置里改过(下次启动必然重跑)。
*/
LaunchedEffect(prefs.keepAliveEnabled, state) {
when (
keepAliveAction(
enabled = prefs.keepAliveEnabled,
sessionKnown = state !is SessionState.Unknown,
loggedIn = state is SessionState.LoggedIn,
notificationsAllowed = context.notificationsAllowed(),
)
) {
KeepAliveAction.Start -> KeepAliveService.start(context)
KeepAliveAction.Stop -> KeepAliveService.stop(context)
KeepAliveAction.Leave -> Unit
}
}
when (state) { when (state) {
SessionState.Unknown -> LoadingScreen() SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container) SessionState.LoggedOut -> LoginScreen(container)
@@ -0,0 +1,82 @@
package top.yeij.cyrene.core.keepalive
/**
* 「后台保持连接」该不该跑(**纯函数**,UI 与测试共用)。
*
* 判据单独拎出来的理由与先例一致(`ui.sessions.silentRefreshStep`、`ui.chat.reconnectPull`):
* 起服务那半边要 `Context`、要系统 API,在 JVM 单测里起不来;而「什么时候该跑」只有几个布尔,
* 能穷举、能断言。糊进 UI 之后就再也说不清「登出之后到底还会不会留着一条前台服务」——
* 那恰恰是这个功能最容易被写错的一半。
*
* 三个条件缺一不可,每一个都有自己的理由:
* - [enabled]:用户的选择,**默认关**。没打开就什么都不该常驻。
* - [loggedIn]:实时连接是登录态的产物(`SessionsViewModel` 在登出时关掉那条共享连接)。
* 没有连接还留一条前台服务,就只剩「白占一条通知 + 一点电」。
* - [notificationsAllowed]:`POST_NOTIFICATIONS` 被拒时前台服务**照样能跑**(系统不再拦它),
* 但那就等于**背着用户常驻一个不可见的进程**。本功能卖的就是「我明说我在跑」,
* 偷偷跑比不跑更糟——所以权限被拒判「不该跑」,由设置页那行字如实说出来。
*
* minSdk 33 ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,不存在「低版本不用问」的那一档。
*/
internal fun shouldKeepAliveRun(
enabled: Boolean,
loggedIn: Boolean,
notificationsAllowed: Boolean,
): Boolean = enabled && loggedIn && notificationsAllowed
/** 这一刻该对那条前台服务做什么([keepAliveAction] 的取值)。 */
internal enum class KeepAliveAction {
/** 该在跑:起服务。重复起是安全的,见 [keepAliveAction]。 */
Start,
/** 不该在跑:停服务。对一条没在跑的服务喊停同样是安全的。 */
Stop,
/** 什么都不做:**此刻还不该有主张**(登录态未定,见 [keepAliveAction])。 */
Leave,
}
/**
* 这一刻该对那条前台服务做什么(**纯函数**,判据本体是 [shouldKeepAliveRun])。
*
* ## 为什么不只回一个布尔
* 只回「该不该跑」的话,调用方每次都得转成一次 `start` 或 `stop`,而**启动自举那一段**
* (`SessionState.Unknown`)两种都不该做:冷启动时进程里根本没有服务,但**热启动**(Activity
* 重建、进程还活着)时上一次那条还好端端跑着,此时若按「还没登录 ⇒ 停」收掉它,用户会看到
* 「通知消失 → 自举完成 → 通知又回来」这一闪,而中间那段(App 刚被切到后台时)进程正好没有任何
* 保护。所以那一档是 [KeepAliveAction.Leave]。
*
* ## 为什么**不**看一眼「现在跑着没有」
* 那样能省掉几回重复调用,但代价是引入一份会漂移的进程内状态:系统完全可能**在进程还活着的前提下**
* 把服务收掉而不走 `onDestroy`(比如用户在系统的「正在运行的应用」里按停),于是那份「跑着」的记忆
* 停在 true,下一轮判据就会「Leave」——**该起的时候一声不响地没起**,而这正是本功能最不能出的错。
* 重复调用则没有任何代价:`startForegroundService` 打到一条已经在跑的服务上只是刷新同一条
* (同 id、静默渠道的)通知,`stopService` 打到没在跑的服务上是空操作。
*
* @param enabled 设置页那个开关(持久化在 `AppPrefs.keepAliveEnabled`)。
* @param sessionKnown 登录态**已经定了**(`Unknown` 之外的一切)。
* @param loggedIn 现在处于登录态。
* @param notificationsAllowed `POST_NOTIFICATIONS` 已授权。
*/
internal fun keepAliveAction(
enabled: Boolean,
sessionKnown: Boolean,
loggedIn: Boolean,
notificationsAllowed: Boolean,
): KeepAliveAction = when {
// 开关关着:没有第二种解释,收掉。**不等登录态**——用户刚表达过「不要」,用不着等自举。
!enabled -> KeepAliveAction.Stop
// 登录态未定(启动自举的那一段):这时谁都说不准该不该跑,别动。
!sessionKnown -> KeepAliveAction.Leave
// 明确登出:连接都没了,留着没意义。开关本身不动(`AppSettings` 是 App 级的、不随登出清空,
// 下次登录仍按用户当初的选择来)。这里读的是**登录态**而不是「用没用上连接」——本服务不碰连接
// (见 [KeepAliveService]),它对连接的了解就只有「现在有没有一个登录着的用户」这一件事。
!loggedIn -> KeepAliveAction.Stop
// 到这儿只剩「开关开着 + 已登录」这一种情形,跑不跑由通知权限说话。
shouldKeepAliveRun(enabled, loggedIn, notificationsAllowed) -> KeepAliveAction.Start
else -> KeepAliveAction.Stop
}
@@ -0,0 +1,162 @@
package top.yeij.cyrene.core.keepalive
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.app.MainActivity
/** 通知渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做的静音设置会失效。 */
internal const val KEEP_ALIVE_CHANNEL_ID = "keep_alive"
/**
* 渠道重要级。**只许是 LOW / MIN**([NotificationManager.IMPORTANCE_LOW] = 不响铃、不震动、
* 不弹横幅):这是「保活」不是「提醒」,用户没要求被叫住。
*
* 注意重要级是**渠道创建时定死的**:应用后改不了(只有用户能改),所以这个值必须是最终值。
*/
internal const val KEEP_ALIVE_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_LOW
/** 通知 id。常驻的那一条始终用它,重复起服务只更新不叠加。 */
internal const val KEEP_ALIVE_NOTIFICATION_ID = 1
private const val LOG_TAG = "KeepAliveService"
/**
* `POST_NOTIFICATIONS` 授没授(minSdk 33 ⇒ 它恒是运行时权限)。
*
* 它既是 [shouldKeepAliveRun] 的入参,也是设置页那行字要说的事,所以两边共用这一处口径。
*/
internal fun Context.notificationsAllowed(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
/**
* 「后台保持连接」的前台服务。
*
* ## 它的职责只有一条:让进程活着
* 实时连接是**进程级共享**的那一条(`AppContainer.chatSocket`),所有权在 `SessionsViewModel`
* ——登录连、登出关。所以本服务**绝不**去 `connect()` / `close()` 它,也**不订阅任何帧**:
* 换了它的死活不等于换了连接的死活,两者各有各的主人。
*
* 它要挡的是 Android 12+ 对 cached 进程的冻结:进程一旦被冻,那条连接的读写就不再被调度,
* 久了连接就断(消息不丢,服务端会落库,回前台靠 history + `after` 补洞恢复,但那就不是
* 「保持连接」了)。前台服务把本进程从 cached 那一档里抬出来,这件事就到此为止。
*
* ## 起停只有一个主人
* [keepAliveAction] 判、`CyreneApp` 那个 `LaunchedEffect` 执行(全 App 唯一一处):
* 谁都不许自己在别处 start / stop 它,否则「登出后还留着一条服务」这类不一致没有一处说得清。
*
* ## 它被系统杀掉怎么办
* [onStartCommand] 回 `START_STICKY`:被内存压力回收后系统会重建它。用户主动关开关走的是
* [stop],那是显式停止,粘性不会把它拉回来。
*/
class KeepAliveService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ServiceCompat.startForeground(
this,
KEEP_ALIVE_NOTIFICATION_ID,
buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
return START_STICKY
}
/**
* Android 15(API 35)起,`dataSync` 型前台服务在 24 小时内累计跑满 6 小时,系统会回调这里,
* 并给几秒钟让应用自己收手——**不接管就会 ANR 崩掉整个 App**(本端 targetSdk 37,躲不掉)。
*
* 所以这里照办:`stopSelf` 收掉自己,进程回到普通后台状态。刻意**不绕**这条限制
* (绕它要么换前台服务类型、要么加心跳,都超出这个功能的边界)。代价如实说:同一台设备
* 24 小时内累计只能保活 6 小时;额度用完之后能不能再起、什么时候能再起由系统说了算,
* 本端不替它猜(这条限制的真机表现留给验收)。
*/
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf(startId)
}
/**
* 常驻通知。文案**刻意克制**:它只是为了让「我在后台跑着」这件事在系统里是可见的
* (前台服务必须有一条通知,这是本功能对用户的代价,设置页那行字把它写明了)。
*
* `setSilent` + `setOngoing` + `setOnlyAlertOnce` + 渠道 LOW:不响、不震、不亮屏、划不掉。
* `setOnlyAlertOnce` 是给**重复起服务**留的(判据不看「现在跑着没有」,见 [keepAliveAction]):
* 第二次 `startForegroundService` 打到同一条通知上时,用户那边必须**一点动静都没有**。
*/
private fun buildNotification(): Notification {
// 点一下回到 App:一条点不动的常驻通知会让人以为卡住了。
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, KEEP_ALIVE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("保持与昔涟的连接")
.setContentText("Cyrene 正在后台维持与昔涟的连接")
.setContentIntent(open)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createChannel() {
val channel = NotificationChannel(
KEEP_ALIVE_CHANNEL_ID,
"后台保持连接",
KEEP_ALIVE_CHANNEL_IMPORTANCE,
).apply {
description = "开启「后台保持连接」期间常驻的通知:让系统不要冻结 Cyrene 的进程"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
companion object {
fun start(context: Context) {
// Android 12+ 对「从后台起前台服务」是受限的,系统直接抛异常;这里吞掉它,理由有两条:
// ① 这个失败用户既看不懂也无从处置,而把异常放出去就是一次崩溃——崩溃比「这次没起成」
// 严重得多;
// ② 状态是自愈的:下一个判据触发点(开关 / 登录态变化、下次启动)会再试一次,
// 而这次没起成除了「进程暂时少一层保护」之外不留任何后遗症。
runCatching { ContextCompat.startForegroundService(context, intent(context)) }
.onFailure {
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "系统拒绝了这次前台服务启动", it)
}
}
fun stop(context: Context) {
context.stopService(intent(context))
}
private fun intent(context: Context): Intent = Intent(context, KeepAliveService::class.java)
}
}
@@ -79,12 +79,15 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白 * @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开 * @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列) * @param interruptMode 她正忙时按发送的行为(默认追加队列)
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
* `core.keepalive.shouldKeepAliveRun`)
*/ */
data class AppPrefs( data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true, val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true, val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue, val interruptMode: InterruptMode = InterruptMode.AppendQueue,
val keepAliveEnabled: Boolean = false,
) )
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */ /** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -100,6 +103,7 @@ class AppSettings(context: Context) {
showNarration = prefs[KEY_SHOW_NARRATION] ?: true, showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true, thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(), interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
) )
} }
@@ -119,6 +123,10 @@ class AppSettings(context: Context) {
store.edit { it[KEY_INTERRUPT_MODE] = mode.name } store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
} }
suspend fun setKeepAliveEnabled(enabled: Boolean) {
store.edit { it[KEY_KEEP_ALIVE] = enabled }
}
private fun String?.toThemeMode(): ThemeMode = when (this) { private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark ThemeMode.Dark.name -> ThemeMode.Dark
@@ -130,5 +138,6 @@ class AppSettings(context: Context) {
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration") val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded") val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode") val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
} }
} }
@@ -0,0 +1,125 @@
package top.yeij.cyrene.ui.settings
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.keepalive.notificationsAllowed
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 设置页的「后台保持连接」一行。
*
* ## 为什么单独一个文件
* `SettingsScreen.kt` 此刻正被另一个切片(登出条目)编辑。为了合并时不打架,这一行的实现全部
* 落在本文件里,`SettingsScreen.kt` 那边只加一行调用。
*
* ## 它只改一个偏好,**不起停服务**
* 起停只有一个主人(`CyreneApp` 里那个 `LaunchedEffect` + `core.keepalive.keepAliveAction`):
* 这里写 `AppPrefs.keepAliveEnabled`,偏好一变,那边自己判、自己动手。两处各自 start / stop
* 迟早会打架(iOS/Android 都一样:能表达同一件事的地方只留一处)。
*
* ## 权限被拒时不假装成功
* `POST_NOTIFICATIONS` 是前台服务的**可见性**来源(minSdk 33,它是运行时权限)。被拒时本端
* **不打开开关**,并把原因写在开关下面:一个「开着但其实没跑」的开关比一个关着的开关更坏。
*
* @param settings 偏好写入口。
* @param prefs 当前偏好(开关的显示态)。
*/
@Composable
internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 授权状态只在**本页内**会变:要么是我们刚发起的那次申请,要么是从系统设置回来重新进这一页。
// 刻意不做「回前台重查」:那需要额外的生命周期钩子,而这块的价值全在「用户动作的那一刻说真话」,
// 不在毫秒级同步系统设置(真被撤销时,服务的起停判据在下次启动时照样读得到)。
var allowed by remember { mutableStateOf(context.notificationsAllowed()) }
var justDenied by remember { mutableStateOf(false) }
val requestNotification = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
allowed = granted
justDenied = !granted
if (granted) scope.launch { settings.setKeepAliveEnabled(true) }
}
KeepAliveSectionTitle()
PreferenceCard {
SwitchPreference(
title = "后台保持连接",
summary = keepAliveSummary(
enabled = prefs.keepAliveEnabled,
notificationsAllowed = allowed,
permissionJustDenied = justDenied,
),
checked = prefs.keepAliveEnabled,
onCheckedChange = { wanted ->
justDenied = false
when {
!wanted -> scope.launch { settings.setKeepAliveEnabled(false) }
allowed -> scope.launch { settings.setKeepAliveEnabled(true) }
// 还没授过(或授过又被撤):按平台规范申请一次,结果由回调说话。
else -> requestNotification.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
)
}
}
/**
* 分段标题。样式与 `SettingsScreen.SectionTitle` 逐字一致——它是那边的 `private`,而本文件
* **不许**改那个文件(另一个切片正在编辑),所以只能照抄这一小段。两处将来若分叉,
* 先动的是这个注释。
*/
@Composable
private fun KeepAliveSectionTitle() {
Text(
text = "连接",
style = MiuixTheme.textStyles.subtitle,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
)
}
/**
* 开关下面那行字(**纯函数**,测试钉住)。它同时是**代价说明**与**当下真实状态**:
* 「关着会怎样」「开着要付出什么」「为什么没打开」——三件事都得说,且顺序上后者优先
* (用户刚被拒的那一次是此刻最要紧的信息)。
*
* 开的代价**必须自己说出来**(常驻通知 + 多耗电):不说的代价会变成用户某天
* 「莫名其妙多了条通知」的意外,那比不开这个功能更伤信任。
*/
internal fun keepAliveSummary(
enabled: Boolean,
notificationsAllowed: Boolean,
permissionJustDenied: Boolean,
): String = when {
permissionJustDenied ->
"系统通知权限被拒绝,没能打开——前台服务没有通知就等于瞒着你常驻,本端不做这种事。到系统设置里允许 Cyrene 发送通知后再来打开"
enabled && !notificationsAllowed ->
"通知权限现在是关的,服务起不来:到系统设置里允许 Cyrene 发送通知后才会真的生效"
enabled ->
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断"
else ->
"关:切到后台久了,系统可能冻结本进程并断开实时连接;消息不会丢(服务端已落库),回到前台会自动补上"
}
@@ -126,6 +126,8 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
KeepAliveRow(settings = settings, prefs = prefs)
SectionTitle("关于") SectionTitle("关于")
PreferenceCard { PreferenceCard {
BasicComponent( BasicComponent(
@@ -0,0 +1,32 @@
package top.yeij.cyrene.core.keepalive
import android.app.NotificationManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 常驻通知的**对外形状**(渠道 id / 重要级)。文案与图标测不了(要 Android 运行时),
* 但这两项能测,而且测它们是有意义的:
*
* - 重要级**建渠道那一刻就定死**了(应用改不动,只有用户能改)。它要是被写成 DEFAULT / HIGH,
* 这个「保活」功能就会开始响铃、震动、弹横幅——那不是保活,是骚扰,用户只能自己去关掉那条渠道。
* - 渠道 id 是用户静音设置的挂靠点:改了它等于换一条新渠道,用户之前对旧渠道的一切设置失效。
*/
class KeepAliveNotificationTest {
@Test
fun `the channel never rings or vibrates`() {
assertTrue(
"渠道重要级只许是 MIN / LOW,现在是 $KEEP_ALIVE_CHANNEL_IMPORTANCE",
KEEP_ALIVE_CHANNEL_IMPORTANCE == NotificationManager.IMPORTANCE_MIN ||
KEEP_ALIVE_CHANNEL_IMPORTANCE == NotificationManager.IMPORTANCE_LOW,
)
}
@Test
fun `the channel id is a stable literal`() {
// 恒定不变是刻意的:换 id = 换渠道 = 用户对旧渠道做的静音设置全部作废。
assertEquals("keep_alive", KEEP_ALIVE_CHANNEL_ID)
}
}
@@ -0,0 +1,93 @@
package top.yeij.cyrene.core.keepalive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.AppPrefs
/**
* 「后台保持连接」的判据(纯函数)。
*
* 钉的是四件事:
* 1. **默认关**——这个功能会常驻一条通知、多耗电,主人拍板默认关,任何人把它改成默认开都是一次
* 产品决定,不该悄悄发生;
* 2. 三个条件缺一不可(开关 / 登录态 / 通知权限),任何一条不成立都不许跑;
* 3. 几种状态各自该走哪一步(登出一定停、关开关一定停、**自举中一定不动**);
* 4. 四个入参的**全表 16 格**都与判据一致——尤其是「登录态未定 ⇒ 不动」这一条,写成穷举才排除得掉
* 「某个组合下热启动会把还跑着的服务收掉」这种走神。
*/
class KeepAlivePolicyTest {
@Test
fun `the feature is off until the user turns it on`() {
assertFalse("默认必须是关", AppPrefs().keepAliveEnabled)
}
@Test
fun `all three conditions are required`() {
assertFalse(shouldKeepAliveRun(enabled = false, loggedIn = true, notificationsAllowed = true))
assertFalse(shouldKeepAliveRun(enabled = true, loggedIn = false, notificationsAllowed = true))
assertFalse(shouldKeepAliveRun(enabled = true, loggedIn = true, notificationsAllowed = false))
assertTrue(shouldKeepAliveRun(enabled = true, loggedIn = true, notificationsAllowed = true))
}
@Test
fun `logging out stops the service even though the switch stays on`() {
// 登出只关连接,不改开关(AppSettings 是 App 级的,不随登出清空)——所以这里必须由判据
// 自己把服务收掉,不能指望开关被重置。
assertEquals(
KeepAliveAction.Stop,
keepAliveAction(enabled = true, sessionKnown = true, loggedIn = false, notificationsAllowed = true),
)
}
@Test
fun `an enabled switch with an undecided session is left alone`() {
// 热启动(Activity 重建、进程还活着)时自举会先经过 Unknown:这一刻把还跑着的服务收掉,
// 用户会看到通知消失又回来,而中间那段进程正好没人保护。
for (loggedIn in listOf(true, false)) {
for (allowed in listOf(true, false)) {
assertEquals(
"登录态未定(sessionKnown=false)时不许动手:loggedIn=$loggedIn allowed=$allowed",
KeepAliveAction.Leave,
keepAliveAction(enabled = true, sessionKnown = false, loggedIn = loggedIn, notificationsAllowed = allowed),
)
}
}
}
@Test
fun `switching off stops the service without waiting for a session`() {
assertEquals(
KeepAliveAction.Stop,
keepAliveAction(enabled = false, sessionKnown = false, loggedIn = false, notificationsAllowed = true),
)
}
@Test
fun `every combination of the four inputs agrees with the judgement`() {
val bools = listOf(true, false)
for (enabled in bools) {
for (known in bools) {
for (loggedIn in bools) {
for (allowed in bools) {
val wanted = known && shouldKeepAliveRun(enabled, loggedIn, allowed)
val expected = when {
wanted -> KeepAliveAction.Start
// 唯一「不许有主张」的一格:开关开着,但登录态还没定。
enabled && !known -> KeepAliveAction.Leave
else -> KeepAliveAction.Stop
}
assertEquals(
"enabled=$enabled known=$known loggedIn=$loggedIn allowed=$allowed",
expected,
keepAliveAction(enabled, known, loggedIn, allowed),
)
}
}
}
}
}
}
@@ -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)
}
}