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:
1 parent
d0b8c5f2f7
commit
0243fb78d4
10 files changed
+630
No files matched your search
@@ -1,6 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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
|
||||
android:name=".app.CyreneApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -65,6 +79,21 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
KeepAliveRow(settings = settings, prefs = prefs)
|
||||
|
||||
SectionTitle("关于")
|
||||
PreferenceCard {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user