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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user