135 lines
6.6 KiB
Markdown
135 lines
6.6 KiB
Markdown
# 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: <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. |