[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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user