feat(W53N): 她主动说的话在后台/不在聊天页时发一条系统通知(只这一档,普通消息不弹)

改了什么:
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)。真机弹通知、点通知的落地未验(留给主人)。
This commit is contained in:
AskaEth committed 2026-10-06 18:51:24 +08:00
1 parent f8ac95ceed
commit 611bc5f58a
16 files changed
+1387 -10

No files matched your search

@@ -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`。
*
@@ -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)
}
}
@@ -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<Boolean> = _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
}
}
@@ -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<Any> =
Collections.newSetFromMap(ConcurrentHashMap<Any, Boolean>())
/**
* 聊天页此刻显示着的会话 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
}
@@ -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)
}
@@ -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
}
}
@@ -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<String>()
/**
* 记下这个键,并回「它是不是**第一次**来」。返回 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`
* 她的内容里带 `<p>` / `<action>` / `<narration>` 标记(服务端原样落库),直接放进通知会把标签
* 露给用户(本仓在会话列表那一行上早就踩过同一件事,见 `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+")
@@ -459,9 +459,21 @@ sealed class WsEvent {
override val type: String get() = "session_read"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
/**
* 主会话(`web` / `web:<uuid>`)消息落库后广播。
*
* [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<SessionReadData>(data)
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"message_persisted" -> decode<PersistedData>(data).let {
MessagePersisted(
message = it.message,
proactive = it.proactive.asTrue(),
turnId = turnId,
seq = seq,
sessionId = sessionId ?: it.message?.sessionId,
)
}
"channel_persisted" -> decode<PersistedData>(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`,上层据此整帧跳过
@@ -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 }
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。
@@ -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`),
// 顺手按账本回收超出的格数。放在组合之后是刻意的——槽位可能刚在这一帧建出来,而账本不该在
// 组合期间被改动(组合被丢弃时不该留下「它被打开过」的结论)。
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<String>()
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, "<p>在的</p><action>轻轻笑</action>")
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())
}
}
@@ -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),
)
}
}
/**
* 通知正文(纯函数):剥标记、折成一行、过长截断。
*
* 这几条**每一条错了都肉眼可见**:露一个 `<p>` 出来、把两句话粘成一个词、或截断劈开一个 emoji
* (半个代理码元在通知里是一块乱码方块)。
*/
class ProactiveNotificationTextTest {
@Test
fun `the markup she writes is stripped instead of shown`() {
// 服务端原样落库她的标记,直接显示会把标签露给用户(会话列表那一行早就踩过同一件事)。
assertEquals(
"今天下午 悄悄戳了戳你 你有空吗",
proactiveNotificationText("<p>今天下午</p><action>悄悄戳了戳你</action><p>你有空吗</p>"),
)
}
@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("<p></p>"))
}
}
/**
* 通知 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)
}
}
@@ -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)
}
}