Files
iris_x_hermes/docs/08-push.md
T
ARIA 7a6d922d12
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s
Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
2026-08-22 22:43:13 +02:00

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_id whose 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_ACCOUNT JSON 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 in devices.db).
  • Payload:
    • notification {title, body} → system notification (tap → open app).
    • data {chat_id, thread_id, kind, message_id, cursor} → app uses to sync.
  • Data-only option: for background catch-up, send a data message (silent) so the app's FirebaseMessagingService wakes 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_TOPIC on NTFY_SERVER_URL (default https://ntfy.sh) via httpx POST, with an X-Title / X-Message / X-Tag / X-Priority and a JSON data attachment ({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.ack returns the current cursor; sync {cursor} replays rows with cursor > 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)

  1. App reconnects → hello → hello.ack {sync_cursor}.
  2. App sends sync {cursor: <last seen>}.
  3. Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers).
  4. Server sends sync.done {cursor: <new>}.
  5. 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.register the 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 sync over 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 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.