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

@@ -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) },
)
/**
@@ -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)
}
/**
@@ -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
}
}
@@ -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`。
*
@@ -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)
}
}
}