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:
ARIA committed 2026-08-21 17:21:54 +02:00
1 parent acd5fb4ad0
commit 9f3f9842c8
11 files changed
+301 -98

No files matched your search

+9
View File
@@ -21,6 +21,10 @@ Every frame:
- `v` — protocol version (currently `1`). Server rejects unknown major versions.
- `id` — request id (client-chosen). Responses/acks echo it. Events have no `id`.
- `chat_id` / `thread_id` — top-level for convenience; may also be in `payload`.
- `cursor` — outbox cursor the frame was parked under. Present **only** on
frames replayed by `sync` (live frames carry none). The app compares it
against `last_pushed_cursor` from `hello.ack` to skip re-notifying frames
that already woke the device via push (`08-push.md` §8.7).
- Unknown `type`s are ignored (forward-compat); unknown `payload` fields ignored.
**Binary media frames** are not JSON. A media transfer is: one JSON header frame
@@ -36,9 +40,14 @@ Pairing succeeded.
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
"search":true,"push":"fcm","pickers":true},
"sync_cursor":1042,
"last_pushed_cursor":1040,
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
}}
```
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
woke the device via push (dedupe, `08-push.md` §8.7).
### `message`
A final / standalone message.
+31 -1
View File
@@ -102,4 +102,34 @@ persist until acted on.
short preview (privacy on lock screen). Full content is fetched via `sync`
over the authenticated WS.
- ntfy: use a **private topic + auth token** for any real trust boundary (hermes
ntfy adapter guidance).
ntfy adapter guidance).
## 8.8 Notification dedupe (push vs. sync)
A message sent while the device is offline is notified **twice** by naive
design: once by the push (FCM displays the `notification` payload), and again
when the app reconnects, syncs the outbox, and mirrors the replayed frames to
system notifications (the background-mirror path, §8.5). The fix is a per-
device push watermark:
- **Gateway** records the highest outbox cursor delivered to each device via
the push backend (`devices.last_pushed_cursor`, advanced only on a
*successful* send) and returns it in `hello.ack` as `last_pushed_cursor`.
- **Gateway** coalesces back-to-back pushes per chat (5 s window): a cron
delivery parks a notification frame AND a message frame, and only the first
pushes — the second reaches the app via sync (tap the first notification).
- **Gateway** tags every `sync`-replayed frame with its outbox cursor in the
frame envelope (`cursor`; live frames carry none).
- **App** skips system notifications for replayed frames with
`cursor <= lastPushedCursor` (they already woke the device). Live frames are
never suppressed — that is exactly the case where no push fired and the app
must notify itself.
- **App** FCM handler (`onMessageReceived`) posts nothing when the WS is
connected (the background-mirror path handles it) and nothing when the
message carried a `notification` payload (FCM already displayed it);
data-only messages are the exception (the app must display them itself).
Residual edge: FCM is at-least-once, so a lost device ack can still produce a
duplicate *system-displayed* notification (two `FCM-Notification:*` ids). The
designed evolution is the data-only push option (§8.2.1), which moves display
into the app and lets it use a stable per-message notification id.
+2
View File
@@ -12,6 +12,7 @@
"type": { "type": "string", "description": "Frame type (see frame_types)." },
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." },
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
"payload": { "type": "object", "description": "Type-specific payload." }
}
},
@@ -22,6 +23,7 @@
"payload": {
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
"sync_cursor": { "type": "integer" },
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
}
},