Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it.
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, rotate the subscribe token, revoke devices, 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 pollingalarmStatus(5s foreground, 30s background) and says so in Settings → Live delivery.
Connection modes
The client mirrors the house pattern in src/services/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=… |
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).
Getting started
bun install
bun start # Metro on 127.0.0.1:8089
bash scripts/android.sh # adb reverse + 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; 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. |
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). |
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.
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,
CAMERA, INTERNET.
Verification
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
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.
License
AGPL-3.0. New source files carry the header from .license-header.tpl.