From d70d70823036d64d4ec91992f6c1eb1e63e36444 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:34:20 +0100 Subject: [PATCH] [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. --- README.md | 201 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 165 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index cfb3447..67f712f 100644 --- a/README.md +++ b/README.md @@ -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://:/ws/alarm?alertId=…&token=…` | +| What | Path | +|---|---| +| actions | `{instance}/api/alarm/action/` (`{payload: …}` body) | +| auth | `{instance}/api/alarm/auth/` (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:` to the *dev -machine's* `127.0.0.1:` — 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=` + 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.