diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e775529..aaa5766 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
index 1ce2238..01508ee 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
@@ -5,8 +5,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
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.common.LoadingScreen
import top.yeij.cyrene.ui.login.LoginScreen
@@ -14,12 +20,41 @@ import top.yeij.cyrene.ui.login.LoginScreen
@Composable
fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState()
+ val prefs = LocalAppPrefs.current
+ val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
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) {
SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicy.kt b/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicy.kt
new file mode 100644
index 0000000..552cd7a
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicy.kt
@@ -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
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveService.kt b/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveService.kt
new file mode 100644
index 0000000..ca185e9
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveService.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt
index 2c71881..1569a7f 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt
@@ -79,12 +79,15 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
* @param showNarration 是否显示 `` / `` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
+ * @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
+ * `core.keepalive.shouldKeepAliveRun`)
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
+ val keepAliveEnabled: Boolean = false,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -100,6 +103,7 @@ class AppSettings(context: Context) {
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
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 }
}
+ suspend fun setKeepAliveEnabled(enabled: Boolean) {
+ store.edit { it[KEY_KEEP_ALIVE] = enabled }
+ }
+
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
@@ -130,5 +138,6 @@ class AppSettings(context: Context) {
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
+ val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
}
}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt
new file mode 100644
index 0000000..db4bbd2
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt
@@ -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 ->
+ "关:切到后台久了,系统可能冻结本进程并断开实时连接;消息不会丢(服务端已落库),回到前台会自动补上"
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt
index da968f4..b2a81e6 100644
--- a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt
@@ -123,6 +123,8 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp))
}
+ KeepAliveRow(settings = settings, prefs = prefs)
+
SectionTitle("关于")
PreferenceCard {
BasicComponent(
diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveNotificationTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveNotificationTest.kt
new file mode 100644
index 0000000..7bdd473
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveNotificationTest.kt
@@ -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)
+ }
+}
diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicyTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicyTest.kt
new file mode 100644
index 0000000..01c2da8
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicyTest.kt
@@ -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),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt
new file mode 100644
index 0000000..94d68ac
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt
@@ -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)
+ }
+}