110 lines
5.8 KiB
Markdown
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`.
|