6.5 KiB
08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. Decision: FCM primary, ntfy fallback (IRIS_PUSH_BACKEND).
8.1 When push fires
- A frame targets a
chat_idwhose device is disconnected (WS closed) → drop to outbox + fire push. - Also fire push for high-priority foreground events the user should see even if the app is backgrounded (approvals, clarifies, cron completions) — the app decides whether to also show an in-app banner.
- If the device is connected, no push (the live frame is enough).
8.2 PushBackend interface (push.py)
class PushBackend(Protocol):
name: str
async def send(self, *, device_id: str, chat_id: str,
title: str, body: str,
data: dict) -> bool: ...
def configured(self) -> bool: ...
Selected at adapter init by IRIS_PUSH_BACKEND (fcm default, ntfy).
8.2.1 FcmBackend (primary)
- FCM HTTP v1 API via
httpx(core dep). Auth = Firebase service account (IRIS_FCM_SERVICE_ACCOUNTJSON path) → mint a short-lived OAuth2 access token (cached, refreshed before expiry). - Fallback: legacy server key (
IRIS_FCM_SERVER_KEY) if no service account (simpler, but legacy). - Target = the device's FCM token (registered via
hello/fcm.register, stored indevices.db). - Payload:
notification {title, body}→ system notification (tap → open app).data {chat_id, thread_id, kind, message_id, cursor}→ app uses tosync.
- Data-only option: for background catch-up, send a data message (silent) so
the app's
FirebaseMessagingServicewakes a foreground service and syncs without a visible notification (used for non-urgent updates). - Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few devices).
8.2.2 NtfyBackend (fallback, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to
NTFY_TOPIConNTFY_SERVER_URL(defaulthttps://ntfy.sh) viahttpxPOST, with anX-Title/X-Message/X-Tag/X-Priorityand a JSONdataattachment ({chat_id, thread_id, kind, cursor}). - The app subscribes to the topic (via an ntfy client lib or a lightweight listener) to receive pushes without Firebase.
- Best for users who self-host ntfy and want zero Firebase.
8.3 Outbox (outbox.py)
- SQLite
outbox.db: rows{cursor (monotonic), chat_id, thread_id, frame_json, ts, delivered}. - Write on every outbound frame that has no live subscriber (or always, then mark delivered on live send — simpler + crash-safe).
- Retention:
outbox_retention_hours(default 72h); prune on write. - Cursor: global monotonic int;
hello.ackreturns the current cursor;sync {cursor}replays rows withcursor > given. - Bounded: if the outbox grows past a cap (e.g. 5k rows), oldest are pruned and
a
notification {kind:"generic", body:"Older messages pruned"}is sent.
8.4 Sync (reconnect catch-up)
- App reconnects →
hello→hello.ack {sync_cursor}. - App sends
sync {cursor: <last seen>}. - Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers).
- Server sends
sync.done {cursor: <new>}. - App updates its local cursor (Room) — idempotent (dedupe by
message_id).
This makes the app eventually consistent across disconnects, restarts, and gateway restarts.
8.5 App-side push handling (Android)
FirebaseMessagingService.onMessageReceived(foreground): show in-app banner + optionally sync.onNewToken→fcm.registerthe new token.- Background data message → start a foreground service (low-priority,
notification channel "Sync") → open WS →
sync→ stop. - Notification channels (Android 8+): one per chat so cron channels can have their own style/priority (e.g. "Cron Reports" = high, "Default" = default). Tapping a notification deep-links to the chat/thread.
- ntfy mode: a foreground service maintains the ntfy subscription; incoming events trigger the same sync path.
8.6 In-app banners (foreground)
notification frames (e.g. channel_renamed, approval, clarify, cron)
render as a transient banner above the composer (like the reference screenshot's
"ARIA hat Thema … umbenannt"). Dismissible; high-priority ones (approval/clarify)
persist until acted on.
8.7 Security
- FCM tokens are per-device, revocable; stored only in
devices.db. - Push payloads carry no secrets and no full message bodies larger than a
short preview (privacy on lock screen). Full content is fetched via
syncover the authenticated WS. - ntfy: use a private topic + auth token for any real trust boundary (hermes 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 inhello.ackaslast_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 anotificationpayload (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.