feat(notify): 设置页加「免打扰」开关 —— 她主动说的话拆成两条渠道按开关发(一并重做 proactive_v2/HIGH 那份改动)

改了什么:
- 新增第二条渠道 `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 条用例)。
This commit is contained in:
AskaEth committed 2026-10-07 11:53:04 +08:00
1 parent 541a70f126
commit 0e2df1193d
7 files changed
+464 -33

No files matched your search

@@ -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,
)
},
)
/**
@@ -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)
}
@@ -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<Preferences> 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<Preferences> = context.applicationContext.appDataStore
/**
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val data: Flow<AppPrefs> = 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<AppPrefs> = 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")
}
}
@@ -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 -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目"
}
@@ -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,
)
}
}
@@ -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())
}
}
}
@@ -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),
)
}
}