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 752fb0e..4479efe 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -70,7 +70,7 @@ class AppContainer(context: Context) { */ val proactiveNotifier: ProactiveNotifier = ProactiveNotifier( presence = appPresence, - post = { text -> postProactiveNotification(appContext, text) }, + post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) }, ) /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt index 6fbb848..484a6f2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotification.kt @@ -31,12 +31,6 @@ internal const val PROACTIVE_CHANNEL_ID = "proactive" */ 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 = "昔涟" @@ -55,21 +49,17 @@ internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话" */ 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] 的事)——本函数被调用就意味着「这一次确实该弹」。 + * 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生 + * 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就 + * 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。 + * + * ## 为什么 id 是入参而不是这里恒定一个 + * 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到 + * 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句 + * 无论到几次都落在同一条通知上,而不同的句子各占一条。 * * ## 没授通知权限时什么都不做 * `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条 @@ -81,15 +71,22 @@ private const val PROACTIVE_OPEN_REQUEST_CODE = 2 * 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上 * 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是 * 同一个做法。要换成本应用的图标时,只需换下面这一行。 + * + * @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动: + * 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。 */ -internal fun postProactiveNotification(context: Context, text: String) { +internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) { if (!context.notificationsAllowed()) return ensureProactiveChannel(context) + // request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会 + // 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒ + // `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。 + // 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。 val openApp = PendingIntent.getActivity( context, - PROACTIVE_OPEN_REQUEST_CODE, + notificationId, Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) @@ -108,10 +105,9 @@ internal fun postProactiveNotification(context: Context, text: String) { .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build() - // 恒用同一个 id:她连说几句时**后来的覆盖前面的**,通知栏里只留最新一句。 - // 这是刻意的取舍——通知只是「她找你了」这一下提醒,全部内容都在聊天页里; - // 换成一事一条会把通知栏刷满,而她的话本来就都在主会话里躺着。 - NotificationManagerCompat.from(context).notify(PROACTIVE_NOTIFICATION_ID, notification) + // 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」, + // 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。 + NotificationManagerCompat.from(context).notify(notificationId, notification) } /** 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 index 9265d42..7f1148f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifier.kt @@ -51,12 +51,14 @@ enum class ProactiveNotifyOutcome { * 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。 * * @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。 - * @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。测试传一个记录用的假实现即可。 + * @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的 + * 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时 + * `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。 * @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。 */ class ProactiveNotifier( private val presence: AppPresence, - private val post: (String) -> Unit, + private val post: (text: String, notificationId: Int) -> Unit, private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, private val recent: RecentProactiveNotices = RecentProactiveNotices(), ) { @@ -84,7 +86,8 @@ class ProactiveNotifier( val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay - val firstArrival = recent.markIfNew(proactiveNotifyKey(id, messageId, timestamp, text)) + val key = proactiveNotifyKey(id, messageId, timestamp, text) + val firstArrival = recent.markIfNew(key) if (!firstArrival) return ProactiveNotifyOutcome.Duplicate // 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反 @@ -95,7 +98,8 @@ class ProactiveNotifier( ) if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen - post(text) + // id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。 + post(text, proactiveNotificationId(key)) 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 index 697fa9f..4aae8f0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/notify/ProactiveNotify.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.core.notify +import kotlin.math.absoluteValue import top.yeij.cyrene.domain.parse.parse /** @@ -110,6 +111,54 @@ fun proactiveNotifyKey( .firstOrNull { !it.isNullOrBlank() } ?: fallback +/** + * 派生 id 的取值**下界**(见 [proactiveNotificationId])。 + * + * 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者 + * (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。 + * 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。 + */ +private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000 + +/** + * 派生 id 的取值**宽度**(见 [proactiveNotificationId])。 + * + * 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的 + * 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。 + */ +private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000 + +/** + * 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键 + * [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。 + * + * ## 为什么必须与去重键同源 + * 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后 + * 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」 + * ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**: + * 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、 + * 却排出两条 id」的形状漂移。 + * + * ## 它会撞号,后果如实说 + * `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息 + * 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。 + * 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧 + * ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端 + * 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。 + * + * ## 两个坑都在这里堵上 + * 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知 + * 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至 + * 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。 + * 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全 + * 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。 + * 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。 + */ +internal fun proactiveNotificationId(key: String): Int { + val magnitude = key.hashCode().toLong().absoluteValue + return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt() +} + /** * 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。 * 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 index 68b8dec..fec7af2 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifierTest.kt @@ -1,6 +1,7 @@ package top.yeij.cyrene.core.notify import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test import top.yeij.cyrene.data.api.dto.SessionSummaryDto @@ -18,7 +19,7 @@ class ProactiveNotifierTest { private val presence = AppPresence() private val posted = mutableListOf() - private val notifier = ProactiveNotifier(presence = presence, post = { posted += it }) + private val notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }) /** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */ private val screen = Any() @@ -61,6 +62,30 @@ class ProactiveNotifierTest { assertEquals(listOf("记得喝水哦"), posted) } + @Test + fun `each message gets its own notification id while a replay does not`() { + // 「一事一条」在这一层就定好了:号由**去重键**派生,所以「认得出是同一条」与「落在同一条 + // 通知上」是同一个判断的两面。第二条消息必须是另一个号——同一个号时 `notify` 是替换, + // 她连说两句用户就只看到最后一句(这就是改前的症状)。 + background() + val ids = mutableListOf() + val recorder = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }) + + recorder.onProactiveFrame(null, id = "1371", messageId = "im-7", timestamp = null, content = "记得喝水哦") + // 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再发一条,也就不该多出第二个号。 + recorder.onProactiveFrame( + SessionSummaryDto.MAIN_SESSION_ID, + id = "1371", + messageId = null, + timestamp = null, + content = "记得喝水哦", + ) + recorder.onProactiveFrame(null, id = "1372", messageId = null, timestamp = null, content = "在忙吗") + + assertEquals(2, ids.size) + assertNotEquals("两条不同的消息必须占两条通知", ids[0], ids[1]) + } + @Test fun `a frame without a row id is deduplicated by the im message id`() { // 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧 diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt index 1529c2b..20f2f3b 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/core/notify/ProactiveNotifyTest.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.core.notify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -106,10 +107,27 @@ 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, + // 同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉。 + // 号现在按消息派生(见 `proactiveNotificationId`),所以这里钉的是「派生的号落在保活那条 + // 之外」——扫一遍真实形状的键(服务端行 id、IM 消息号、时间字面量、正文兜底、空键)。 + val keys = listOf( + "0", + "1", + "2", + "1371", + "im-7", + "4294967295", + "2026-10-06T01:20:05.123+08:00", + "记得喝水哦", + "", ) + + keys.forEach { key -> + assertTrue( + "键「$key」派生出的号撞上了保活那条常驻通知", + proactiveNotificationId(key) != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID, + ) + } } @Test @@ -132,3 +150,50 @@ class ProactiveNotificationShapeTest { assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE) } } + +/** + * 通知 id 的派生(W54A)——「一事一条」靠的就是它。 + * + * 三条各自钉住一件**错了就肉眼可见**的事: + * 1. **同一条消息派生两次必须同号**:重放、或实时帧与落库帧两条路都到,落的是同一条通知; + * 2. **两条不同的消息必须不同号**:同号时 `notify` 是替换,她连说两句就只剩最后一句(改前的症状); + * 3. **任何输入都得落在合法区间**:Android 的通知 id 是 int,而 `hashCode()` 可以为负、甚至正好是 + * `Int.MIN_VALUE`——那一个连 `abs` 都兜不住(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)。 + */ +class ProactiveNotificationIdTest { + + @Test + fun `the same message derives the same id every time`() { + // 幂等:进程重启、两条路先后到达都不该改变「这条消息是哪一条通知」。 + val key = proactiveNotifyKey("1371", "im-7", null, "记得喝水哦") + + assertEquals(proactiveNotificationId(key), proactiveNotificationId(key)) + // 键里那三个 id 的**优先级**也是同一件事的一半:同一条消息两条路给的是不同的入参形状, + // 落到同一个键上才算真的稳。 + assertEquals( + key, + proactiveNotifyKey("1371", null, null, "记得喝水哦"), + ) + } + + @Test + fun `two different messages derive two different ids`() { + val first = proactiveNotificationId(proactiveNotifyKey("1371", "im-7", null, "记得喝水哦")) + val second = proactiveNotificationId(proactiveNotifyKey("1372", "im-8", null, "在忙吗")) + + assertNotEquals("不同号是「先来的不被后来的顶掉」的全部前提", first, second) + } + + @Test + fun `every derived id stays positive even when the hash is Int MIN_VALUE`() { + // 先钉住这条用例的前提:这个字符串的 hashCode **正好是** Int.MIN_VALUE(`abs` 兜不住的那个 + // 值)。前提没了就等于这条用例什么都没验。 + val extreme = "polygenelubricants" + assertEquals(Int.MIN_VALUE, extreme.hashCode()) + + val keys = listOf("", "0", "1371", "web", "她主动说的话", extreme) + keys.forEach { key -> + assertTrue("键「$key」派生出了非正号:${proactiveNotificationId(key)}", proactiveNotificationId(key) > 0) + } + } +}