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
@@ -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
@@ -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.
|
||||
@@ -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" } }
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user