fix(app): don't re-show notification banners on app reopen
CI / Gateway plugin tests (push) Successful in 6m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m44s

The persisted sync cursor only advanced on sync.done, so on every
restart the event stream re-delivered all frames consumed since the
last sync.done - and replayed notification frames re-showed their
banner (e.g. 'thread deleted' again after close/reopen).

Track the consumed outbox high-water cursor in the controller,
persist it (debounced + on backgrounding), and skip notification
frames whose cursor is at/below the mark (also covers the duplicate
delivery of SSE catch-up + explicit sync replay on reconnect).
This commit is contained in:
ARIA committed 2026-08-23 01:42:55 +02:00
1 parent 6458c3183c
commit abbed438ec
1 file changed
+67 -13
@@ -82,6 +82,7 @@ import iris.ui.theme.UserTheme
import iris.util.IrisLog import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -209,6 +210,37 @@ class IrisController(
* via push (live frames carry no cursor and are never suppressed). */ * via push (live frames carry no cursor and are never suppressed). */
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
/** M5: highest outbox cursor this device has CONSUMED (applied from the
* event stream). The stream resumes from [store.syncCursor] on every
* (re)connect, so this high-water mark is persisted there (debounced +
* on backgrounding): without it, a restart re-delivers every frame
* consumed since the last `sync.done` — and replayed `notification`
* frames re-showed their banner on every app reopen (docs/08 §8.4).
* @Volatile: written on the frame-handler coroutine, read on the
* foreground/state collectors. */
@Volatile
private var consumedCursor: Long = store.syncCursor
/** Debounced persist of [consumedCursor] (one store write per burst,
* not per frame). */
private var cursorSaveJob: Job? = null
private fun persistCursorDebounced() {
cursorSaveJob?.cancel()
cursorSaveJob =
scope.launch {
delay(CACHE_SAVE_DEBOUNCE_MS)
// maxOf: sync.done may have advanced the store past us.
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
}
}
/** Flush [consumedCursor] to the store now (app backgrounding / exit). */
private fun persistCursorNow() {
cursorSaveJob?.cancel()
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
}
// ── M3: threads toggle (per-app for now; per-channel lands later) ───── // ── M3: threads toggle (per-app for now; per-channel lands later) ─────
// Persisted (Settings → "Threads"). // Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled) private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
@@ -507,12 +539,23 @@ class IrisController(
// clears on scroll-to-bottom while already focused.) // clears on scroll-to-bottom while already focused.)
scope.launch { scope.launch {
foreground.collect { fg -> foreground.collect { fg ->
if (!fg) persistCursorNow()
if (fg && currentLaneAtBottom) markCurrentLaneRead() if (fg && currentLaneAtBottom) markCurrentLaneRead()
} }
} }
scope.launch { scope.launch {
client.events.collect { frame -> client.events.collect { frame ->
try { try {
// Consume the frame's outbox cursor BEFORE dispatching:
// a frame that arrives a second time (SSE catch-up and
// the explicit sync replay both carry the cursor) must
// not re-notify — only its first consumption may.
val cursor = frame.cursor
val alreadyConsumed = cursor != null && cursor <= consumedCursor
if (cursor != null && cursor > consumedCursor) {
consumedCursor = cursor
persistCursorDebounced()
}
when (frame.type) { when (frame.type) {
TYPE_TOOL_START, TYPE_TOOL_START,
TYPE_TOOL_PROGRESS, TYPE_TOOL_PROGRESS,
@@ -562,7 +605,7 @@ class IrisController(
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
} }
if (!isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
} }
} }
@@ -575,7 +618,7 @@ class IrisController(
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
} }
if (!isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
} }
} }
@@ -649,8 +692,13 @@ class IrisController(
TYPE_SYNC_DONE -> { TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the // Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox). // cursor is authoritative server-side (outbox). Keep the
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor } // consumed high-water mark in step (it may be ahead of
// us — live frames consumed after the replay started).
frame.payloadAs<SyncDonePayload>()?.let {
consumedCursor = maxOf(consumedCursor, it.cursor)
store.syncCursor = maxOf(store.syncCursor, it.cursor)
}
} }
TYPE_HISTORY -> { TYPE_HISTORY -> {
@@ -689,15 +737,21 @@ class IrisController(
TYPE_NOTIFICATION -> { TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p -> frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) // alreadyConsumed: this frame was applied earlier
// M5: WS is live but the app is backgrounded — the // (its cursor is at/below the high-water mark) —
// in-app banner is invisible, so mirror to a system // a duplicate delivery (restart catch-up or the
// notification (the push backend only fires when // sync replay) must not re-show the banner.
// there is no live subscriber). Suppressed for if (!alreadyConsumed) {
// sync replays that already woke the device via pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// push (docs/08 §8.7). // M5: WS is live but the app is backgrounded — the
if (!isAppForeground() && !isPushedReplay(frame)) { // in-app banner is invisible, so mirror to a system
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId) // notification (the push backend only fires when
// there is no live subscriber). Suppressed for
// sync replays that already woke the device via
// push (docs/08 §8.7).
if (!isAppForeground() && !isPushedReplay(frame)) {
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
}
} }
} }
} }