Files
alarm-client/README.md
T
2026-10-18 22:42:00 +01:00

110 lines
5.8 KiB
Markdown

# 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
```sh
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`:
```sh
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
```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
```
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`.