From 0243fb78d46716fdd0b01251605b9f86f2ce295e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:59:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=BE=E7=BD=AE):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E5=90=8E=E5=8F=B0=E4=BF=9D=E6=8C=81=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E3=80=8D=E5=BC=80=E5=85=B3=E4=B8=8E=E5=89=8D=E5=8F=B0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=EF=BC=88=E9=BB=98=E8=AE=A4=E5=85=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现状是一件保活都没做: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 失败。 --- app/src/main/AndroidManifest.xml | 29 ++++ .../kotlin/top/yeij/cyrene/app/CyreneApp.kt | 35 ++++ .../cyrene/core/keepalive/KeepAlivePolicy.kt | 82 +++++++++ .../cyrene/core/keepalive/KeepAliveService.kt | 162 ++++++++++++++++++ .../top/yeij/cyrene/core/prefs/AppSettings.kt | 9 + .../yeij/cyrene/ui/settings/KeepAliveRow.kt | 125 ++++++++++++++ .../yeij/cyrene/ui/settings/SettingsScreen.kt | 2 + .../keepalive/KeepAliveNotificationTest.kt | 32 ++++ .../core/keepalive/KeepAlivePolicyTest.kt | 93 ++++++++++ .../ui/settings/KeepAliveSummaryTest.kt | 61 +++++++ 10 files changed, 630 insertions(+) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicy.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveService.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAliveNotificationTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/keepalive/KeepAlivePolicyTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt 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 39186fa..ef89277 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 @@ -126,6 +126,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) + } +}