# 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).