feat(notify): 她主动说的话改成「一事一条」——通知号按消息派生,后到的不再顶掉先到的

改前所有她主动说的话共用一个固定的通知 id,而同 id 的 `notify` 是**替换**:她连说几句时后来
的那条把先来的顶掉,用户拿起手机只看到最后一句,前面几句一声没响就没了(这正是 W50 判据下游
的落地形状)。

改成按消息派生:
- `ProactiveNotify.kt` 新增纯函数 `proactiveNotificationId(key)`:把**去重键**(`proactiveNotifyKey`
  的输出)折成通知号。刻意与去重键同源——去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」,
  两处口径一旦分成两套,就会出现「认得出是同一条、却排出两条通知」的形状漂移(实时帧与落库帧
  两条路都会到,这条尤其要命)。
- 两个坑都真的堵上:① 号必须 > 0,而 `hashCode()` 可以是负的、甚至正好是 `Int.MIN_VALUE`
  (`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)⇒ **先展宽到 `Long` 再取绝对值**,不引任何新依赖;
  ② 不撞既有 id:全仓只有保活那条常驻通知(id = 1)与它 `PendingIntent` 的 request code = 0 是
  占用者,派生的号从 1000 起步、宽度 2×10⁹,落点与它们**结构性**不相交。
- `ProactiveNotifier.post` 的签名带上这个号(`post: (text: String, notificationId: Int) -> Unit`),
  `AppContainer` 的装配 lambda 同步改;`ProactiveNotification.kt` 删掉恒定的
  `PROACTIVE_NOTIFICATION_ID` / `PROACTIVE_OPEN_REQUEST_CODE`,通知 id 与 `PendingIntent` 的
  request code **一起**随消息走(只换 id 不换 request code,点后到的那条通知会串到先到那条的
  `PendingIntent` 上)。
- 撞号仍会发生(`String.hashCode()` 只有 32 位):后果与改前一样是「后来者替换先来者」,已在
  `proactiveNotificationId` 的 KDoc 里如实写清概率,以及为什么接受这一侧。**没有**放松任何既有断言。

测试:新增 `ProactiveNotificationIdTest`(同一条派生两次同号 / 两条不同消息不同号 / 任何输入恒 > 0,
最后一条用 hashCode 正好是 `Int.MIN_VALUE` 的字符串 `polygenelubricants` 钉住前提);另在
`ProactiveNotifierTest` 加一条端到端的号断言(两条路都到的第一条只发一次且号不变、第二条是另一个
号)。`ProactiveNotificationShapeTest` 里那条「不与保活那条相撞」改成扫一遍真实形状的键。

验证:`./gradlew.bat assembleDebug` → BUILD SUCCESSFUL;`./gradlew.bat testDebugUnitTest` →
BUILD SUCCESSFUL,111 suite / 1336 tests / 0 失败(基线 110 suite / 1332 tests,本批净增 4 条)。
This commit is contained in:
AskaEth committed 2026-10-06 21:08:23 +08:00
1 parent 33c320219a
commit 53339b369a
6 files changed
+172 -33

No files matched your search

@@ -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<String>()
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<Int>()
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 侧消息号还在——服务端重推同一帧
@@ -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)
}
}
}