React Navigation paints every scene with its own theme background and defaults to the light one, so a screen that did not paint `bg` itself sat on white while the text tokens stayed light. Root-level background + a matching navigator theme means no screen can fall through to a light surface again, and the status bar icons are legible on the dark palette.
alarm-client
Android client for the Dcntrlized Alarm service: one person (the alert owner) can make other people's phones ring at maximum volume, live, in under a second.
Built as a bare React Native app (no Expo) with the same stack as
keepnote-client / calendar-client: RN 0.86, React 19, TypeScript,
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, 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 (
/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 with409 ERR_ALARM_TRIGGERED_OFFLINEplus 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
alarmframe or a409 ERR_ALARM_TRIGGERED_OFFLINEmay 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 legacyalarm.dcntrlized.orgcodes and other instances' codes keep working.
One instance, one origin
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:
| 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) |
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 rawdeviceIdthe app sent tojoinAlert— 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
409withERR_ALARM_TRIGGERED_OFFLINEand 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 livetriggerframe (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
ackframe the app already sends after a trigger marks them, so an online client is never also told409. Do not drop theack— 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:falsestatus 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
bun install
bun start # Metro on 127.0.0.1:8089
bash scripts/android.sh # adb reverse :8080 + build + install + launch
VS Code entrypoints (same scheme as the other clients): tasks Start Metro,
Setup adb reverse, Launch Android, Lint, Test, Typecheck,
Build Debug APK (arm64-v8a); launches Debug Maestro (with the reverse task)
and Debug Metro.
Gradle in a memory-constrained environment
The repo keeps org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m,
org.gradle.workers.max=2 and kotlin.daemon.jvmargs=-Xmx512m, and build
artifacts/daemons live inside the workspace so nothing needs $HOME:
cd android
GRADLE_USER_HOME=$PWD/../.gradle-home \
CCACHE_DIR=$PWD/../.ccache CCACHE_TEMPDIR=$PWD/../.ccache/tmp \
XDG_DATA_HOME=$PWD/../.xdg-data TMPDIR=$PWD/../.kotlin-tmp \
./gradlew :app:assembleDebug -PreactNativeArchitectures=arm64-v8a --console=plain
The native alarm engine (Android)
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. 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, getServiceState, getPermissions, requestPermission, getLaunchAlert, randomToken, openSoundSettings, openAppSettings, openFullScreenIntentSettings, openExactAlarmSettings). |
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, 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
bunx tsc --noEmit # 0 errors
bun run lint # 0 errors (25 react-native/no-inline-styles warnings)
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.
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.