From 0e2df1193d023408c4520d6c3fa8c15512abacb5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:53:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(notify):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E5=85=8D=E6=89=93=E6=89=B0=E3=80=8D=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20=E2=80=94=E2=80=94=20=E5=A5=B9=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E8=AF=B4=E7=9A=84=E8=AF=9D=E6=8B=86=E6=88=90=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E6=8C=89=E5=BC=80=E5=85=B3=E5=8F=91=EF=BC=88?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E9=87=8D=E5=81=9A=20proactive=5Fv2/HIGH=20?= =?UTF-8?q?=E9=82=A3=E4=BB=BD=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么: - 新增第二条渠道 `proactive_quiet`(`IMPORTANCE_DEFAULT`,渠道名「她主动说的话(免打扰:只在通知栏)」), 与 `proactive_v2`(`IMPORTANCE_HIGH`,弹横幅)并存;两条的定义收在一个纯函数 `proactiveChannelSpec(quiet)` 里(id / 渠道名 / 重要级 / 描述成对给全),`proactiveChannelId(quiet)` 是它的 id。 - 设置页新增「通知」段 + 一行「免打扰」(`SwitchPreference` 外包 `PreferenceCard`,照「后台保持连接」那一行; 副文案走纯函数 `proactiveQuietSummary`,写明「这不是静音」)。偏好落进既有的 `AppSettings` (键 `proactive_quiet`,**默认 `false` = 都弹**),不新造存储。 - 发通知时按开关选渠道:`postProactiveNotification(..., quiet)`,7.x 及更早的 `setPriority` 跟着一起降回 `PRIORITY_DEFAULT`;`AppContainer` 的 `post` lambda 每次**现读** `AppSettings.current`(`StateFlow` 热副本), 所以开关一改、下一条通知就走新渠道,不必重启 App。 - 重做主仓那份未提交的渠道改动:`PROACTIVE_CHANNEL_ID` 由 `proactive` 改为 `proactive_v2`、 `PROACTIVE_IMPORTANCE` 由 DEFAULT 提到 HIGH、新增 `LEGACY_PROACTIVE_CHANNEL_ID = "proactive"` 并在建渠道时 `deleteNotificationChannel` 掉它、`setPriority` 改 `PRIORITY_HIGH`,对应测试断言同步更新。 为什么: - 横幅由**渠道重要级**决定,而重要级**一旦创建只有用户改得动**(App 改不动)⇒「开关横幅」不可能靠运行时改 设置实现,只能两条渠道切着用。 - 刻意**不用** `setSilent(true)`:那是完全静音(连提示音都没),而用户要的是「只在通知栏提示,别弹横幅」。 - 惰性创建只保证**这一次要用的那一条**(不存在的渠道会让 `notify` 被系统静默丢弃,所以必须在发之前建): 一个从没收到过她消息的用户,系统设置里不会平白多出渠道;另一条不删(用户可能对它调过设置), 只删那条再也发不到的 legacy id。 - 去重、通知 id 派生、「是否该弹」的判据一个字节都没动。 验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 **123 suites / 1479 tests / 0 failures / 0 errors** (基线 121/1471,本次新增 2 个测试类 + 8 条用例)。 --- .../top/yeij/cyrene/app/AppContainer.kt | 12 +- .../core/notify/ProactiveNotification.kt | 205 +++++++++++++++--- .../top/yeij/cyrene/core/prefs/AppSettings.kt | 67 ++++++ .../yeij/cyrene/ui/settings/SettingsScreen.kt | 37 ++++ .../cyrene/core/notify/ProactiveNotifyTest.kt | 82 ++++++- .../cyrene/core/prefs/AppPrefsQuietTest.kt | 49 +++++ .../ui/settings/ProactiveQuietSummaryTest.kt | 45 ++++ 7 files changed, 464 insertions(+), 33 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/prefs/AppPrefsQuietTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/settings/ProactiveQuietSummaryTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 78d2b94..306dbf6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -72,7 +72,17 @@ class AppContainer(context: Context) { */ val proactiveNotifier: ProactiveNotifier = ProactiveNotifier( presence = appPresence, - post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) }, + post = { text, notificationId -> + // 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就 + // 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事, + // 都写在 `AppSettings.current` 的 KDoc 里。 + postProactiveNotification( + context = appContext, + text = text, + notificationId = notificationId, + quiet = appSettings.current.value.proactiveQuiet, + ) + }, ) /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt index 484a6f2..2c58a2d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt @@ -11,25 +11,70 @@ import top.yeij.cyrene.app.MainActivity import top.yeij.cyrene.core.keepalive.notificationsAllowed /** - * 「她主动说的话」那条通知的渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做过的静音 - * 设置会失效(与 `keep_alive` 同一条纪律)。 + * 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。 + * + * ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了** + * (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级 + * 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级 + * 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里 + * 多一条永远不再出现的条目)。 * * **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是 - * [PROACTIVE_IMPORTANCE](**要提醒人**)。两者混在一条渠道里,就只能在「保活开始响铃」与 - * 「她的话不响铃」之间二选一——那正是「一个渠道只能是其中一件事」的由来。 + * [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间 + * 二选一——那正是「一个渠道只能是其中一件事」的由来。 + * + * 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定, + * 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条 + * 渠道发(见 [proactiveChannelId])。 */ -internal const val PROACTIVE_CHANNEL_ID = "proactive" +internal const val PROACTIVE_CHANNEL_ID = "proactive_v2" /** - * 渠道重要级:`DEFAULT`(有提示音、不弹全屏横幅)。 + * 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE] + * (有提示音、通知栏有条目、**不弹横幅**)。 * - * 这一条**是**要来提醒人的(她主动找我说话),所以不用 LOW;也不升到 HIGH——HIGH 会在屏幕顶部 - * 弹横幅盖住用户正在做的事,而这句话并没有急到那个程度。 + * 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID]) + * ——想「只关横幅」就只有换渠道这一条路。 * - * 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改),所以这里必须是 - * 最终值。`ProactiveNotificationTest` 把这两个值钉住。 + * ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是 + * 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。 */ -internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT +internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet" + +/** + * 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是 + * DEFAULT 且改不动,留着毫无用处。 + */ +internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive" + +/** + * 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。 + * + * 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台), + * 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过 + * (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。 + * + * 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利, + * 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到 + * [QUIET_PROACTIVE_CHANNEL_ID]。 + * + * ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量 + * 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest` + * 把这两个值钉住。 + */ +internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH + +/** + * 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。 + * + * `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」 + * 那一档。 + * + * ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的 + * 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何 + * 报错,所以这条纪律只能靠常量与测试钉住。 + */ +internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT /** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */ internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟" @@ -40,6 +85,67 @@ internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟" */ internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话" +/** + * 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表 + * 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了 + * (他只能点进去、一条条试着看)。 + */ +internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)" + +/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */ +internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知" + +/** + * 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到 + * 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统 + * 设置里翻、翻完还是不确定的那一件事)。 + */ +internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION = + "她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)" + +/** + * 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。 + * + * 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 = + * 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径, + * 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。 + */ +internal data class ProactiveChannelSpec( + val id: String, + val name: String, + val importance: Int, + val description: String, +) + +/** + * 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。 + * + * @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。 + */ +internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) { + ProactiveChannelSpec( + id = QUIET_PROACTIVE_CHANNEL_ID, + name = QUIET_PROACTIVE_CHANNEL_NAME, + importance = QUIET_PROACTIVE_IMPORTANCE, + description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION, + ) +} else { + ProactiveChannelSpec( + id = PROACTIVE_CHANNEL_ID, + name = PROACTIVE_CHANNEL_NAME, + importance = PROACTIVE_IMPORTANCE, + description = PROACTIVE_CHANNEL_DESCRIPTION, + ) +} + +/** + * 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。 + * + * 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`), + * 而测试要问的也只是这一个问题:「开关两档各发去哪条」。 + */ +internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id + /** * 点通知进来时那个「切到主会话」的 Intent extra。 * @@ -52,15 +158,20 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR /** * 弹一条「她主动说的话」的通知。 * - * 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生 - * 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就 - * 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。 + * 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、 + * 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事) + * ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。 * * ## 为什么 id 是入参而不是这里恒定一个 * 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到 * 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句 * 无论到几次都落在同一条通知上,而不同的句子各占一条。 * + * ## 免打扰只换渠道,别的一个字节都不动 + * [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一 + * 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我 + * 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。 + * * ## 没授通知权限时什么都不做 * `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条 * 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有 @@ -74,11 +185,24 @@ internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PR * * @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动: * 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。 + * @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、 + * 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写 + * 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。 + * 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App, + * 下一条通知就走新渠道。 */ -internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) { +internal fun postProactiveNotification( + context: Context, + text: String, + notificationId: Int, + quiet: Boolean, +) { if (!context.notificationsAllowed()) return - ensureProactiveChannel(context) + // 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来, + // 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。 + val channel = proactiveChannelSpec(quiet) + ensureProactiveChannel(context, channel) // request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会 // 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒ @@ -91,7 +215,7 @@ internal fun postProactiveNotification(context: Context, text: String, notificat PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val notification = NotificationCompat.Builder(context, PROACTIVE_CHANNEL_ID) + val notification = NotificationCompat.Builder(context, channel.id) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentTitle(PROACTIVE_NOTIFICATION_TITLE) .setContentText(text) @@ -102,7 +226,10 @@ internal fun postProactiveNotification(context: Context, text: String, notificat // 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。 .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是 + // 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得 + // 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。 + .setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH) .build() // 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」, @@ -111,20 +238,40 @@ internal fun postProactiveNotification(context: Context, text: String, notificat } /** - * 建(或更新)渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的渠道列表里不会平白多出 - * 一条「她的话」。 + * 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的 + * 渠道列表里不会平白多出一条「她的话」。 + * + * ## 为什么只建「这一次要用的那一条」,而不是两条都建 + * 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话, + * 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话 + * (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。 + * + * ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默 + * 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠 + * [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec] + * 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不 + * 通知我了」——用户只会以为是她不说话了)。 + * + * ## 另一条渠道**不删** + * 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 / + * 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是 + * [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会 + * 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。 * * 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效** - * ——它已经在 [PROACTIVE_IMPORTANCE] 上定死。 + * ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍 + * 可以改,重要级改不了)。 + * + * @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。 */ -private fun ensureProactiveChannel(context: Context) { - val channel = NotificationChannel( - PROACTIVE_CHANNEL_ID, - PROACTIVE_CHANNEL_NAME, - PROACTIVE_IMPORTANCE, - ).apply { - description = "她主动找你说话时的通知" +private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) { + val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply { + description = spec.description setShowBadge(true) } - context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + // 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目 + // (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。 + manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID) } 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 1569a7f..880c629 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 @@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn private val Context.appDataStore: DataStore by preferencesDataStore("cyrene_app") @@ -72,6 +78,23 @@ fun String?.toInterruptMode(): InterruptMode = when (this) { else -> InterruptMode.AppendQueue } +/** + * 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音 + * + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。 + * + * 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用), + * 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」 + * ——一个默认就安静的 App 会让人以为通知坏了。 + */ +internal const val PROACTIVE_QUIET_DEFAULT = false + +/** + * 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测 + * ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键 + * ⇒ [PROACTIVE_QUIET_DEFAULT]。 + */ +internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT + /** * App 级偏好(非机密)。 * @@ -81,6 +104,10 @@ fun String?.toInterruptMode(): InterruptMode = when (this) { * @param interruptMode 她正忙时按发送的行为(默认追加队列) * @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见 * `core.keepalive.shouldKeepAliveRun`) + * @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认 + * `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的 + * 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定, + * 而重要级建完就改不了,所以只能两条渠道切着用 */ data class AppPrefs( val themeMode: ThemeMode = ThemeMode.System, @@ -88,6 +115,7 @@ data class AppPrefs( val thinkingExpanded: Boolean = true, val interruptMode: InterruptMode = InterruptMode.AppendQueue, val keepAliveEnabled: Boolean = false, + val proactiveQuiet: Boolean = false, ) /** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */ @@ -97,6 +125,13 @@ class AppSettings(context: Context) { private val store: DataStore = context.applicationContext.appDataStore + /** + * 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在 + * `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的 + * 任何一次通知落地,没有比进程结束更晚的时刻需要它。 + */ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val data: Flow = store.data.map { prefs -> AppPrefs( themeMode = prefs[KEY_THEME_MODE].toThemeMode(), @@ -104,9 +139,31 @@ class AppSettings(context: Context) { thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true, interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(), keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false, + proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(), ) } + /** + * [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。 + * + * ## 为什么需要它 + * 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是 + * `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起 + * 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。 + * + * ## 为什么是 `Eagerly` + * 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是 + * 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` / + * `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。 + * + * ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是 + * [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore + * 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)—— + * 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**, + * 而不是丢通知。 + */ + val current: StateFlow = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs()) + suspend fun setThemeMode(mode: ThemeMode) { store.edit { it[KEY_THEME_MODE] = mode.name } } @@ -127,6 +184,15 @@ class AppSettings(context: Context) { store.edit { it[KEY_KEEP_ALIVE] = enabled } } + /** + * 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里 + * 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去 + * 通知谁。 + */ + suspend fun setProactiveQuiet(enabled: Boolean) { + store.edit { it[KEY_PROACTIVE_QUIET] = enabled } + } + private fun String?.toThemeMode(): ThemeMode = when (this) { ThemeMode.Light.name -> ThemeMode.Light ThemeMode.Dark.name -> ThemeMode.Dark @@ -139,5 +205,6 @@ class AppSettings(context: Context) { val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded") val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode") val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled") + val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet") } } 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 22bb87b..a8e9a25 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 @@ -114,6 +114,27 @@ fun SettingsScreen( ) } + /* + * 「免打扰」单独占一段,不塞进上面那个「聊天」里:它管的是**通知**(屏幕上会不会被 + * 盖住),与「聊天页怎么渲染」是两件事。下一段「连接」也是同一个道理(那边问的是连接 + * 会不会断)。 + * + * 写法与「后台保持连接」那一行逐项对齐(`KeepAliveRow.kt`):`SwitchPreference` 外面 + * 必须包一层 `PreferenceCard`(Miuix 的 Preference 系列自己没有背景与圆角),副文案 + * 走一个纯函数(好单测,见 `proactiveQuietSummary`),改开关只写偏好、**不在这里做 + * 任何通知相关的事**——发通知时自己会现读(见 `AppSettings.current`)。 + */ + SectionTitle("通知") + PreferenceCard { + SwitchPreference( + title = "免打扰", + summary = proactiveQuietSummary(prefs.proactiveQuiet), + checked = prefs.proactiveQuiet, + onCheckedChange = { scope.launch { settings.setProactiveQuiet(it) } }, + ) + } + Spacer(Modifier.height(8.dp)) + SectionTitle("她忙时发消息") InterruptMode.entries.forEach { mode -> PreferenceCard { @@ -261,3 +282,19 @@ private fun InterruptMode.summary(): String = when (this) { InterruptMode.Parallel -> "她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token" } + +/** + * 「免打扰」开关下面那行字(**纯函数**,测试钉住)。它要说清的只有一件事: + * **这不是静音** —— 关掉的是横幅,不是消息、也不是提示音。 + * + * 为什么非说不可:「免打扰」四个字在系统里到处都是,用户对它的第一印象常是「什么都不响」。而这个 + * 开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住我正在做的事」——两句话指向的期待 + * 差得很远,一句不说就等于让用户自己猜(猜错的代价是他以为通知坏了,或以为自己买到了完全静音)。 + * + * 两档都说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,而只写「开着 = 安静」 + * 等于让他自己去比较两种安静。 + */ +internal fun proactiveQuietSummary(enabled: Boolean): String = when { + enabled -> "已开:她主动找你说话只在通知栏提示(有提示音),不在屏幕顶部弹横幅" + else -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目" +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt index 20f2f3b..2ad3050 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt @@ -136,8 +136,78 @@ class ProactiveNotificationShapeTest { "两条渠道各自表达一件事,混在一条里就只能二选一", PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID, ) - // 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。 - assertEquals("proactive", PROACTIVE_CHANNEL_ID) + // 🔴 这里是 `proactive_v2` 而**不是** `proactive`:渠道重要级一旦创建就改不了(只有用户能改), + // 2026-10-07 把它从 DEFAULT 提到 HIGH 对**已建过旧渠道**的设备不生效 ⇒ 只能换一个新 id + // 让系统按新重要级重新建。这条断言就是「以后谁要改重要级,必须同时换 id」的机器可读版本。 + assertEquals("proactive_v2", PROACTIVE_CHANNEL_ID) + assertEquals("proactive", LEGACY_PROACTIVE_CHANNEL_ID) + assertTrue( + "新 id 必须不同于旧 id —— 相同就说明有人改了重要级却没换 id(对老设备无效)", + PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID, + ) + } + + /** + * 「免打扰」那条渠道:它必须是**另一条**,而且是**安静的那一条**。 + * + * 这几件事各有各的坏法,且都很难在真机上发现: + * - 与 `PROACTIVE_CHANNEL_ID` 同 id ⇒ 开关两档发到同一条渠道,「开着免打扰照样弹横幅」; + * - 重要级不低于 HIGH ⇒ 它照样弹横幅,用户按了开关却什么都没变(而**不会有任何报错**); + * - 撞上旧 id ⇒ 建新渠道时会被顺手删掉(那条通知随后就被系统静默丢弃)。 + */ + @Test + fun `the do-not-disturb channel is a second, quieter channel`() { + assertEquals("proactive_quiet", QUIET_PROACTIVE_CHANNEL_ID) + assertEquals( + "免打扰那一档只许是 DEFAULT(有提示音、不弹横幅):HIGH 会照样弹横幅", + android.app.NotificationManager.IMPORTANCE_DEFAULT, + QUIET_PROACTIVE_IMPORTANCE, + ) + assertTrue( + "两条渠道必须真的不同 —— 同 id 就等于「开关两档没有区别」", + QUIET_PROACTIVE_CHANNEL_ID != PROACTIVE_CHANNEL_ID, + ) + assertTrue( + "它也不许撞上旧 id(否则建新渠道那一步会把自己删掉)", + QUIET_PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID, + ) + assertTrue( + "更不许撞上保活那条(否则用户对保活的静音设置会被她的话一起改掉)", + QUIET_PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID, + ) + } + + /** + * 开关 → 渠道 id 的**纯函数**(两档)。这是「免打扰」唯一的落地形状:横幅由渠道重要级决定, + * 而重要级建完就改不了 ⇒ 只能按开关选一条渠道发。 + * + * 同时钉住 `ProactiveChannelSpec` 的**成对性**:id / 渠道名 / 重要级必须一起跟着开关走。四处 + * 各写一份 `if (quiet)` 时的坏法是「换了 id 却没换重要级」,症状是「按了开关,什么都没变」。 + */ + @Test + fun `the switch picks the channel and the whole spec moves with it`() { + assertEquals(PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = false)) + assertEquals(QUIET_PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = true)) + + val loud = proactiveChannelSpec(quiet = false) + val quiet = proactiveChannelSpec(quiet = true) + assertEquals(PROACTIVE_CHANNEL_ID, loud.id) + assertEquals(PROACTIVE_CHANNEL_NAME, loud.name) + assertEquals(PROACTIVE_IMPORTANCE, loud.importance) + assertEquals(QUIET_PROACTIVE_CHANNEL_ID, quiet.id) + assertEquals(QUIET_PROACTIVE_CHANNEL_NAME, quiet.name) + assertEquals(QUIET_PROACTIVE_IMPORTANCE, quiet.importance) + + // 系统设置的渠道列表里这两行会并排出现,用户就是靠它判断「哪条会弹横幅」——同名等于白分两条。 + assertNotEquals( + "两条渠道名必须一眼看得出区别", + PROACTIVE_CHANNEL_NAME, + QUIET_PROACTIVE_CHANNEL_NAME, + ) + assertTrue( + "免打扰那条的渠道名要点明它是什么:$QUIET_PROACTIVE_CHANNEL_NAME", + QUIET_PROACTIVE_CHANNEL_NAME.contains("免打扰"), + ) } @Test @@ -147,7 +217,13 @@ class ProactiveNotificationShapeTest { "她要提醒人,重要级必须高于保活那条", PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE, ) - assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE) + // `HIGH`(4) 是**屏幕顶部弹横幅**的门槛:DEFAULT 只会响一声躺在通知栏里。 + // 主人 2026-10-07 的原话「我在其他 APP 时没看到横幅」就是 DEFAULT 这一档造成的。 + assertEquals(android.app.NotificationManager.IMPORTANCE_HIGH, PROACTIVE_IMPORTANCE) + assertTrue( + "免打扰那一档必须比它低 —— 否则「只关横幅」这件事就没有档位可用了", + QUIET_PROACTIVE_IMPORTANCE < PROACTIVE_IMPORTANCE, + ) } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/prefs/AppPrefsQuietTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/AppPrefsQuietTest.kt new file mode 100644 index 0000000..ba37c6a --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/AppPrefsQuietTest.kt @@ -0,0 +1,49 @@ +package top.yeij.cyrene.core.prefs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * 「免打扰」这个设置项的形状(默认值 + 读写往返)。 + * + * `AppSettings` 本体要 Android + DataStore、单测里起不来(与 `InterruptModeTest` 同一条理由), + * 所以钉的是它两侧的**纯**部分。三件事各自对应一种真会发生的坏法: + * + * 1. **默认必须是 `false`(都弹)**:主人 2026-10-07 的话是「关闭时两者都弹,**默认都弹**」—— + * 一个默认就安静的 App 会让人以为通知坏了(而这一条恰恰没有任何报错); + * 2. **数据类的默认参数与落盘读取口的缺省值必须是同一个值**:它们表达的是同一个「缺省」, + * 分叉的症状是「开关显示关着、行为却安静」(或反过来),同样没有任何报错; + * 3. **读写往返**:写进去什么就读出什么,两个布尔都走一遍。这一档没有 `Enum` 那种映射层, + * 唯一会坏的方式就是某个分支被写反,而写反的后果正好是「开关与行为相反」。 + */ +class AppPrefsQuietTest { + + @Test + fun `do not disturb is off by default so both keep alerting`() { + assertFalse( + "默认必须是「都弹」:默认就安静只会让人以为通知坏了", + AppPrefs().proactiveQuiet, + ) + assertFalse("缺省常量本身就是默认值,它不能是 true", PROACTIVE_QUIET_DEFAULT) + } + + @Test + fun `the data-class default and the read seam agree on one default`() { + // 两条路读的是同一个「缺省」:进程刚起来(数据类默认参数)与盘上没有这个键(读取口)。 + assertEquals( + "数据类的默认参数与落盘读取口的缺省值必须同源", + AppPrefs().proactiveQuiet, + (null as Boolean?).toProactiveQuiet(), + ) + } + + @Test + fun `the switch round-trips from the data class through the read seam`() { + listOf(false, true).forEach { wanted -> + val written = AppPrefs(proactiveQuiet = wanted).proactiveQuiet + + assertEquals("写进去什么就得读出什么", wanted, (written as Boolean?).toProactiveQuiet()) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/settings/ProactiveQuietSummaryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/ProactiveQuietSummaryTest.kt new file mode 100644 index 0000000..645e8b5 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/ProactiveQuietSummaryTest.kt @@ -0,0 +1,45 @@ +package top.yeij.cyrene.ui.settings + +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「免打扰」开关下面那行字(纯函数)。 + * + * 这一行的全部价值在一件事上:**说清它不是静音**。「免打扰」四个字在系统里到处都是,用户对它的 + * 第一印象常是「什么都不响」,而这个开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住 + * 我正在做的事」——两句话指向的期待差得很远,一句不说就是让用户自己猜(猜错的代价是他以为通知 + * 坏了,或者以为自己买到了完全静音)。 + * + * 两档都必须说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,只写「开着 = 安静」 + * 等于让他自己去比较两种安静。 + */ +class ProactiveQuietSummaryTest { + + @Test + fun `the on state says it is not silence`() { + val text = proactiveQuietSummary(enabled = true) + + assertTrue("要说清它还在通知栏里:$text", text.contains("通知栏")) + assertTrue("要说清关掉的只是横幅:$text", text.contains("横幅")) + assertTrue("必须明说提示音还在(否则等于说自己在静音):$text", text.contains("提示音")) + } + + @Test + fun `the off state names the banner as what gets silenced`() { + val text = proactiveQuietSummary(enabled = false) + + assertTrue("要说清默认这一档是弹横幅的:$text", text.contains("默认")) + assertTrue("要说清默认会弹横幅:$text", text.contains("横幅")) + } + + @Test + fun `the two states are two different sentences`() { + assertNotEquals( + "两档必须各说各的,任何一次改文案都不该把某一档悄悄吞掉", + proactiveQuietSummary(enabled = false), + proactiveQuietSummary(enabled = true), + ) + } +}