# 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** (`ANDROID_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`) ```python 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 `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`). ### 8.2.1 `FcmBackend` (primary) - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived OAuth2 access token (cached, refreshed before expiry). - Fallback: legacy **server key** (`ANDROID_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: }`. 3. Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers). 4. Server sends `sync.done {cursor: }`. 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.