Admin a78c4cea14 [feat](client): show what stands between this phone and a real alarm
The blocking grants (exact alarms, full-screen intent, notifications) and the advisory battery exemption are surfaced as readiness, not as warnings that still allow arming. Listening state is read from the persisted native record on resume so a process-killed service cannot leave a stale armed claim on screen.
2026-10-19 02:34:02 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00
2026-10-18 22:42:00 +01:00

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 polling alarmStatus (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.

S
Description
No description provided
Readme AGPL-3.0
464 KiB
Languages
TypeScript 82.8%
Kotlin 15.9%
JavaScript 0.3%
Ruby 0.3%
Swift 0.3%
Other 0.3%