Fix double notification + double unread count for cron deliveries
A cron delivery emits two frames (high-priority notification banner +
message). Backgrounded, each posted its own system notification
('Cron: <job_id>' and the channel name), and the message frame's
redelivery (live SSE + sync replay after the push-triggered reconnect)
incremented the unread badge twice.
- Record when a high-priority banner (cron/approval/clarify) was posted
per lane; suppress the message frame's system notification within a
5 s window (mirrors the gateway's push-coalesce window).
- Dedupe unread counting per lane by message id so a redelivered frame
only counts once (recorded only when the message actually counts as
unread).
This commit is contained in:
1 parent
b065d1783b
commit
ea375fd88c
1 file changed
+49
-5
@@ -95,6 +95,8 @@ import kotlinx.coroutines.launch
|
|||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.TimeMark
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
|
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
|
||||||
@@ -188,13 +190,34 @@ class IrisController(
|
|||||||
_foreground.value = fg
|
_foreground.value = fg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M8: the last message id whose arrival incremented [lane]'s unread
|
||||||
|
* badge. The same finalized message can be delivered twice (live SSE
|
||||||
|
* plus the sync replay after a push-triggered reconnect) — only the
|
||||||
|
* first delivery may count. Frame-handler coroutine only. */
|
||||||
|
private val countedMessageIds = HashMap<String, String>()
|
||||||
|
|
||||||
|
/** M5/M8: when a high-priority notification banner (cron/approval/clarify)
|
||||||
|
* was last posted per lane. Cron delivery = notification frame + message
|
||||||
|
* frame; the banner already announced the delivery, so the accompanying
|
||||||
|
* message frame must not post a second system notification. Frame-handler
|
||||||
|
* coroutine only. */
|
||||||
|
private val lastHighPriorityBannerAt = HashMap<String, TimeMark>()
|
||||||
|
|
||||||
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
|
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
|
||||||
* unless the user is actively reading that lane right now (it is the
|
* unless the user is actively reading that lane right now (it is the
|
||||||
* current lane, the app is focused, and the newest content is at the
|
* current lane, the app is focused, and the newest content is at the
|
||||||
* bottom of the viewport). */
|
* bottom of the viewport). [messageId] dedupes redeliveries of the same
|
||||||
private fun noteIncomingAssistantMessage(lane: String) {
|
* frame (see [countedMessageIds]). */
|
||||||
|
private fun noteIncomingAssistantMessage(
|
||||||
|
lane: String,
|
||||||
|
messageId: String?,
|
||||||
|
) {
|
||||||
|
if (messageId != null && countedMessageIds[lane] == messageId) return
|
||||||
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
|
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
|
||||||
if (!beingRead) chat.markUnread(lane)
|
if (!beingRead) {
|
||||||
|
if (messageId != null) countedMessageIds[lane] = messageId
|
||||||
|
chat.markUnread(lane)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M8: the user is now viewing the current lane's newest content — clear
|
/** M8: the user is now viewing the current lane's newest content — clear
|
||||||
@@ -332,6 +355,11 @@ class IrisController(
|
|||||||
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
||||||
private const val MAX_BANNERS = 5
|
private const val MAX_BANNERS = 5
|
||||||
|
|
||||||
|
/** Window in which a high-priority banner suppresses the system
|
||||||
|
* notification for its accompanying message frame (mirrors the
|
||||||
|
* gateway's push-coalesce window, classify._PUSH_COALESCE_S). */
|
||||||
|
private const val BANNER_NOTIFY_SUPPRESS_MS = 5_000L
|
||||||
|
|
||||||
const val FONT_SCALE_MIN = 0.8f
|
const val FONT_SCALE_MIN = 0.8f
|
||||||
const val FONT_SCALE_MAX = 1.5f
|
const val FONT_SCALE_MAX = 1.5f
|
||||||
|
|
||||||
@@ -523,6 +551,13 @@ class IrisController(
|
|||||||
) {
|
) {
|
||||||
if (isAppForeground()) return
|
if (isAppForeground()) return
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
|
// A high-priority banner (cron/approval/clarify) for this lane just
|
||||||
|
// announced this delivery — don't stack a second notification for the
|
||||||
|
// accompanying message frame.
|
||||||
|
chatId?.let { cid ->
|
||||||
|
val mark = lastHighPriorityBannerAt[chat.laneKey(cid, threadId)]
|
||||||
|
if (mark != null && mark.elapsedNow().inWholeMilliseconds < BANNER_NOTIFY_SUPPRESS_MS) return
|
||||||
|
}
|
||||||
val id = chatId ?: "default"
|
val id = chatId ?: "default"
|
||||||
val chatName = channels.byId(id)?.name
|
val chatName = channels.byId(id)?.name
|
||||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||||
@@ -647,7 +682,7 @@ class IrisController(
|
|||||||
// content — count it as unread unless the
|
// content — count it as unread unless the
|
||||||
// user is reading this lane right now.
|
// user is reading this lane right now.
|
||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||||
}
|
}
|
||||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||||
@@ -660,7 +695,7 @@ class IrisController(
|
|||||||
if (it.role == ROLE_ASSISTANT) {
|
if (it.role == ROLE_ASSISTANT) {
|
||||||
// M8: a finalized (non-streaming) reply.
|
// M8: a finalized (non-streaming) reply.
|
||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||||
}
|
}
|
||||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||||
@@ -787,6 +822,15 @@ class IrisController(
|
|||||||
// sync replay) must not re-show the banner.
|
// sync replay) must not re-show the banner.
|
||||||
if (!alreadyConsumed) {
|
if (!alreadyConsumed) {
|
||||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||||
|
// Cron delivery = notification frame + message frame: remember
|
||||||
|
// that the banner announced this lane so the message frame
|
||||||
|
// doesn't post a second system notification.
|
||||||
|
if (p.kind in HIGH_PRIORITY_NOTIF_KINDS) {
|
||||||
|
p.chatId?.let { cid ->
|
||||||
|
lastHighPriorityBannerAt[chat.laneKey(cid, p.threadId)] =
|
||||||
|
TimeSource.Monotonic.markNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
// M5: the connection is live but the app is backgrounded — the
|
// M5: the connection is live but the app is backgrounded — the
|
||||||
// in-app banner is invisible, so mirror to a system
|
// in-app banner is invisible, so mirror to a system
|
||||||
// notification (the push backend only fires when
|
// notification (the push backend only fires when
|
||||||
|
|||||||
Reference in new issue
Block a user