[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
- **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,
and get a full-screen takeover when the owner triggers — over the lock screen,
at maximum alarm volume, with vibration.
- **Live delivery** is a WebSocket (`/ws/alarm`) while the app is in the
foreground, plus an Android foreground service that keeps its own socket open
while the app is backgrounded. When the connection or proxy mode cannot carry
the socket, the client falls back to polling `alarmStatus` (5s foreground,
30s background) and says so in Settings → Live delivery.
- **Live delivery** is a WebSocket (`/api/alarm/ws/alarm`) over the instance
origin: while the app is in the foreground, plus an Android foreground service
that keeps its own socket open while the app is backgrounded. The socket is the
alarm path. If it is not open, the app says the device is not listening — the
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 |
|---|---|---|
| `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" |
| `direct` | probe the service's `/_manifest` and talk to it directly | available: `ws://<host>:<port>/ws/alarm?alertId=…&token=…` |
| What | Path |
|---|---|
| actions | `{instance}/api/alarm/action/<action>` (`{payload: …}` body) |
| 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`).
This app's **Metro** runs on **8089**. The two must never share a number: both
are reached over `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev
machine's* `127.0.0.1:<port>` — one host socket, one owner. Per-client Metro
ports across the house: keepnote `8082`, hub `8084`, calendar `8086`, alarm
`8089`; the service ports stay put (alarm `8088`, Maestro `8080`).
The previous `direct` mode (the service's own `/_manifest`, host port 8088) and
the `maestro`/`serviceRef` lookup (`/api/containers`, `/api/actions/...`) are
deleted with their `adb reverse` port juggling. The instance address is the one
thing the user configures; dev builds default to `http://127.0.0.1:8080`, which a
USB phone reaches through `adb reverse tcp:8080 tcp: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
```sh
bun install
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`,
@@ -69,22 +147,42 @@ Kotlin, under `android/app/src/main/java/com/dcntrlized/alarm/`:
| 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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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` →
vibration → high-priority notification. If Android refuses the alarm stream (DND,
volume locked, full-screen intent revoked), the app does not claim it forced the
volume — the takeover shows a warning strip with a **Raise volume** action, and
the notification carries the "Raise your volume" action too.
**Delivery is an alarm, not a notification.** A trigger is turned into an
`AlarmManager.setAlarmClock` entry that launches the takeover; the audio path is
max-volume `STREAM_ALARM` → vibration. The only notification this app posts
about a firing alarm is the volume warning ("Alarm volume is at N% — raise it to
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`,
`SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`, `VIBRATE`, `WAKE_LOCK`,
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC` +
`FOREGROUND_SERVICE_MEDIA_PLAYBACK`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`,
`SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`,
`WAKE_LOCK`,
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SPECIAL_USE` +
`FOREGROUND_SERVICE_DATA_SYNC` (API 29–33 only), `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`,
`CAMERA`, `INTERNET`.
## Verification
@@ -92,18 +190,49 @@ Manifest permissions: `POST_NOTIFICATIONS`, `USE_FULL_SCREEN_INTENT`,
```sh
bunx tsc --noEmit # 0 errors
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
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.
**Runtime behaviour still needs a device**: the lock-screen takeover, the volume
ladder, vibration, haptics, camera capture, and every animation claim in
`planning/service/alarm/04-android-client/01-alarm-ux.md` §8 are verified only by
source reading and a compile, not by observation.
**Proven without a device**: the native-delivery contract (only
`setAlarmClock` schedules a ring, no `startActivity` in the trigger path, no
`setFullScreenIntent`, exactly two notification channels, `specialUse` + subtype
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
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.