merge: W54A —— 通知一事一条(通知 id / PendingIntent requestCode 按消息稳定派生,修她连说几句时后到覆盖先到)
This commit is contained in:
commit
b3ded77c23
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user