From 611bc5f58aa8cdf27913dee8b9833caab91ea14f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 18:51:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(W53N):=20=E5=A5=B9=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E8=AF=B4=E7=9A=84=E8=AF=9D=E5=9C=A8=E5=90=8E=E5=8F=B0/?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E8=81=8A=E5=A4=A9=E9=A1=B5=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5=EF=BC=88?= =?UTF-8?q?=E5=8F=AA=E8=BF=99=E4=B8=80=E6=A1=A3=EF=BC=8C=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=8D=E5=BC=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么: 1. WsEvent:把落库帧上被丢掉的 `proactive` 判据**读回来**(新增 MessagePersisted.proactive)。 此前 PersistedData 只解 message,而生产 Json 是 ignoreUnknownKeys=true ⇒ 这个键既不解也不报错, 「她主动说的话走了 IM 桥那条落库路」在客户端完全不可见。两层落点都认(data.proactive 与帧壳 顶层 proactive 取或,契约只说了「帧顶」);值用**宽容读法**(只认字面 true),形状不认识时按 「不是 proactive」——判错成真会给普通消息弹通知,判错成假只是漏一次提醒,方向必须偏不打扰。 2. core/notify(新):判据 + 去重 + 落地 - shouldNotifyProactive(appInForeground, viewingTargetSession):语义只有一条——**这条消息此刻 会不会正好被用户看到**。四档组合都写进 KDoc(含「不在前台、聊天页却还挂着」那一档)。 - RecentProactiveNotices:进程级、容量 64、按插入序淘汰。记的是「**到达过**」而不是「弹过」, 因为「用户正看着」那一次也算这条已经到达(他看过了,另一条路再补一帧时不该为一句看过的话弹)。 - ProactiveNotifier:两条路共用一处;顺序是 归属 → 有没有字 → 记账/查重 → 判时机。 - proactiveNotifyKey:落库行 id > IM 消息号 > 时间字面量 > 正文(服务端一个键都不给时的兜底)。 实时帧的 data.id 与落库帧的 message.id 是**同一个值**,去重就靠它。 - proactiveNotificationText:复用权威解析器 domain.parse 剥标记(不再写第二份剥标签正则)、 折成一行、超 120 字截断;截断不劈开代理对(emoji)。 3. 前台判定不引新依赖:MainActivity 的 onStart/onStop 按**实例**登记到一个进程级集合 (配置变更时新实例的 onStart 先于旧实例的 onStop,布尔量在那里必然误判成「后台」); 聊天页那一半由 ChatScreen 的组合报告(DisposableEffect)。两者在 AppContainer 那层汇合。 4. 通知本体:自建渠道 `proactive`(DEFAULT,**要提醒人**,与 keep_alive 的 LOW 分开)、通知 id=2 (与保活那条 id=1 分开,同 id 是替换会把常驻通知顶掉)、标题「昔涟」+ 正文是她的原话、 setAutoCancel、PendingIntent.FLAG_IMMUTABLE(且 request code 与保活那条分开:两者的 Intent filterEquals 相等而 PendingIntent 判据不含 extras,同 code 会复用先建的那个、丢掉这个 extra)。 通知权限现读(复用 core.keepalive.notificationsAllowed),没授权就不发。 5. 点通知 ⇒ 打开 App 并切回主会话:Intent extra → 进程级待落地意图(ProactiveTapIntake,通知可能 在 App 没起来 / 没登录时被点)→ ChatHomeScreen 落地成「关掉设置/管理/关于/抽屉 + select(web)」, 走的是用户点抽屉里那一行的同一条路,不引导航库。 为什么这样切:通知逻辑**绝不碰连接**(那条共享连接的所有权仍只在登录态那一处),只在帧**已经到达 之后**做判断;判断与去重全是纯函数/纯类,可单测;Android 落地只有一层薄薄的通知构造。 验证:W53N worktree(基线 f8ac95c)`assembleDebug testDebugUnitTest` 全绿 —— 109 suite / 1320 tests / 0 失败(基线 101 / 1270 / 0,新增 8 suite / 50 tests)。真机弹通知、点通知的落地未验(留给主人)。 --- .../top/yeij/cyrene/app/AppContainer.kt | 24 ++ .../top/yeij/cyrene/app/MainActivity.kt | 34 +- .../top/yeij/cyrene/app/ProactiveTapIntake.kt | 56 ++++ .../yeij/cyrene/core/notify/AppPresence.kt | 73 +++++ .../core/notify/ProactiveNotification.kt | 134 ++++++++ .../cyrene/core/notify/ProactiveNotifier.kt | 101 ++++++ .../cyrene/core/notify/ProactiveNotify.kt | 143 +++++++++ .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 70 ++++- .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 16 + .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 12 + .../cyrene/ui/sessions/SessionsViewModel.kt | 35 ++- .../yeij/cyrene/app/ProactiveTapIntakeTest.kt | 56 ++++ .../cyrene/core/notify/AppPresenceTest.kt | 98 ++++++ .../core/notify/ProactiveNotifierTest.kt | 297 ++++++++++++++++++ .../cyrene/core/notify/ProactiveNotifyTest.kt | 134 ++++++++ .../data/ws/WsEventPersistedProactiveTest.kt | 114 +++++++ 16 files changed, 1387 insertions(+), 10 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/ProactiveTapIntake.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/notify/AppPresence.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/app/ProactiveTapIntakeTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/notify/AppPresenceTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedProactiveTest.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 cba6061..752fb0e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -3,6 +3,9 @@ package top.yeij.cyrene.app import android.content.Context import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.SessionStore +import top.yeij.cyrene.core.notify.AppPresence +import top.yeij.cyrene.core.notify.ProactiveNotifier +import top.yeij.cyrene.core.notify.postProactiveNotification import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.EnumPreferences import top.yeij.cyrene.core.prefs.SessionPreferences @@ -49,6 +52,27 @@ class AppContainer(context: Context) { */ val goneSessions: GoneSessions = GoneSessions() + /** + * 「App 在不在前台」「聊天页显示着哪个会话」(**进程级**,与 [chatSocket] / [goneSessions] + * 同一层):写它的是 `MainActivity`(`onStart` / `onStop`)与 `ChatScreen`(组合进出), + * 读它的是 [proactiveNotifier]。为什么不引 `ProcessLifecycleOwner` 见 [AppPresence] 的类注释。 + */ + val appPresence: AppPresence = AppPresence() + + /** 「点通知进来要看主会话」那笔待落地的意图(写:`MainActivity`,消费:`ChatHomeScreen`)。 */ + val proactiveTapIntake: ProactiveTapIntake = ProactiveTapIntake() + + /** + * 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。 + * + * 它**不订阅任何东西**(帧由 `SessionsViewModel` 那条既有订阅喂进来),因此与连接的所有权 + * 完全无关——本端那条共享连接的生死仍然只在登录态那一处表达。 + */ + val proactiveNotifier: ProactiveNotifier = ProactiveNotifier( + presence = appPresence, + post = { text -> postProactiveNotification(appContext, text) }, + ) + /** * 会话槽位账本(**进程级**):本端为一个账号留着的聊天状态,一格一份 `ViewModelStore`。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt index 732def6..fbc0e82 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt @@ -7,6 +7,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge class MainActivity : ComponentActivity() { + + private val container: AppContainer get() = (application as CyreneApplication).container + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -17,22 +20,47 @@ class MainActivity : ComponentActivity() { // intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState, // 冷启动不带——这就是判据。 if (savedInstanceState == null) { - intent?.let { ShareIntake.accept(this, it) } + intent?.let { + ShareIntake.accept(this, it) + // 点她那条通知冷启动进来:记一笔「要看主会话」(见 ProactiveTapIntake)。 + // 与分享同一个判据:重建时系统会把同一个 intent 再送一遍,那时不能重复记账 + // (重复没有害处,但「冷启动才接」这条纪律只有一处,别在两处各写一半)。 + container.proactiveTapIntake.accept(it) + } } - val container = (application as CyreneApplication).container setContent { CyreneRoot(container) } } + /** + * 本 App 只有**一个** Activity(`singleTop`),所以它的 start / stop 就是「App 有没有在被看」 + * ——这是「她主动说的话该不该弹通知」的两半之一(见 `core.notify.shouldNotifyProactive`)。 + * + * 报的是**实例**而不是一个布尔量:旋转 / 系统深浅色切换会让新实例的 `onStart` 先于旧实例的 + * `onStop`,布尔量在那一刻必然被记成「后台」,于是用户正看着聊天页她却弹一条通知出来。 + * 集合由 [AppPresence] 自己持有,这里只报自己。 + */ + override fun onStart() { + super.onStart() + container.appPresence.onActivityStarted(this) + } + + override fun onStop() { + container.appPresence.onActivityStopped(this) + super.onStop() + } + /** * 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。 - * 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。 + * 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有,而 + * 「点她的通知 → 回到已经开着的 Cyrene(可能停在设置页)」同理。 */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。 setIntent(intent) ShareIntake.accept(this, intent) + container.proactiveTapIntake.accept(intent) } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ProactiveTapIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ProactiveTapIntake.kt new file mode 100644 index 0000000..a94e0ee --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ProactiveTapIntake.kt @@ -0,0 +1,56 @@ +package top.yeij.cyrene.app + +import android.content.Intent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import top.yeij.cyrene.core.notify.EXTRA_PROACTIVE_OPEN_MAIN_SESSION + +/** + * 「点她那条通知进来 ⇒ 要看主会话」这条**待落地的意图**(W53N)。 + * + * ## 为什么它必须活在进程里,而不是直接在那次 Intent 里做完 + * 通知可能在**任何**时刻被点:App 还没起来(冷启动)、已经开着但停在设置页、甚至还没登录。 + * 而真正能「切到主会话」的只有一处——`ChatHomeScreen` 里那个 `SessionsViewModel`(`select()`), + * 它要被组合出来才存在,登录态没定时**根本没有**。所以这里只记一笔「用户要求看主会话」, + * 由 `ChatHomeScreen` 在能落地的那一刻取走(与 `ShareIntake` 同一种做法:待落地的意图活在 + * Compose 与 ViewModel 之外,消费方自己挑时机认领)。 + * + * ## 为什么是 StateFlow 而不是一个布尔 + * 消费方(`ChatHomeScreen`)在 App 已经开着的时候**已经在组合里**:一个普通布尔量被改成 true + * 不会叫醒它,那一次点击就会静默丢掉(用户点了通知,进来还停在设置页)。`StateFlow` 一改值就能 + * 让订着它的那段组合重跑判据。 + * + * ## 不清理会怎样 + * 没人消费时它一直是 true(例如点了通知但登录失败)——代价是「重登之后落在主会话」。 + * 主会话本来就是默认落点(`resolveActiveSession` 在会话没了时也收敛到它),所以这里**刻意不** + * 在登出时清掉:留一个不会做错事的意图,好过为了干净而多一条「谁负责清它」的路径。 + */ +class ProactiveTapIntake { + + private val _openMainSession = MutableStateFlow(false) + + /** 是否有一笔待落地的「切到主会话」。消费方拿到 true 后必须调 [consume]。 */ + val openMainSession: StateFlow = _openMainSession.asStateFlow() + + /** + * 从一次启动 / 热启动的 Intent 里认领这条意图(`MainActivity` 的两条入口都走这里)。 + * + * 判据是这个 extra 的**字面量 true**:别的 Intent(分享、桌面图标)里它缺席,什么都不做。 + */ + fun accept(intent: Intent?) { + if (intent?.getBooleanExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, false) == true) { + requestMainSession() + } + } + + /** 记下「用户要求看主会话」(与 `accept` 分开,是为了这一半能脱离 Android 单测)。 */ + fun requestMainSession() { + _openMainSession.value = true + } + + /** 取走这笔意图(只有真正落地了「切到主会话 + 回到聊天页」的那一次才调)。 */ + fun consume() { + _openMainSession.value = false + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/notify/AppPresence.kt b/app/src/main/kotlin/top/yeij/cyrene/core/notify/AppPresence.kt new file mode 100644 index 0000000..6e18050 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/AppPresence.kt @@ -0,0 +1,73 @@ +package top.yeij.cyrene.core.notify + +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * 进程级的两个「我此刻在哪儿」的事实(W53N):**App 在不在前台**、**聊天页正显示哪个会话**。 + * + * ## 为什么是这一层,而不是 ProcessLifecycleOwner + * 本仓没有 `lifecycle-process` 依赖,而为一个「要不要弹通知」的判断新增依赖不值当(主人明令不加)。 + * 既有的 `MainActivity` 生命周期已经足够说清第一件事——本应用只有**一个** Activity + * (`singleTop`),它的 `onStart` / `onStop` 就是「App 有没有在被看」。第二件事由 `ChatScreen` + * 的组合报告(组合在 = 那一页在屏幕上,见 [onChatSurface])。两条都在手写 DI 容器 + * (`app.AppContainer`)那一层汇合——与那条共享连接、`goneSessions` 那几件进程级事实同一层。 + * + * ## 为什么前台用「活着的 Activity 集合」而不是一个布尔 + * 配置变更(旋转 / 系统深浅色切换)时,**新实例的 `onStart` 会先于旧实例的 `onStop`**: + * 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出 + * 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。 + * 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**), + * 且读写可能不在同一线程上(帧在主线程的收集里读),所以用并发容器而不是普通 `MutableSet`。 + * + * ## 它**不是**「App 有没有活着」 + * 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要 + * 出现的那一档。两者是两件事,别把它们揉在一起。 + */ +class AppPresence { + + private val startedActivities: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) + + /** + * 聊天页此刻显示着的会话 id;`null` = 聊天页不在屏幕上(设置页 / 管理页 / 未登录 / 已销毁)。 + * + * 由 [onChatSurface] 写、通知判据读。**刻意不带「前台」的含义**:App 退到后台时这段组合在 + * Android 上并不会被销毁,这个值会原样留着——前台那一半由 [appInForeground] 单独表达, + * 两个事实的合取才是「这条消息此刻会不会正好被用户看到」(见 [shouldNotifyProactive])。 + */ + @Volatile + var chatSurfaceSessionId: String? = null + private set + + /** App 是否正在被看(有任何一个 Activity 处在 started 之后、stopped 之前)。 */ + val appInForeground: Boolean + get() = startedActivities.isNotEmpty() + + /** 一个 Activity 进入「可见」这一档(`onStart`)。 */ + fun onActivityStarted(owner: Any) { + startedActivities.add(owner) + } + + /** + * 一个 Activity 离开「可见」这一档(`onStop`)。 + * + * 只移除**报上来的那一个实例**:照抄成「一停就整集合清空」会把正在起的新实例一起算掉 + * (那条路径见类注释里的配置变更)。 + */ + fun onActivityStopped(owner: Any) { + startedActivities.remove(owner) + } + + /** 聊天页刚显示(或刚换成)[sessionId];`null` = 它离开了屏幕。 */ + fun onChatSurface(sessionId: String?) { + chatSurfaceSessionId = sessionId + } + + /** + * 聊天页此刻显示的是不是 [sessionId] 这个会话(等值比较,不是前缀:`web` ≠ `web:xxx`)。 + * `sessionId` 为空白时恒假——「没显示任何会话」不等于「显示了一个空 id 的会话」。 + */ + fun isShowingChatSurface(sessionId: String): Boolean = + sessionId.isNotBlank() && chatSurfaceSessionId == sessionId +} 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 new file mode 100644 index 0000000..6fbb848 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt @@ -0,0 +1,134 @@ +package top.yeij.cyrene.core.notify + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import top.yeij.cyrene.app.MainActivity +import top.yeij.cyrene.core.keepalive.notificationsAllowed + +/** + * 「她主动说的话」那条通知的渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做过的静音 + * 设置会失效(与 `keep_alive` 同一条纪律)。 + * + * **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是 + * [PROACTIVE_IMPORTANCE](**要提醒人**)。两者混在一条渠道里,就只能在「保活开始响铃」与 + * 「她的话不响铃」之间二选一——那正是「一个渠道只能是其中一件事」的由来。 + */ +internal const val PROACTIVE_CHANNEL_ID = "proactive" + +/** + * 渠道重要级:`DEFAULT`(有提示音、不弹全屏横幅)。 + * + * 这一条**是**要来提醒人的(她主动找我说话),所以不用 LOW;也不升到 HIGH——HIGH 会在屏幕顶部 + * 弹横幅盖住用户正在做的事,而这句话并没有急到那个程度。 + * + * 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改),所以这里必须是 + * 最终值。`ProactiveNotificationTest` 把这两个值钉住。 + */ +internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT + +/** + * 通知 id。**必须**与保活那条(`KEEP_ALIVE_NOTIFICATION_ID` = 1)分开:同 id 的 `notify` 是 + * **替换**,撞上就是把「正在后台保持连接」那条常驻通知顶掉(前台服务还在跑,而它的通知没了)。 + */ +internal const val PROACTIVE_NOTIFICATION_ID = 2 + +/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */ +internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟" + +/** + * 渠道名(系统设置的渠道列表里那一行)。与通知标题分开是为了让它说清**这条渠道是什么**, + * 而不是重复一遍说话人的名字。 + */ +internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话" + +/** + * 点通知进来时那个「切到主会话」的 Intent extra。 + * + * 它是本端**手写布尔路由**里的一次「带意图的冷启动 / 热启动」:`MainActivity` 读它 → + * `ProactiveTapIntake` 记一笔 → `ChatHomeScreen` 落成一次 `select(主会话)`。刻意**不引导航库** + * (本项目没有导航库,路由就是 `ChatHomeScreen` 里那几个布尔量)。 + */ +internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION" + +/** + * 那个 `PendingIntent` 的 request code。 + * + * ⚠️ **绝不能和保活那条用同一个**(那边是 `0`):两个 Intent 都是「启动 MainActivity」、没有 + * action / data / categories ⇒ `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等 + * 判据里**不含 extras**。同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被 + * 悄悄丢掉——症状是「点通知进来了,但停在别的会话上,什么都没发生」。 + */ +private const val PROACTIVE_OPEN_REQUEST_CODE = 2 + +/** + * 弹一条「她主动说的话」的通知。 + * + * 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据与去重都不在这里** + * (那是 [ProactiveNotifier] 的事)——本函数被调用就意味着「这一次确实该弹」。 + * + * ## 没授通知权限时什么都不做 + * `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条 + * 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有 + * 一份「授没授」的口径)。用户之后在设置页授权、或直接开通了保活那个开关,下一句话就能弹出来 + * ——本函数每次都**现读**权限,不缓存。 + * + * ## 小图标用的是平台资源 + * 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上 + * 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是 + * 同一个做法。要换成本应用的图标时,只需换下面这一行。 + */ +internal fun postProactiveNotification(context: Context, text: String) { + if (!context.notificationsAllowed()) return + + ensureProactiveChannel(context) + + val openApp = PendingIntent.getActivity( + context, + PROACTIVE_OPEN_REQUEST_CODE, + Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = NotificationCompat.Builder(context, PROACTIVE_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setContentTitle(PROACTIVE_NOTIFICATION_TITLE) + .setContentText(text) + // 展开态显示完整正文(`setContentText` 那一行放不下 120 字,而截断只解决「过长」, + // 解决不了「一行的宽度」)。 + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(openApp) + // 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。 + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + // 恒用同一个 id:她连说几句时**后来的覆盖前面的**,通知栏里只留最新一句。 + // 这是刻意的取舍——通知只是「她找你了」这一下提醒,全部内容都在聊天页里; + // 换成一事一条会把通知栏刷满,而她的话本来就都在主会话里躺着。 + NotificationManagerCompat.from(context).notify(PROACTIVE_NOTIFICATION_ID, notification) +} + +/** + * 建(或更新)渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的渠道列表里不会平白多出 + * 一条「她的话」。 + * + * 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效** + * ——它已经在 [PROACTIVE_IMPORTANCE] 上定死。 + */ +private fun ensureProactiveChannel(context: Context) { + val channel = NotificationChannel( + PROACTIVE_CHANNEL_ID, + PROACTIVE_CHANNEL_NAME, + PROACTIVE_IMPORTANCE, + ).apply { + description = "她主动找你说话时的通知" + setShowBadge(true) + } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt new file mode 100644 index 0000000..9265d42 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt @@ -0,0 +1,101 @@ +package top.yeij.cyrene.core.notify + +import top.yeij.cyrene.data.api.dto.SessionSummaryDto + +/** + * 一帧「她主动说的话」进到本类之后**发生了什么**(纯判定的结果,可单测)。 + * + * 分成五档而不是一个布尔,是为了让「为什么这次没弹」在日志与测试里都能一眼看穿:这五条各自 + * 对应一个完全不同的理由,而在通知这一档上,「没弹」与「弹了」一样需要说得清。 + */ +enum class ProactiveNotifyOutcome { + + /** 弹了。 */ + Posted, + + /** 用户正看着主会话(前台 ∧ 聊天页显示主会话):她的话就出现在他眼前,不打扰。 */ + SeenOnScreen, + + /** 同一条已经到达过(实时帧与落库帧两条路,或服务端重推)。 */ + Duplicate, + + /** 帧里没有可显示的正文(坏帧 / 落库失败的空帧)。 */ + NothingToSay, + + /** + * 不属主会话。她主动说的话落的是主会话(服务端 `history.MainSessionID`);帧上若明说了别的 + * 会话,那不是这件事,绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 + */ + OtherSession, +} + +/** + * 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。 + * + * ## 它凭什么能看见两条路 + * 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端 + * 汇于一处:`SessionsViewModel` 的帧订阅(它持有整个会话列表,两条帧它本来就都在消费)。本类 + * 因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android。 + * + * ## 它绝不碰连接 + * 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达 + * 之后**做判断——帧没到就没有任何事发生,它不会去重连、不会去补拉、不会去订阅。 + * + * ## 一次帧到这里的顺序(每一步的顺序都有理由) + * 1. [ProactiveNotifyOutcome.OtherSession]:不是主会话,连账本都不必碰; + * 2. [ProactiveNotifyOutcome.NothingToSay]:先看有没有可显示的字。**这一步不记账**——同一句话的 + * 另一条路上正文可能是齐的(实时帧空、落库帧有字的形状漂移),记了就会把那条能弹的挡成重复; + * 3. [ProactiveNotifyOutcome.Duplicate]:记账与查重。**先记账再判「会不会被看到」**,因为「用户 + * 正看着」那一次也算这条已经到达:他当场看过了,另一条路再补一帧时不该为一句**看过的话**弹 + * 通知(他这会儿可能已经切走了); + * 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。 + * + * @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。 + * @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。测试传一个记录用的假实现即可。 + * @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。 + */ +class ProactiveNotifier( + private val presence: AppPresence, + private val post: (String) -> Unit, + private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, + private val recent: RecentProactiveNotices = RecentProactiveNotices(), +) { + + /** + * 一帧到了(实时帧与落库帧**共用**这一处,所以同一条消息无论到几次都只弹一次)。 + * + * @param sessionId 帧上的会话归属;`null` / 空白 = 帧壳没给(实时帧就是这样:服务端构造它时 + * 不设 `sessionId`)⇒ 按「无法判定」放行,由 [mainSessionId] 那条事实兜住归属。 + * @param id 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路的公共键。 + * @param messageId IM 侧消息号(实时帧才有;落库失败那一档它是最后的服务端键)。 + * @param timestamp 服务端给的时间**字面量**(只作去重兜底,排序与本类无关)。 + * @param content 她的原话(原始文本,标记由 [proactiveNotificationText] 剥)。 + */ + fun onProactiveFrame( + sessionId: String?, + id: String?, + messageId: String?, + timestamp: String?, + content: String, + ): ProactiveNotifyOutcome { + if (!sessionId.isNullOrBlank() && sessionId != mainSessionId) { + return ProactiveNotifyOutcome.OtherSession + } + + val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay + + val firstArrival = recent.markIfNew(proactiveNotifyKey(id, messageId, timestamp, text)) + if (!firstArrival) return ProactiveNotifyOutcome.Duplicate + + // 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反 + // (两份口径早晚会分叉,而这里分叉的代价是「她的话在用户眼前弹出来」)。 + val seenOnScreen = !shouldNotifyProactive( + appInForeground = presence.appInForeground, + viewingTargetSession = presence.isShowingChatSurface(mainSessionId), + ) + if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen + + post(text) + return ProactiveNotifyOutcome.Posted + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt new file mode 100644 index 0000000..697fa9f --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt @@ -0,0 +1,143 @@ +package top.yeij.cyrene.core.notify + +import top.yeij.cyrene.domain.parse.parse + +/** + * 「她主动说的话」该不该弹一条系统通知 —— 纯函数(W53N)。 + * + * 主人对时机的原话是「**App 不在前台,或者不在聊天页**」。写成一个判据(而不是直接写成 + * `!viewing`)是为了让两个事实**各自**可见、可穷举、可测试:它们是两个方向完全不同的东西, + * 合成一行以后「为什么这一档不弹」就再也说不清了。 + * + * 语义(**判断标准只有一条:这条消息此刻会不会正好被用户看到**): + * + * | [appInForeground] | [viewingTargetSession] | 结论 | 场景 | + * |---|---|---|---| + * | true | true | **不弹** | 用户正看着主会话,她的话就出现在他眼前 | + * | true | false | 弹 | 用户在看设置页 / 别的会话(甚至另一个会话的聊天页) | + * | false | false | 弹 | App 退到后台 / 熄屏(进程被保活着,帧还在到) | + * | false | true | 弹 | **Android 上真实存在**:Activity `onStop` 之后组合并没有销毁, + * 聊天页仍「显示」着主会话。此时的用户根本看不见它,所以照弹 | + * + * 最后一行是「两个事实不许互相顶替」的那一半:只看聊天页显示着谁,就会在 App 退到后台以后 + * 静默漏掉**所有**她的话。 + * + * @param appInForeground 此刻有没有 Activity 在「可见」这一档(见 [AppPresence.appInForeground])。 + * @param viewingTargetSession 聊天页此刻显示的是不是**她说这句话的那个会话**。注意它**不含** + * 「前台」的含义(见 [AppPresence.chatSurfaceSessionId])——它只说「屏幕上摆着哪一页」。 + */ +fun shouldNotifyProactive( + appInForeground: Boolean, + viewingTargetSession: Boolean, +): Boolean = !(appInForeground && viewingTargetSession) + +/** + * 通知正文的长度上限(字符)。超出的部分截断加 `…`。 + * + * 取值理由:通知在折叠态只显示一行(约 20~30 字),展开态(`BigTextStyle`)能显示四五行。 + * 这个上限是**展开态**的预算——正文不砍到一行那么短(那样会看不出她在说什么),但也不许整屏 + * 铺开。真正的长文在聊天页里一字不少。 + */ +const val PROACTIVE_NOTIFICATION_MAX_CHARS = 120 + +/** + * 去重账本的容量。同一条她的话最多两条路会到(实时帧 + 落库帧),容量只需要覆盖「最近一小段」; + * 取 64 是给「她连说几句 + 两条路交错」留的余量,同时**有界**——进程活一整天也不会涨。 + */ +const val RECENT_PROACTIVE_CAPACITY = 64 + +/** + * 最近**已经到达过**的 proactive 消息的键(进程级、容量有限、先进先出淘汰)。 + * + * ## 为什么必须有它 + * 同一条消息会走**两条**路到本端:实时帧 `proactive_message`(带**落库行** `id`)与落库帧 + * `message_persisted{proactive:true}`(带 `message.id`,同一个值)。不去重就是同一条消息弹两次。 + * + * ## 为什么记的是「到达过」而不是「弹过」 + * 因为「用户正看着主会话」那一次也要记:他当场看过这条了,随后另一条路再补一帧时**不该**为一句 + * 已经看过的话弹通知(他这会儿可能已经切到别的会话了)。两个方向都要求同一件事:**先到的那一次 + * 说了算**。判据见 [ProactiveNotifier.onProactiveFrame]。 + * + * 有界:超过容量按插入顺序丢掉最老的(`LinkedHashSet` 的迭代顺序就是插入顺序)。 + * 只活在本进程里,不持久化——重启之后最多重复弹一条「刚刚到达过、还没被看过」的话,那比 + * 为一个通知判据在磁盘上留一份状态诚实得多。 + */ +class RecentProactiveNotices(private val capacity: Int = RECENT_PROACTIVE_CAPACITY) { + + private val seen = LinkedHashSet() + + /** + * 记下这个键,并回「它是不是**第一次**来」。返回 false = 这条已经到达过,调用方必须闭嘴。 + * + * 空键恒回 false(「没有键」不是一个可去重的身份,绝不把它当成一条新消息的凭据)。 + */ + @Synchronized + fun markIfNew(key: String): Boolean { + if (key.isBlank()) return false + if (!seen.add(key)) return false + while (seen.size > capacity.coerceAtLeast(1)) { + val oldest = seen.firstOrNull() ?: break + seen.remove(oldest) + } + return true + } + + /** 账本里现有多少个键(测试与诊断用)。 */ + @Synchronized + fun size(): Int = seen.size +} + +/** + * 一条 proactive 消息的去重键(纯函数)。 + * + * 按**服务端给的 id 优先级**取,最后才退到正文: + * 1. 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路**唯一的公共键**, + * 也是服务端唯一保证「同一条消息同一个值」的键; + * 2. IM 侧消息号(只有实时帧有:落库失败那一档它仍然在,`id` 缺席); + * 3. 时间字面量(同上,最后的服务端键); + * 4. [fallback](调用方传**正文**)——服务端一个键都没给时的兜底。 + * + * 第 4 条是刻意的取舍:它会让「没有 id、正文又一个字不差」的两条消息被并成一条(少弹一次), + * 而反过来(不兜底 ⇒ 无键可查 ⇒ 每来一帧都弹)会把服务端重推的同一条话弹成两条。**少弹** + * 是这两边更轻的那一侧——通知只是提醒,话本身在聊天页里一条都不少。 + */ +fun proactiveNotifyKey( + id: String?, + messageId: String?, + timestamp: String?, + fallback: String, +): String = listOf(id, messageId, timestamp) + .firstOrNull { !it.isNullOrBlank() } + ?: fallback + +/** + * 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。 + * + * ## 为什么不直接发原始 `content` + * 她的内容里带 `

` / `` / `` 标记(服务端原样落库),直接放进通知会把标签 + * 露给用户(本仓在会话列表那一行上早就踩过同一件事,见 `ui.sessions.toPreviewText`)。 + * 这里**复用权威解析器** [parse] 而不是再写一份剥标签的正则:同一件事有两份口径早晚会分叉, + * 而分叉的那一侧就是通知里露标签的那一侧。解析出的全部片段按原顺序拼回(旁白/动作也留着—— + * 那也是她说的话),片段之间补空格,免得「正文紧贴旁白」时两侧的字粘成一个词。 + * + * 截断按 UTF-16 码元走,但**不劈开代理对**(emoji 这类字符由两个码元组成,劈开会显示成半个 + * 乱码方块):末尾正好落在高代理项上时整体后退一位。 + */ +fun proactiveNotificationText( + content: String, + maxChars: Int = PROACTIVE_NOTIFICATION_MAX_CHARS, +): String? { + val flat = parse(content) + .joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { part -> part.text } } + .replace(WHITESPACE_RE, " ") + .trim() + if (flat.isEmpty()) return null + // maxChars <= 0 = 「不截断」(调用方明确要全文);其余情况只在真的超长时才动手。 + if (maxChars <= 0 || flat.length <= maxChars) return flat + + var end = maxChars + if (Character.isHighSurrogate(flat[end - 1])) end -= 1 + return flat.substring(0, end).trimEnd() + "…" +} + +private val WHITESPACE_RE = Regex("\\s+") diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index cb02e0e..d005fab 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -459,9 +459,21 @@ sealed class WsEvent { override val type: String get() = "session_read" } - /** 主会话(`web` / `web:`)消息落库后广播。 */ + /** + * 主会话(`web` / `web:`)消息落库后广播。 + * + * [proactive] = **这句话是她主动说的**(v0.72 起,服务端走 IM 桥那条路由时会补发这一帧, + * 并在帧上打这个判据)。它此前被本端**丢掉**了:`data` 只解 `message`,而生产 `Json` 是 + * `ignoreUnknownKeys = true` ⇒ 这个键既没被解、也不会报错,于是「她主动说的话走了落库这条路」 + * 这件事在客户端**完全不可见**(W53N 的通知判据正是靠它)。 + * + * 判据的两种落点都接受(见 [parse]):`data.proactive` 与帧壳顶层的 `proactive` 取**或**。 + * 契约只说「帧顶带它」,没说清是帧壳还是载荷,而**猜错就是整个功能静默失效**——所以两处都读。 + * 缺席(`false`)= 普通消息,绝不弹通知(主人只批了 proactive 这一档)。 + */ data class MessagePersisted( val message: HistoryMessageDto?, + val proactive: Boolean = false, override val turnId: String? = null, override val seq: Long? = null, override val sessionId: String? = null, @@ -667,7 +679,16 @@ sealed class WsEvent { val seq = root["seq"].asStringOrNull()?.toLongOrNull() val sessionId = root["sessionId"].asStringOrNull() val data = root["data"]?.takeIf { it !is JsonNull } - return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull() + val event = runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull() + ?: return null + // 帧顶那一位 `proactive`(见 [MessagePersisted])。这一层只对落库帧有意义,载荷里的那一份 + // 由 [PersistedData] 解出来,两边**取或**:契约写的是「帧顶带它」而没说是帧壳还是载荷, + // 而猜错的方向是「功能静默失效」,所以两处都认。 + return if (event is MessagePersisted && root.frameProactive()) { + event.copy(proactive = true) + } else { + event + } } private fun dispatch( @@ -840,8 +861,15 @@ sealed class WsEvent { // 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。 "session_read" -> decode(data) .let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) } - "message_persisted" -> decode(data) - .let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } + "message_persisted" -> decode(data).let { + MessagePersisted( + message = it.message, + proactive = it.proactive.asTrue(), + turnId = turnId, + seq = seq, + sessionId = sessionId ?: it.message?.sessionId, + ) + } "channel_persisted" -> decode(data) .let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } // 消息编辑(D3 §3.8):`data` **就是**编辑后的完整消息 DTO——**没有** `message` @@ -922,6 +950,22 @@ private fun JsonElement?.asStringOrNull(): String? { return primitive.content } +/** + * 帧壳顶层的 `proactive` 是否为真。 + * + * 只认**字面量** `true`(布尔或字符串 `"true"`),其余(键缺席、`false`、`JsonNull`、数字、对象) + * 一律按假——这个键的方向必须偏「不打扰」:判错成真会给一条普通消息弹出通知,而主人只批了 + * proactive 那一档;判错成假的最多是她主动说的话没弹通知(不打扰人,也不撒谎)。 + */ +private fun JsonObject.frameProactive(): Boolean = this["proactive"].asTrue() + +/** + * `proactive` 那个键的**宽容**读法(只认字面 `true`;`JsonNull` / 别的形状一律假)。 + * 帧壳与载荷两处共用这一份口径,免得同一个键在两处各判一次、各自漂移。 + */ +private fun JsonElement?.asTrue(): Boolean = + (this as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true + @Serializable private data class IndexData(val index: Int = 0) @@ -1037,8 +1081,24 @@ private data class ChatDuplicateData( @Serializable private data class SessionReadData(val readAt: Long = 0L) +/** + * `message_persisted` / `channel_persisted` 载荷:`{message:{…}}`。 + * + * [proactive] 是「这句话是她主动说的」(见 [WsEvent.MessagePersisted])。它此前**不在**这里, + * 于是被 `ignoreUnknownKeys = true` 静静吃掉——这正是 W53N 要修的那一条:判据键读不回来, + * 「她主动说的话」在客户端就只剩 `proactive_message` 那一条路可见。 + * + * ⚠️ 声明成 [JsonElement] 而不是 `Boolean` 是**修一类能把整帧弄没的解码缺陷**(同 + * [ProactiveData.timestamp] 那条教训):契约没说这个键的类型,而线上只要给的是 `"true"` 这种 + * 字符串形状,`Boolean` 解码就会抛、被 `parse` 的 `runCatching` 吞成 `null` ⇒ **整帧消失**, + * 连普通消息的落库广播都跟着受影响。收成元素之后按字面量读(见 [asTrue]),一个判据键从此 + * 不可能让一整类帧消失。 + */ @Serializable -private data class PersistedData(val message: HistoryMessageDto? = null) +private data class PersistedData( + val message: HistoryMessageDto? = null, + val proactive: JsonElement? = null, +) /** * `message_deleted` 载荷(§3.8):`{id}`。缺键 / 空串解成空 `id`,上层据此整帧跳过 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index e9700be..59819cb 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -65,6 +65,22 @@ fun ChatHomeScreen( } } + // 「点她那条通知进来 ⇒ 看主会话」(W53N)。意图活在进程里(通知可能在 App 还没起来、甚至还没 + // 登录时就被点,见 ProactiveTapIntake),这里只负责把它落地成用户能看见的两件事: + // 回到聊天页、并切到主会话。走的是用户点抽屉里那一行的同一条路(select),不另造机制。 + val pendingProactiveTap by container.proactiveTapIntake.openMainSession.collectAsState() + LaunchedEffect(pendingProactiveTap) { + if (!pendingProactiveTap) return@LaunchedEffect + container.proactiveTapIntake.consume() + // 通知说的是「她刚在主会话里说话」,所以落点必须是**那一页**:设置页 / 管理页 / 关于 / + // 抽屉还开着的话,用户点进来看到的仍是它们,这条通知就等于没兑现。 + drawerOpen = false + showAbout = false + showSettings = false + showAdmin = false + sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID) + } + BackHandler(enabled = showAdmin) { showAdmin = false } // 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉, // 返回才只退一层(关于 → 设置),不会一步跳回聊天。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 012259d..cec623e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -195,6 +196,17 @@ fun ChatScreen( val state by viewModel.state.collectAsState() val listState = rememberLazyListState() + // 「聊天页此刻正显示着这个会话」——「她主动说的话要不要弹通知」的两半之一 + // (判据 `core.notify.shouldNotifyProactive`:前台 ∧ 这一半 才不弹)。 + // + // 报在这里是因为**组合就是「这一页在不在屏幕上」**:设置页 / 管理页时上层那段 `when` 根本不会 + // 组合本函数,会话一换本函数也整个换掉——两个方向都自动成立,不需要谁去同步一份状态。 + // 退出时记 `null`(不是「记成别的会话」):那一刻屏幕上没有聊天页。 + DisposableEffect(container, sessionId) { + container.appPresence.onChatSurface(sessionId) + onDispose { container.appPresence.onChatSurface(null) } + } + // 这个会话**刚刚成为当前会话**:这是「最近」的唯一表达处(口径见 `chatSessionEvictions`), // 顺手按账本回收超出的格数。放在组合之后是刻意的——槽位可能刚在这一帧建出来,而账本不该在 // 组合期间被改动(组合被丢弃时不该留下「它被打开过」的结论)。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 672b2f6..452b57d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -505,6 +505,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { * 帧里那个会话 id,不是当前打开的那个(抽屉要回答的正是「别的会话在不在跑」)。这一折**只读** * 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义 * 一个字节都没变。 + * + * **她主动说的话还有第三个落点**(W53N):两条路(实时帧 / 带 `proactive` 的落库帧)**都**要 + * 喂给 [top.yeij.cyrene.core.notify.ProactiveNotifier]——通知该不该弹、是不是重复由它判。 + * 选在这里而不是新起一个订阅,是因为本 VM 是**唯一**同时看着这两帧、又活过整个登录态的 + * 消费者;而它只把帧**转交**出去,不碰连接(连接的所有权仍在登录态那一处)。 */ private fun onEvent(event: WsEvent) { onBusyFrame(event) @@ -514,11 +519,37 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { // 落了新消息。哪一个会话都有事做:正在看的那个推进游标(消息已经显示在屏幕上了, // 否则「自己正看着的会话」会攒出一个未读角标,契约 §3.9 的客户端处理建议), // 别的会话则去问一次服务端(红点不该等用户开抽屉才出现)——两半都在 onMessagePersisted。 - is WsEvent.MessagePersisted -> onMessagePersisted(event) + is WsEvent.MessagePersisted -> { + onMessagePersisted(event) + // 她主动说的话走 **IM 桥那条路由**时,服务端把它补发在这个落库帧上(帧里的 + // `proactive` 判据,见 WsEvent.MessagePersisted)。那条路**不发** `proactive_message`, + // 所以不在这一档看一眼,通知就只剩一半入口(W53N)。 + if (event.proactive) { + container.proactiveNotifier.onProactiveFrame( + sessionId = event.sessionId, + id = event.message?.id, + messageId = null, + timestamp = null, + content = event.message?.content.orEmpty(), + ) + } + } // 她主动说的话(v0.72)。服务端**不为这条路补** `message_persisted`(那条只有 IM 路由 // 才广播),所以上面那一档永远不会被它触发——不在这里补一句,主会话的红点会停在她 // 开口之前,要等用户自己开一次抽屉(那次静默重拉)才补上。 - is WsEvent.ProactiveMessage -> onProactiveMessage(event) + // + // 同一帧也是「要不要弹通知」的入口之一(另一条是上面那个落库帧):同一条消息两条路都 + // 可能到,去重由 `core.notify.ProactiveNotifier` 按 id 管,这里不做任何判断。 + is WsEvent.ProactiveMessage -> { + onProactiveMessage(event) + container.proactiveNotifier.onProactiveFrame( + sessionId = event.sessionId, + id = event.id, + messageId = event.messageId, + timestamp = event.timestamp, + content = event.content, + ) + } else -> Unit } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/app/ProactiveTapIntakeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/app/ProactiveTapIntakeTest.kt new file mode 100644 index 0000000..c12bee0 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/app/ProactiveTapIntakeTest.kt @@ -0,0 +1,56 @@ +package top.yeij.cyrene.app + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「点通知进来要看主会话」那笔待落地的意图(W53N)。 + * + * 这里**刻意不测** [ProactiveTapIntake.accept](它要 `android.content.Intent`,单元测试里没有 + * 真实现)——那条判断只有一条 extra 的字面量比较,而真正会出错的是这一半:**记账与消费的时机** + * (记账必须在通知被点的那一刻就发生,消费必须只发生一次、且只在真的落地之后)。 + */ +class ProactiveTapIntakeTest { + + @Test + fun `nothing is requested before a tap`() { + assertFalse(ProactiveTapIntake().openMainSession.value) + } + + @Test + fun `a request survives until it is consumed`() { + // 通知可能在 App 还没起来、甚至还没登录时就被点:意图必须留着,等 ChatHomeScreen 能落地 + // 的那一刻再来取(它一旦被丢掉,用户点了通知却什么都没发生)。 + val intake = ProactiveTapIntake() + + intake.requestMainSession() + + assertTrue(intake.openMainSession.value) + } + + @Test + fun `consuming clears it exactly once`() { + val intake = ProactiveTapIntake() + intake.requestMainSession() + + intake.consume() + + assertFalse(intake.openMainSession.value) + // 再消费一次也不该把「已经取走了」翻回成「还欠着」(它是个纯写入,没有副作用)。 + intake.consume() + assertFalse(intake.openMainSession.value) + } + + @Test + fun `a new tap after a consumed one is taken again`() { + // 第二次点通知必须照样生效:消费清的只是「这一笔」,不是把整个入口关掉。 + val intake = ProactiveTapIntake() + intake.requestMainSession() + intake.consume() + + intake.requestMainSession() + + assertTrue(intake.openMainSession.value) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/notify/AppPresenceTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/notify/AppPresenceTest.kt new file mode 100644 index 0000000..f860a24 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/AppPresenceTest.kt @@ -0,0 +1,98 @@ +package top.yeij.cyrene.core.notify + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「App 在不在前台」这个事实的记账(W53N)。 + * + * 为什么值得单测:它最容易做成一个布尔量,而布尔量在**配置变更**上必然出错——旋转 / 系统深浅色 + * 切换时,**新实例的 `onStart` 先于旧实例的 `onStop`**。记成布尔量的话,用户正看着聊天页, + * 她一句话却会弹出一条通知来。下面第二条就是这一幕。 + */ +class AppPresenceTest { + + private val presence = AppPresence() + + private class Screen + + @Test + fun `a process with no started activity is not in the foreground`() { + assertFalse(presence.appInForeground) + } + + @Test + fun `a config change keeps the app in the foreground`() { + val old = Screen() + val new = Screen() + presence.onActivityStarted(old) + + // 旋转:新实例先起来,旧实例之后才停。 + presence.onActivityStarted(new) + presence.onActivityStopped(old) + + assertTrue("新实例还活着,就不算后台", presence.appInForeground) + } + + @Test + fun `two activities keep it in the foreground until the last one stops`() { + val first = Screen() + val second = Screen() + presence.onActivityStarted(first) + presence.onActivityStarted(second) + + presence.onActivityStopped(first) + assertTrue("还有一屏在被看", presence.appInForeground) + + presence.onActivityStopped(second) + assertFalse(presence.appInForeground) + } + + @Test + fun `a stop from an activity that never started does not flip anything`() { + // 「只删自己那一行」的等价纪律:登出 / 销毁路径上多报一次 stop 不该把别人算掉。 + val alive = Screen() + presence.onActivityStarted(alive) + + presence.onActivityStopped(Screen()) + + assertTrue(presence.appInForeground) + } + + @Test + fun `the chat surface reports the session it shows, verbatim`() { + presence.onChatSurface("web") + + assertTrue(presence.isShowingChatSurface("web")) + // 主会话 `web` 与次级会话 `web:xxx` 是两个会话:等值比较,不是前缀。 + presence.onChatSurface("web:7f3a") + assertFalse(presence.isShowingChatSurface("web")) + assertTrue(presence.isShowingChatSurface("web:7f3a")) + } + + @Test + fun `leaving the chat page shows no session at all`() { + presence.onChatSurface("web") + + presence.onChatSurface(null) + + assertFalse(presence.isShowingChatSurface("web")) + } + + @Test + fun `a blank session id is never being shown`() { + // 「没显示任何会话」与「显示了一个空 id 的会话」不是同一件事——空 id 不是任何会话的键。 + presence.onChatSurface("") + + assertFalse(presence.isShowingChatSurface("")) + } + + @Test + fun `the chat surface fact does not imply the foreground fact`() { + // 两件事各报各的:Android 上 App 退到后台时组合并不会销毁,聊天页那一半会原样留着。 + presence.onChatSurface("web") + + assertFalse("没有 Activity 在可见档,就是后台", presence.appInForeground) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt new file mode 100644 index 0000000..68b8dec --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt @@ -0,0 +1,297 @@ +package top.yeij.cyrene.core.notify + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.SessionSummaryDto + +/** + * 通知通路的整体判据(W53N):**去重**与「什么时候弹」。 + * + * 这里钉的都是**错了就肉眼可见**的边界: + * 1. 同一条消息走两条路(实时帧带落库行 `id`、落库帧带同一个 `message.id`)——**只许弹一次**; + * 2. 普通消息(不带 `proactive`)根本到不了这里,到了也不该弹(见 `WsEventPersistedProactiveTest`); + * 3. 后台 + 聊天页仍挂着主会话那一档**要弹**(Android 上真实存在); + * 4. 不在主会话的帧绝不弹。 + */ +class ProactiveNotifierTest { + + private val presence = AppPresence() + private val posted = mutableListOf() + private val notifier = ProactiveNotifier(presence = presence, post = { posted += it }) + + /** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */ + private val screen = Any() + + /** 后台、聊天页不在(最常见的「她说了句话」):这一档必须弹。 */ + private fun background() { + presence.onChatSurface(null) + } + + /** 前台、正看着主会话:她的话出现在屏幕上,不该再有通知。 */ + private fun onMainSessionInForeground() { + presence.onActivityStarted(screen) + presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID) + } + + // —— 两条路只弹一次 —— + + @Test + fun `the live frame and the persisted frame notify once`() { + background() + // 实时帧:落库行 id 在 `data.id` 里(IM 侧消息号是另一个 id)。 + val live = notifier.onProactiveFrame( + sessionId = null, + id = "1371", + messageId = "im-7", + timestamp = "2026-10-06T01:20:05.123+08:00", + content = "记得喝水哦", + ) + // 落库帧:同一条的 `message.id`(服务端两侧同一个值)。 + val persisted = notifier.onProactiveFrame( + sessionId = SessionSummaryDto.MAIN_SESSION_ID, + id = "1371", + messageId = null, + timestamp = null, + content = "记得喝水哦", + ) + + assertEquals(ProactiveNotifyOutcome.Posted, live) + assertEquals(ProactiveNotifyOutcome.Duplicate, persisted) + assertEquals(listOf("记得喝水哦"), posted) + } + + @Test + fun `a frame without a row id is deduplicated by the im message id`() { + // 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧 + // (或本端重放)不该变成两条通知。 + background() + fun frame() = notifier.onProactiveFrame( + sessionId = null, + id = null, + messageId = "im-9", + timestamp = "2026-10-06T01:20:05+08:00", + content = "在忙吗", + ) + + assertEquals(ProactiveNotifyOutcome.Posted, frame()) + assertEquals(ProactiveNotifyOutcome.Duplicate, frame()) + assertEquals(1, posted.size) + } + + @Test + fun `a frame with no server key at all falls back to the text`() { + // 服务端一个键都没给:兜底键取正文。**少弹**是刻意选的那一侧(多弹一次才是骚扰)。 + background() + fun frame() = notifier.onProactiveFrame( + sessionId = null, + id = null, + messageId = null, + timestamp = null, + content = "想你了", + ) + + assertEquals(ProactiveNotifyOutcome.Posted, frame()) + assertEquals(ProactiveNotifyOutcome.Duplicate, frame()) + } + + @Test + fun `a blank id is treated as absent`() { + // 形状漂移(空串)与「这个键不在」必须是同一个结论,否则一条空 id 会把后面的都判成重复。 + background() + notifier.onProactiveFrame(null, id = " ", messageId = "im-3", timestamp = null, content = "甲") + + val second = notifier.onProactiveFrame(null, id = null, messageId = "im-4", timestamp = null, content = "乙") + + assertEquals(ProactiveNotifyOutcome.Posted, second) + assertEquals(2, posted.size) + } + + // —— 什么时候弹 —— + + @Test + fun `watching the main session in the foreground stays silent`() { + onMainSessionInForeground() + + val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦") + + assertEquals(ProactiveNotifyOutcome.SeenOnScreen, outcome) + assertTrue(posted.isEmpty()) + } + + @Test + fun `watching another session still notifies`() { + // 「不在聊天页」包括「在另一个会话的聊天页」:她的话进的是主会话,用户看不到它。 + presence.onActivityStarted(this) + presence.onChatSurface("web:7f3a") + + val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦") + + assertEquals(ProactiveNotifyOutcome.Posted, outcome) + } + + @Test + fun `the settings page still notifies`() { + // 前台但聊天页不在组合里(设置页 / 管理页 / 关于):那一刻聊天页报的是 null。 + presence.onActivityStarted(this) + presence.onChatSurface(null) + + assertEquals( + ProactiveNotifyOutcome.Posted, + notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"), + ) + } + + @Test + fun `a backgrounded app notifies even though the chat page is still composed`() { + // 这是 AppPresence 的类注释说的那一档:Activity onStop 之后组合还挂着,「聊天页显示着主会话」 + // 仍然为真,而用户根本看不见它。只看聊天页那一半就会把后台的每一句都漏掉。 + presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID) + + assertEquals( + ProactiveNotifyOutcome.Posted, + notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"), + ) + } + + @Test + fun `a message that was already seen on screen is not notified by the other route`() { + // 先到的那一次说了算:他当场看过了,随后落库帧再补一帧时他可能已经切走—— + // 但**看过就是看过**,不该为一句看过的话弹通知。 + onMainSessionInForeground() + assertEquals( + ProactiveNotifyOutcome.SeenOnScreen, + notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"), + ) + + presence.onChatSurface("web:7f3a") + assertEquals( + ProactiveNotifyOutcome.Duplicate, + notifier.onProactiveFrame(SessionSummaryDto.MAIN_SESSION_ID, "1371", null, null, "记得喝水哦"), + ) + assertTrue(posted.isEmpty()) + } + + // —— 硬边界:不该弹的绝不弹 —— + + @Test + fun `a frame from another session never notifies`() { + background() + + val outcome = notifier.onProactiveFrame( + sessionId = "sess-9", + id = "1371", + messageId = "im-7", + timestamp = null, + content = "今天在忙什么", + ) + + assertEquals(ProactiveNotifyOutcome.OtherSession, outcome) + assertTrue("别处的消息不许说成是她在找我说话", posted.isEmpty()) + } + + @Test + fun `an absent - blank session id on the frame is treated as the main session`() { + // 实时帧的帧壳**不带** sessionId(服务端构造时不设)⇒「无法判定」放行,归属由主会话那条事实兜住。 + background() + + assertEquals( + ProactiveNotifyOutcome.Posted, + notifier.onProactiveFrame(sessionId = null, id = "1", messageId = null, timestamp = null, content = "在吗"), + ) + assertEquals( + ProactiveNotifyOutcome.Posted, + notifier.onProactiveFrame(sessionId = "", id = "2", messageId = null, timestamp = null, content = "在吗"), + ) + } + + @Test + fun `a frame with nothing to show is skipped`() { + background() + + val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, " ") + + assertEquals(ProactiveNotifyOutcome.NothingToSay, outcome) + assertTrue(posted.isEmpty()) + } + + @Test + fun `a contentless frame does not burn the key of the message that does have content`() { + // 空帧**不记账**:同一条消息的另一条路上正文可能是齐的(形状漂移),记了就会把它挡成重复。 + background() + notifier.onProactiveFrame(null, "1371", null, null, "") + + assertEquals( + ProactiveNotifyOutcome.Posted, + notifier.onProactiveFrame(null, "1371", null, null, "记得喝水哦"), + ) + } + + @Test + fun `the posted text is her own words, not the raw markup`() { + background() + + notifier.onProactiveFrame(null, "1", null, null, "

在的

轻轻笑") + + assertEquals(listOf("在的 轻轻笑"), posted) + } + + @Test + fun `the main session constant has one spelling`() { + // `ui.chat.MAIN_SESSION_ID` 与 `SessionSummaryDto.MAIN_SESSION_ID` 是同一个事实的两处写法 + // (判据里用的是后者)。两处一旦分叉,「主会话」这条归属就会静默判错——钉住它。 + assertEquals("web", SessionSummaryDto.MAIN_SESSION_ID) + assertEquals(SessionSummaryDto.MAIN_SESSION_ID, top.yeij.cyrene.ui.chat.MAIN_SESSION_ID) + } +} + +/** + * 去重账本本身(与 [ProactiveNotifierTest] 分开:一个是「弹不弹」,一个是「凭什么记得住」)。 + */ +class RecentProactiveNoticesTest { + + @Test + fun `the first arrival of a key is new and the second is not`() { + val recent = RecentProactiveNotices() + + assertTrue(recent.markIfNew("1371")) + assertTrue(!recent.markIfNew("1371")) + assertTrue(recent.markIfNew("1372")) + } + + @Test + fun `the ledger is bounded and drops the oldest first`() { + // 进程级账本**不许无限涨**:容量满了丢掉最老的那些(丢掉的代价最多是重复弹一次, + // 而那要等到「同一条又被重推」才可能发生)。 + val recent = RecentProactiveNotices(capacity = 3) + + recent.markIfNew("a") + recent.markIfNew("b") + recent.markIfNew("c") + recent.markIfNew("d") + + assertEquals(3, recent.size()) + assertTrue("最老的 a 已经被挤出去", recent.markIfNew("a")) + assertTrue("'b' 也早被挤出去(容量 3 只留 c/d/a)", recent.markIfNew("b")) + } + + @Test + fun `a blank key is never considered new`() { + // 「没有键」不是一个可去重的身份:把它当新消息就等于每次都弹。 + val recent = RecentProactiveNotices() + + assertTrue(!recent.markIfNew("")) + assertTrue(!recent.markIfNew(" ")) + assertEquals(0, recent.size()) + } + + @Test + fun `a zero capacity still remembers one key`() { + // 容量被误传成 0 / 负数时不许退化成「每条都弹」:至少记住最近一条。 + val recent = RecentProactiveNotices(capacity = 0) + + assertTrue(recent.markIfNew("1371")) + assertTrue(!recent.markIfNew("1371")) + assertEquals(1, recent.size()) + } +} 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 new file mode 100644 index 0000000..1529c2b --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt @@ -0,0 +1,134 @@ +package top.yeij.cyrene.core.notify + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「她主动说的话」的通知判据(W53N)——**纯函数**,四档组合穷举在这里。 + * + * 主人对时机的原话是「App 不在前台,或者不在聊天页」;这个判据是它唯一的落地处, + * 而那四种组合**各有各的真实场景**(尤其是「不在前台、聊天页却还显示着主会话」那一档: + * Android 上 Activity `onStop` 之后组合并不销毁,只看聊天页就会把后台收到的**每一句**都漏掉)。 + */ +class ShouldNotifyProactiveTest { + + @Test + fun `being on the main session in the foreground is the only silent case`() { + assertFalse( + "用户正看着主会话,她的话就出现在他眼前——再弹一条通知是打扰", + shouldNotifyProactive(appInForeground = true, viewingTargetSession = true), + ) + } + + @Test + fun `any other combination notifies`() { + assertTrue( + "前台但不在聊天页(设置页 / 管理页 / 别的会话)——这条消息他不会正好看到", + shouldNotifyProactive(appInForeground = true, viewingTargetSession = false), + ) + assertTrue( + "退到后台:两个事实都假", + shouldNotifyProactive(appInForeground = false, viewingTargetSession = false), + ) + assertTrue( + "退到后台而组合还挂着(聊天页仍「显示」主会话):用户根本看不见它,所以照弹", + shouldNotifyProactive(appInForeground = false, viewingTargetSession = true), + ) + } +} + +/** + * 通知正文(纯函数):剥标记、折成一行、过长截断。 + * + * 这几条**每一条错了都肉眼可见**:露一个 `

` 出来、把两句话粘成一个词、或截断劈开一个 emoji + * (半个代理码元在通知里是一块乱码方块)。 + */ +class ProactiveNotificationTextTest { + + @Test + fun `the markup she writes is stripped instead of shown`() { + // 服务端原样落库她的标记,直接显示会把标签露给用户(会话列表那一行早就踩过同一件事)。 + assertEquals( + "今天下午 悄悄戳了戳你 你有空吗", + proactiveNotificationText("

今天下午

悄悄戳了戳你

你有空吗

"), + ) + } + + @Test + fun `newlines collapse into one line`() { + assertEquals("第一句 第二句", proactiveNotificationText("第一句\n\n第二句")) + } + + @Test + fun `a short line is left untouched`() { + assertEquals("记得喝水哦", proactiveNotificationText("记得喝水哦")) + } + + @Test + fun `a long line is truncated with an ellipsis`() { + val text = proactiveNotificationText("她".repeat(200), maxChars = 10) + + assertEquals("她".repeat(10) + "…", text) + } + + @Test + fun `truncation never splits a surrogate pair`() { + // 😀 在 UTF-16 里是两个码元。maxChars = 2 正好落在它的**高代理项**上:不后退一位就会留下 + // 半个字符(通知里显示成乱码方块)。 + val text = proactiveNotificationText("a😀b", maxChars = 2) + + assertEquals("a…", text) + assertFalse( + "结果里不许有孤立的代理码元", + text!!.any { it.isHighSurrogate() || it.isLowSurrogate() }, + ) + } + + @Test + fun `nothing to say returns null rather than an empty notification`() { + assertNull(proactiveNotificationText("")) + assertNull(proactiveNotificationText(" \n ")) + // 只有标记、没有一个字:解析器把它解成一串空白,也不该弹一条空通知。 + assertNull(proactiveNotificationText("

")) + } +} + +/** + * 通知 id / 渠道 id 的**对外形状**(与 `KeepAliveNotificationTest` 同一套理由)。 + * + * 这两条只能在单元测试里钉住(文案与图标要 Android 运行时),而它们恰恰是最容易出大错的两处: + * 同 id 会**替换**掉保活那条常驻通知;同渠道会让「保活开始响铃」与「她的话不响铃」二选一。 + */ +class ProactiveNotificationShapeTest { + + @Test + fun `the proactive notification never replaces the keep-alive one`() { + assertTrue( + "同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉", + PROACTIVE_NOTIFICATION_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID, + ) + } + + @Test + fun `proactive has its own channel`() { + assertTrue( + "两条渠道各自表达一件事,混在一条里就只能二选一", + PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID, + ) + // 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。 + assertEquals("proactive", PROACTIVE_CHANNEL_ID) + } + + @Test + fun `the channel is allowed to alert unlike the keep-alive one`() { + // 这一条**是**要来提醒人的;与保活那条(LOW)刻意相反,所以这里钉的是「它比 LOW 高」。 + assertTrue( + "她要提醒人,重要级必须高于保活那条", + PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE, + ) + assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedProactiveTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedProactiveTest.kt new file mode 100644 index 0000000..cf47be5 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedProactiveTest.kt @@ -0,0 +1,114 @@ +package top.yeij.cyrene.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 落库帧上的 `proactive` 判据键(W53N)。 + * + * 这是 W53N 的**第一半**:她主动说的话有两条线上路径,其中走 IM 桥那条会把判据打在落库帧上, + * 而这个键此前被本端**整个丢掉**了(`PersistedData` 只解 `message`,生产 `Json` 又是 + * `ignoreUnknownKeys = true` ⇒ 既不解也不报错)。丢掉它不报任何错,只是「她主动说的话」在客户端 + * 少了一半入口——通知永远不会为那条路弹。 + * + * 另外两件事也必须在这里钉住,它们各自都足以让整帧消失或让普通消息响起来: + * 1. 这个键**缺席**时必须是普通消息(主人只批了 proactive 这一档,普通消息绝不弹); + * 2. 这个键**形状不认识**时,帧照旧成形(宁可判成「不是 proactive」,也不能让整帧消失—— + * 那会连普通消息的落库广播一起弄没)。 + */ +class WsEventPersistedProactiveTest { + + private fun frame(data: String) = """{"type":"message_persisted","sessionId":"web","data":$data}""" + + private val row = """ + {"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123} + """.trimIndent() + + @Test + fun `a proactive flag inside the payload is read back`() { + val event = WsEvent.parse( + frame("""{"proactive":true,"message":$row}"""), + ) as WsEvent.MessagePersisted + + assertTrue("判据键必须真的读回来——这是整批任务的前提", event.proactive) + assertEquals("1371", event.message?.id) + assertEquals("记得喝水哦", event.message?.content) + } + + @Test + fun `a proactive flag on the frame shell is read too`() { + // 契约说的是「帧顶带它」,而没说清是帧壳还是载荷。**猜错的方向是这个功能静默失效**, + // 所以两层都认(这里钉住帧壳那一层)。 + val event = WsEvent.parse( + """{"type":"message_persisted","sessionId":"web","proactive":true,"data":{"message":$row}}""", + ) as WsEvent.MessagePersisted + + assertTrue(event.proactive) + assertEquals("1371", event.message?.id) + } + + @Test + fun `a string shaped flag is accepted instead of killing the frame`() { + // 同 `proactive_message` 的 `timestamp` 那条教训:线上形状只要不是客户端声明的那一种, + // 严格解码就会抛、被 `parse` 的 runCatching 吞成 null ⇒ **整帧消失**(连普通消息都受影响)。 + // 宽容读法把「不认识」折成「不是 proactive」,帧本身照旧成形。 + val event = WsEvent.parse( + frame("""{"proactive":"true","message":$row}"""), + ) as WsEvent.MessagePersisted + + assertTrue(event.proactive) + assertEquals("1371", event.message?.id) + } + + @Test + fun `an absent flag means an ordinary message`() { + // 硬边界:普通消息绝不弹通知。缺席是**唯一**的现实形状(老服务端 / 非 proactive 路由)。 + val event = WsEvent.parse(frame("""{"message":$row}""")) as WsEvent.MessagePersisted + + assertFalse(event.proactive) + assertEquals("1371", event.message?.id) + } + + @Test + fun `a false flag means an ordinary message`() { + val event = WsEvent.parse( + frame("""{"proactive":false,"message":$row}"""), + ) as WsEvent.MessagePersisted + + assertFalse(event.proactive) + } + + @Test + fun `an unrecognised flag shape keeps the frame and reads as not proactive`() { + // 形状漂移(对象 / 数字 / null)一律按假:这一侧判错只会漏一次通知, + // 判成真会给普通消息弹一条通知——方向必须偏「不打扰」。 + listOf("""{"proactive":{"v":1},"message":$row}""", """{"proactive":1,"message":$row}""", """{"proactive":null,"message":$row}""") + .forEach { data -> + val event = WsEvent.parse(frame(data)) as WsEvent.MessagePersisted + assertFalse("这条形状该被按成普通消息:$data", event.proactive) + assertNotNull("整帧不许因为一个判据键消失:$data", event.message) + } + } + + @Test + fun `the frame still decodes when the payload is missing entirely`() { + val event = WsEvent.parse("""{"type":"message_persisted","sessionId":"web"}""") as WsEvent.MessagePersisted + + assertFalse(event.proactive) + assertNull(event.message) + } + + @Test + fun `channel persisted frames still decode`() { + // 两条帧共用一个载荷类型:给 `PersistedData` 加字段不许影响 IM 日志页那条路。 + val event = WsEvent.parse( + """{"type":"channel_persisted","sessionId":"sess-9","data":{"message":{"id":"m-1","sessionId":"sess-9","role":"user","content":"在吗","createdAt":1}}}""", + ) as WsEvent.ChannelPersisted + + assertEquals("m-1", event.message?.id) + } +}