Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8
This commit is contained in:
1 parent
acd5fb4ad0
commit
9f3f9842c8
11 files changed
+301
-98
No files matched your search
@@ -70,7 +70,14 @@ class GatewayClient(
|
||||
sealed interface State {
|
||||
data object Disconnected : State
|
||||
data object Connecting : State
|
||||
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
|
||||
data class Connected(
|
||||
val caps: ServerCaps,
|
||||
val channels: List<ChannelInfo>,
|
||||
/** M5: highest outbox cursor already pushed to this device
|
||||
* (from hello.ack; 0 = never). Sync-replayed frames at/below
|
||||
* it must not re-post system notifications (docs/08 §8.7). */
|
||||
val lastPushedCursor: Long = 0,
|
||||
) : State
|
||||
data object Reconnecting : State
|
||||
data class AuthFailed(val message: String) : State
|
||||
}
|
||||
@@ -275,7 +282,7 @@ class GatewayClient(
|
||||
helloAck.invokeOnCompletion { e ->
|
||||
if (e == null) {
|
||||
val ack = helloAck.getCompleted()
|
||||
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
||||
_state.value = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
|
||||
@@ -115,6 +115,11 @@ data class Frame(
|
||||
val type: String,
|
||||
@SerialName("chat_id") val chatId: String? = null,
|
||||
@SerialName("thread_id") val threadId: String? = null,
|
||||
/** Outbox cursor this frame was parked under. Set only on frames
|
||||
* replayed by `sync` (live frames carry none) — the app skips
|
||||
* re-notifying replayed frames with `cursor <= lastPushedCursor`
|
||||
* (they already woke the device via push, docs/08 §8.7). */
|
||||
val cursor: Long? = null,
|
||||
val payload: JsonElement = JsonObject(emptyMap()),
|
||||
) {
|
||||
/** Payload as a JSON object (the wire format); parse per-type with
|
||||
@@ -185,6 +190,10 @@ data class HelloAckPayload(
|
||||
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
||||
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
/** M5: highest outbox cursor already delivered to THIS device via the
|
||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
||||
)
|
||||
|
||||
// ── message (server -> app) ─────────────────────────────────────────────
|
||||
|
||||
@@ -136,6 +136,19 @@ class IrisController(
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||
|
||||
/** M5: highest outbox cursor already delivered to this device via the
|
||||
* push backend (from hello.ack; 0 = never). Sync-replayed frames with
|
||||
* `cursor <= lastPushedCursor` already woke the device via push, so the
|
||||
* app must not post a second system notification for them (docs/08
|
||||
* §8.7). In-memory: hello.ack refreshes it on every (re)connect. */
|
||||
@Volatile
|
||||
private var lastPushedCursor: Long = 0
|
||||
|
||||
/** True while [frame] is a sync replay that already reached the device
|
||||
* 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
|
||||
|
||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||
// Persisted (Settings → "Threads").
|
||||
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
|
||||
@@ -393,13 +406,15 @@ class IrisController(
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE_STOP -> {
|
||||
frame.payloadAs<MessageStopPayload>()?.let {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
if (!isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_MESSAGE -> {
|
||||
frame.payloadAs<MessagePayload>()?.let {
|
||||
if (it.role == ROLE_ASSISTANT) {
|
||||
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
}
|
||||
}
|
||||
@@ -493,8 +508,10 @@ class IrisController(
|
||||
// M5: WS is live but the app is backgrounded — the
|
||||
// in-app banner is invisible, so mirror to a system
|
||||
// notification (the push backend only fires when
|
||||
// there is no live subscriber).
|
||||
if (!isAppForeground()) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -540,6 +557,8 @@ class IrisController(
|
||||
prevState = s
|
||||
if (s is GatewayClient.State.Connected) {
|
||||
channels.setAll(s.channels)
|
||||
// M5: refresh the push-dedupe watermark (docs/08 §8.7).
|
||||
lastPushedCursor = s.lastPushedCursor
|
||||
val home = s.channels.firstOrNull { it.isDefault }?.chatId
|
||||
if (home != null) {
|
||||
_homeChannel.value = home
|
||||
|
||||
Reference in new issue
Block a user