[docs](client): document single-origin delivery and the alarm contract

Record the one-origin transport and its four paths, the native alarm-clock delivery model and why notifications are not a fallback, the specialUse foreground service, the durable ack/offline check-in, alert endings and expiry, the cryptographic device id, and the QR payload rules. Also lists what is proven without hardware versus what still needs a phone.
This commit is contained in:
2026-10-19 02:34:20 +01:00
parent a375dd3db9
commit d70d708230
+165 -36
View File
@@ -10,38 +10,116 @@ react-navigation, heroUI Native + uniwind, async-storage, lucide-react-native.
## What it does ## What it does
- **Owner**: create an alert, show its QR code, hold-to-trigger it (1200ms + - **Owner**: create an alert, show its QR code, hold-to-trigger it (1200ms +
confirm), disarm it, rotate the subscribe token, revoke devices, delete it. confirm), disarm it, revoke a device's subscription, delete it.
- **Subscriber**: scan the QR (or paste the code by hand), subscribe this device, - **Subscriber**: scan the QR (or paste the code by hand), subscribe this device,
and get a full-screen takeover when the owner triggers — over the lock screen, and get a full-screen takeover when the owner triggers — over the lock screen,
at maximum alarm volume, with vibration. at maximum alarm volume, with vibration.
- **Live delivery** is a WebSocket (`/ws/alarm`) while the app is in the - **Live delivery** is a WebSocket (`/api/alarm/ws/alarm`) over the instance
foreground, plus an Android foreground service that keeps its own socket open origin: while the app is in the foreground, plus an Android foreground service
while the app is backgrounded. When the connection or proxy mode cannot carry that keeps its own socket open while the app is backgrounded. The socket is the
the socket, the client falls back to polling `alarmStatus` (5s foreground, alarm path. If it is not open, the app says the device is not listening — the
30s background) and says so in Settings → Live delivery. status poll is what reconciles the screen, and its one delivery duty is the
**durable** half below. A trigger that fires while this device is offline is
answered with `409 ERR_ALARM_TRIGGERED_OFFLINE` plus the frame, which the
client turns into the same takeover (late, not lost) — never into an error.
- **No auto-stop.** A triggered alarm rings until the person holding that phone
dismisses it, or the alert is disarmed/deleted. There is no timer, no
countdown and no server expiry; dismissing silences audio and vibration
locally first, and works for a subscriber without the owner doing anything.
- **No bucket quantization.** A trigger is `setAlarmClock(now + 250 ms)`: exact
scheduling with a small future margin, never rounded to a tick. A trigger in
second 30 starts the takeover in second 30 (socket p50 8 ms measured), not at
the top of the next minute.
- **Dismissal is an explicit ack.** `POST /action/ackAlarm` `{alertId, eventId,
deviceId}` is what retires this device's ledger row; the status check-in only
reads. The ack is persisted in a durable queue first and retried on reconnect,
foreground and a slow timer, so "dismissed while offline" converges instead of
ringing again later. A 404 on the ack means the alert already ended: settled,
dropped, never retried.
- **Alerts are one-shot and self-destructing.** The owner picks a lifetime
(1/3/7/14 days; service default 24 h, hard cap 14 days) and the alert —
definition, subscriptions, QR — is destroyed when every subscriber has acked,
when that window expires, or when the owner disarms it. All three are endings,
each with its own message; afterwards every action 404s. A 404 is never a missed
alarm: only a live WS `alarm` frame or a `409 ERR_ALARM_TRIGGERED_OFFLINE`
may start the takeover.
- **The QR payload is instance-relative.** The service mints
`/api/alarm/a/{alertId}#{token}` with no host, and the client renders
`{configuredOrigin}{qrPayload}`. The scanner matches path shape + fragment and
never host, so legacy `alarm.dcntrlized.org` codes and other instances' codes
keep working.
## Connection modes ## One instance, one origin
The client mirrors the house pattern in `src/services/connection.ts`: Product ruling: *"every packet goes through Maestro, and thus, only a single port
needs to ever be open … Direct service access should never happen."* So there is
no connection mode in this client, only an **instance address** (a hosting
concept: one origin, every service a path under it). All four paths are derived
from it in `src/services/instanceUrl.ts` / `connection.ts`:
| Mode | How it reaches the service | WebSocket | | What | Path |
|---|---|---| |---|---|
| `maestro` (default) | probe `/api/containers`, address the service through its `serviceRef`, actions at `POST {base}/api/actions/{serviceRef}/{action}` | **not available** — Maestro has no service WS passthrough, so the client polls `alarmStatus` and Settings says "live delivery unavailable in proxy mode" | | actions | `{instance}/api/alarm/action/<action>` (`{payload: …}` body) |
| `direct` | probe the service's `/_manifest` and talk to it directly | available: `ws://<host>:<port>/ws/alarm?alertId=…&token=…` | | auth | `{instance}/api/alarm/auth/<path>` (raw JSON body) |
| live socket | `ws(s)://{instance}/api/alarm/ws/alarm?alertId=…&token=…` |
| reachability probe | `GET {instance}/api/instance` (the instance's public profile) |
Dev host port for the service is **8088** (prod: `https://alarm.dcntrlized.org`). The previous `direct` mode (the service's own `/_manifest`, host port 8088) and
This app's **Metro** runs on **8089**. The two must never share a number: both the `maestro`/`serviceRef` lookup (`/api/containers`, `/api/actions/...`) are
are reached over `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev deleted with their `adb reverse` port juggling. The instance address is the one
machine's* `127.0.0.1:<port>` — one host socket, one owner. Per-client Metro thing the user configures; dev builds default to `http://127.0.0.1:8080`, which a
ports across the house: keepnote `8082`, hub `8084`, calendar `8086`, alarm USB phone reaches through `adb reverse tcp:8080 tcp:8080`.
`8089`; the service ports stay put (alarm `8088`, Maestro `8080`).
### Offline check-in — the durable half of delivery
`pg_notify` and the socket fan-out are not durable: a phone that is offline at
trigger time misses the frame, and the in-memory catch-up only lives in the
process that produced the event. The service therefore writes a **per-device
delivery ledger** row at trigger time for every enabled subscription
(`alarm.alarm_deliveries`), and `alarmStatus` is the check-in that settles it.
- Call `GET {instance}/api/alarm/action/alarmStatus?alertId=…&deviceId=<raw id>`
on reconnect/cold start. **Pass the same raw `deviceId` the app sent to
`joinAlert`** — the service hashes it before matching; without it the server
falls back to the caller's oldest subscription on that alert, so a
multi-device account can be told about the wrong device's debt.
- If this device still owes an alarm, the answer is **`409` with
`ERR_ALARM_TRIGGERED_OFFLINE`** and the alarm frame:
`{"error":{"code":"ERR_ALARM_TRIGGERED_OFFLINE","message":"…"},"alarm":
{"type":"alarm","alertId":"…","eventId":"…","action":"trigger","message":"…",
"serverTime":"…"}}`. **That is a status, not a failure — ring.** Render it
exactly like a live `trigger` frame (same takeover, same max volume, wake the
screen); a client that shows it as an error has failed to deliver the alarm.
- The server marks the ledger rows acknowledged *by answering*, so one
uninterrupted alarm produces one `409`; the call is idempotent and an ack for
something this device never owed is a no-op.
- The live path clears the same rows: the socket `ack` frame the app already
sends after a trigger marks them, so an online client is never also told
`409`. Do not drop the `ack` — it is the online half of the same ledger.
- A trigger that was already disarmed before the phone reconnects is not news:
the service clears the debt and returns a normal `active:false` status instead
of a stale ring.
**The socket replays the ledger too — that is the primary path.** On every
successful upgrade the service sends every trigger frame this subscription still
owes, before any live traffic, and marks nothing until the client acks. So the
whole path works with the JS layer dead: the foreground service holds the socket,
receives the frame and schedules the alarm clock — no native check-in, and
therefore no access token in the service, is needed. The `409` check-in is the
secondary path for a client that is polling without a socket. A replay and a
live `NOTIFY` can race, so the same `eventId` may arrive twice; acks are keyed by
`eventId` and the server clears idempotently, so it is a duplicate, not a fault.
This app's **Metro** runs on **8089**, so it never collides with the instance on
`8080` (per-client Metro ports across the house: keepnote `8082`, hub `8084`,
calendar `8086`, alarm `8089`).
## Getting started ## Getting started
```sh ```sh
bun install bun install
bun start # Metro on 127.0.0.1:8089 bun start # Metro on 127.0.0.1:8089
bash scripts/android.sh # adb reverse + build + install + launch bash scripts/android.sh # adb reverse :8080 + build + install + launch
``` ```
VS Code entrypoints (same scheme as the other clients): tasks `Start Metro`, VS Code entrypoints (same scheme as the other clients): tasks `Start Metro`,
@@ -69,22 +147,42 @@ Kotlin, under `android/app/src/main/java/com/dcntrlized/alarm/`:
| File | Job | | File | Job |
|---|---| |---|---|
| `AlarmPlayer.kt` | `MediaPlayer` on `USAGE_ALARM`/`STREAM_ALARM`, looped, volume forced to max and **read back**; `RingtoneManager` fallback; `VibrationEffect` waveform; auto-stop cap. Reports honestly whether the alarm stream was actually raised. | | `AlarmPlayer.kt` | `MediaPlayer` on `USAGE_ALARM`/`STREAM_ALARM`, looped, volume forced to max and **read back**; `RingtoneManager` fallback; `VibrationEffect` waveform. Also holds `AlarmNotifier`: the only two channels (`alarm_volume_warning`, `alarm_armed`) and the volume warning. Reports honestly whether the alarm stream was actually raised. |
| `AlarmNotifier.kt` | `alarm_trigger` (IMPORTANCE_HIGH, bypassDnd, PUBLIC, full-screen intent, "Raise your volume" action when the stream was blocked) and `alarm_armed` (IMPORTANCE_LOW) channels. | | `AlarmScheduler.kt` | The delivery mechanism: `AlarmManager.setAlarmClock` (the API the Clock app uses) for the takeover, plus a **standing** alarm-clock entry while an alert is armed. No notification fallback — an alarm is scheduled or the device reports it cannot deliver. |
| `AlarmForegroundService.kt` | Holds its **own** OkHttp WebSocket while armed (so a trigger lands with the app backgrounded), shows the persistent "Alarm armed" notification, and on a trigger plays the alarm and launches the takeover. | | `AlarmWakeReceiver.kt` | Restores the standing entry after a reboot and, on the standing wake, restarts the armed service. The wake doubles as the re-arm point if the platform stops the service. |
| `AlarmArmStore.kt` | Persists `alertId` + `wsUrl` so the boot/wake path knows what to reconnect to; cleared on stop. |
| `AlarmListeningState.kt` | Persists whether the armed socket is really listening (`running` / `idle` / `stopped-timeout` / `delivery-failed`) so a reopened app never shows a stale "armed". |
| `AlarmForegroundService.kt` | Holds its **own** OkHttp WebSocket while armed (so a trigger lands with the app backgrounded), shows the mandatory `alarm_armed` foreground-service notification (IMPORTANCE_MIN, silent), and on a trigger plays the alarm and schedules the takeover with `AlarmScheduler`. Runs as a `specialUse` foreground service (API 34+; `dataSync` on 29–33) and still implements `onTimeout()` as a guard. |
| `AlarmActivity.kt` | `ReactActivity` for the takeover: `showWhenLocked` + `turnScreenOn`, keyguard dismissal, back consumed, player stopped on destroy. | | `AlarmActivity.kt` | `ReactActivity` for the takeover: `showWhenLocked` + `turnScreenOn`, keyguard dismissal, back consumed, player stopped on destroy. |
| `AlarmModule.kt` / `AlarmPackage.kt` | RN bridge (`playAlarm`, `stopAlarm`, `startService`, `stopService`, `getPermissions`, `requestPermission`, `getLaunchAlert`, `openSoundSettings`, `openAppSettings`, `openFullScreenIntentSettings`, `openExactAlarmSettings`). | | `AlarmModule.kt` / `AlarmPackage.kt` | RN bridge (`playAlarm`, `stopAlarm`, `startService`, `stopService`, `getServiceState`, `getPermissions`, `requestPermission`, `getLaunchAlert`, `randomToken`, `openSoundSettings`, `openAppSettings`, `openFullScreenIntentSettings`, `openExactAlarmSettings`). |
The **escalation ladder** is deliberate and honest: max-volume `STREAM_ALARM` → **Delivery is an alarm, not a notification.** A trigger is turned into an
vibration → high-priority notification. If Android refuses the alarm stream (DND, `AlarmManager.setAlarmClock` entry that launches the takeover; the audio path is
volume locked, full-screen intent revoked), the app does not claim it forced the max-volume `STREAM_ALARM` → vibration. The only notification this app posts
volume — the takeover shows a warning strip with a **Raise volume** action, and about a firing alarm is the volume warning ("Alarm volume is at N% — raise it to
the notification carries the "Raise your volume" action too. maximum"), because that tells the user something true and actionable. The only
other notification is the foreground-service one Android requires while the
armed socket is held; it is on an IMPORTANCE_MIN, silent, badge-less channel.
There is deliberately no "you got a notification instead" path: a device that
cannot schedule exact alarms or cannot take over the screen is reported as
**unable to deliver a real alarm**, and the app refuses to arm or subscribe
rather than pretend.
The armed socket runs as a **`specialUse` foreground service** (with a
`PROPERTY_SPECIAL_USE_FGS_SUBTYPE` that states the real reason: a persistent
connection so a subscribed alert can be delivered in milliseconds). `specialUse`
is used instead of `dataSync` because `dataSync` is capped at 6 hours cumulative
per 24-hour window on Android 15+ — a budget re-arming cannot escape — and
`mediaPlayback` would only be honest while audio plays. It is available because
this app is not distributed through Play. `onTimeout()` is kept as a guard, the
standing alarm-clock wake re-arms the socket, and the UI says when listening
has stopped.
Manifest permissions: `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`, Manifest permissions: `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`,
`SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`, `VIBRATE`, `WAKE_LOCK`, `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`,
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC` + `WAKE_LOCK`,
`FOREGROUND_SERVICE_MEDIA_PLAYBACK`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` +
`FOREGROUND_SERVICE_DATA_SYNC` (API 29–33 only), `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`,
`CAMERA`, `INTERNET`. `CAMERA`, `INTERNET`.
## Verification ## Verification
@@ -92,18 +190,49 @@ Manifest permissions: `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`,
```sh ```sh
bunx tsc --noEmit # 0 errors bunx tsc --noEmit # 0 errors
bun run lint # 0 errors (25 react-native/no-inline-styles warnings) bun run lint # 0 errors (25 react-native/no-inline-styles warnings)
bun run test # 46 tests: QR payload parser, WS reconnect machine, time formatting bun run test # 121 tests across 13 suites (single-origin transport, subscriber rows,
# offline-trigger check-in, ended-alert/404 + durable ack queue, QR/join
# errors, WS reconnect, device id, readiness copy, native delivery + latency
# contract, local dismiss contract, time)
``` ```
The WebSocket client (`src/services/alarmSocket.ts`) is a pure module with an The WebSocket client (`src/services/alarmSocket.ts`) is a pure module with an
injectable socket factory, so its reconnect ramp (1s→2s→4s→8s→16s→30s cap, ±25% injectable socket factory, so its reconnect ramp (1s→2s→4s→8s→16s→30s cap, ±25%
jitter, reset on open) and frame parsing are tested headlessly. jitter, reset on open) and frame parsing are tested headlessly.
**Runtime behaviour still needs a device**: the lock-screen takeover, the volume **Proven without a device**: the native-delivery contract (only
ladder, vibration, haptics, camera capture, and every animation claim in `setAlarmClock` schedules a ring, no `startActivity` in the trigger path, no
`planning/service/alarm/04-android-client/01-alarm-ux.md` §8 are verified only by `setFullScreenIntent`, exactly two notification channels, `specialUse` + subtype
source reading and a compile, not by observation. in the manifest, and disarm keeps the standing entry) and the transport contract
(one instance origin, `/api/alarm/action|auth|ws` paths, the `{items}` list
unwrapped, the join error copy).
**Runtime behaviour still needs a phone**: that a live Maestro accepts the
WebSocket upgrade on `/api/alarm/ws/alarm`; that `setAlarmClock` lands in the
alarm-stream classification and `canUseFullScreenIntent()` still returns true
for a `USE_EXACT_ALARM` app on API 34–36; the lock-screen takeover; the real
volume ladder; the FGS start from an alarm broadcast and its re-arm after a
restart/`onTimeout`; notification visibility (source-level only here); vibration,
haptics, camera capture; and every animation claim in
`planning/service/alarm/04-android-client/01-alarm-ux.md` §8.
## License ## License
AGPL-3.0. New source files carry the header from `.license-header.tpl`. AGPL-3.0. New source files carry the header from `.license-header.tpl`.
### Dismiss = `ackAlarm`; endings are not triggers
Clearing is **ack-driven**: `alarmStatus` only *reports* debt (409 + frame) and
clears nothing, so a 409 lost in transit cannot forfeit the alarm. On dismiss,
send `POST /api/alarm/action/ackAlarm` with `{alertId, eventId, deviceId}` (raw
`deviceId`; the socket `ack` frame settles the same row). A repeat ack is a
no-op. When every device has acked, the alert is **destroyed** — `destroyed:
true` is success, not an error.
An alert also ends when the owner disarms it (triggered *or* armed — disarm of an
armed alert deletes it), when the owner deletes it, or when its owner-set
persistence window (`expiresInSeconds`, max 14 days) elapses. A destroyed alert
reads as `ERR_ALERT_NOT_FOUND` on every call **including `alarmStatus`**: treat
that as **ended**, never as "it fired" — the server will not replay a frame or
answer 409 for an alert that is gone. Client support for `ackAlarm` and the
shared 404-as-ended handling is in flight.