Prd/docs/phone-connectivity.md
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

195 lines
12 KiB
Markdown

# Phone ↔ backend connectivity
How the patched GrapheneOS Dialer (the office Pixel) finds the backend, authenticates, and talks to
it. The wire shapes live in `docs/device-api.md`; this page is the device side: discovery, the request
channels, presence, lifecycle, and troubleshooting. Class names refer to the
`com.android.dialer.helpdesk` overlay in `components/dialer-patch`.
## Two trust planes
| Plane | Who | Auth | Transport |
|-------|-----|------|-----------|
| Device (this page) | the office Pixel | HMAC (device token + secret) | production: `https://moje.al.army:8443` (TLS, no client cert). dev: plain `http://` on the LAN |
| Operator | the console browser | vmin client cert (mTLS) | `https://moje.al.army:443` |
The phone holds no certificate; it signs each request with HMAC-SHA256. In production the TLS door
adds transport encryption on top - the phone posts over the public internet, so HMAC alone would leave
the audio readable on the wire. On a dev LAN, plain http with HMAC is the normal setup.
## Finding the backend (`base_url`)
`HelpdeskConfig.baseUrl()` resolves the backend URL in precedence order - first hit wins:
1. `persist.helpdesk.base_url` - runtime-settable system property, survives reboot. The normal knob;
the production phone is pinned this way to `https://moje.al.army:8443/api/v1`.
2. `ro.helpdesk.base_url` - baked at build time (product `build.prop`).
3. mDNS discovery - `HelpdeskDiscovery` browses `_helpdesk._tcp` on the current WiFi and returns e.g.
`http://192.168.87.234:4000/api/v1`. Dev convenience; production doesn't rely on it.
4. Dev default `http://10.0.2.2:4000/api/v1` (emulator loopback).
Set the pin (the `/api/v1` suffix is part of the URL, don't drop it):
```bash
adb shell setprop persist.helpdesk.base_url https://moje.al.army:8443/api/v1
adb reboot # properties set after zygote start aren't visible to already-running apps
```
Two gotchas that both end in "the pin silently does nothing":
- SELinux: custom properties default to a context `priv_app` can't read. The build includes
`components/sepolicy-patch` (a `helpdesk_prop` context for `persist.helpdesk.*` / `ro.helpdesk.*`);
without it the Dialer never sees the value and falls through to discovery.
- Cleartext: the manifest sets `android:usesCleartextTraffic="true"` (patch 0006) so plain-http dev
URLs work at all. Without it, `HttpURLConnection` refuses http before opening a socket - `status=0`,
zero packets, no crash, while a raw `nc` to the same port connects fine. This only matters for http
URLs; the production https door doesn't need it.
## Device authentication
Every request carries `Authorization: Bearer <token>`, `X-Request-Ts`, `X-Request-Nonce` and
`X-Signature` (HMAC-SHA256 over `ts.nonce.body`), built by `DeviceAuthSigner`. The full recipe,
including the server's skew and replay windows, is in `docs/device-api.md`.
Credentials resolve like `base_url` (`persist.helpdesk.device_token` / `device_secret` /
`device_id`). On a production (`user`) build, `HelpdeskConfig.provisioned()` fails closed: an
unprovisioned device refuses to authenticate with the repo-public dev defaults. Dev/`userdebug` builds
keep the defaults so a fresh flash talks to a local server out of the box.
## The five request channels
| Channel | Component | Request | When |
|---------|-----------|---------|------|
| Call webhooks | `CallEventEmitter``HelpdeskDispatcher` | `POST /calls/incoming` · `/outgoing` · `/answered` · `/ended` | on the call lifecycle |
| Recording upload | `HelpdeskRecordingBridge` | `PUT /recordings/{uuid}` + `X-Call-Id` | when a recording finalizes |
| Reverse commands | `HelpdeskCommandService``CommandChannelClient` | `GET /device/commands?wait=25` (long-poll) | always - 24/7 foreground service |
| Contacts sync | `ContactsSyncer` | `POST /device/contacts` | on InCallService bind, ≤ once/30 min |
| Heartbeat | `HelpdeskHeartbeat` | `GET /device/heartbeat` | every ~2 min (AlarmManager) |
Webhooks go through `HelpdeskDispatcher`: one background thread draining a head-of-line queue with
capped exponential backoff (1 s → 60 s), so events arrive in order and never block the ring path. The
queue is in-memory; a webhook the process dies holding is lost, and the server's reconciler is what
catches those.
The recording bridge streams the finalized MediaStore file in two passes (HMAC over the bytes, then
the chunked body), 3 attempts with the same `recording_uuid` so server-side retries are idempotent.
The reverse-command poller deserves its own paragraph because it moved once already. It used to live
in the InCallService, which Telecom only binds during a call - so a click-to-dial command issued while
the phone was idle sat undrained forever. It now lives in `HelpdeskCommandService`, an always-on
foreground service started at boot and kept sticky; it owns the single process-wide poller, and the
in-call poller is gone (two pollers would race on a destructive drain). Commands are validated and
deduplicated by `CommandGate`, applied through the same Telecom calls the on-screen buttons use, and
the resulting state flows back through the ordinary webhooks.
## Presence
The console shows "phone connected" when the device polled commands or heartbeated in the last 300
seconds. With the always-on poller the long-poll itself keeps presence fresh; the AlarmManager
heartbeat is the belt-and-suspenders signal that survives the service being killed and recreated. On a
fresh backend the badge stays grey until the first poll or beat arrives.
## Lifecycle: where it all starts
At boot, a `BOOT_COMPLETED` receiver arms the heartbeat alarm and starts `HelpdeskCommandService`
(note: the phone must be unlocked once after reboot for the receiver to fire). When Telecom binds the
InCallService for a call, the patch wires the rest: discovery, the `CallEventEmitter` listener, the
recording bridge, a throttled contacts sync, and an idempotent "make sure the command service runs".
The emitter and recorder are call-scoped; the command poller and heartbeat persist between calls.
## Config keys
Set at runtime with `adb shell setprop persist.helpdesk.<key> <value>` (needs the sepolicy patch) or
bake `ro.helpdesk.<key>` into the build.
| Property | Purpose |
|----------|---------|
| `persist.helpdesk.base_url` / `ro.helpdesk.base_url` | backend URL including `/api/v1` |
| `persist.helpdesk.device_token` / `ro.…` | HMAC bearer token |
| `persist.helpdesk.device_secret` / `ro.…` | HMAC secret |
| `persist.helpdesk.device_id` / `ro.…` | device id (presence + command routing) |
| `persist.helpdesk.rec_bitrate` / `ro.…` | AAC bitrate in bits/sec. Unset keeps the stock 16000, which is about one bit per sample and mangles ordinary words before Whisper sees them |
| `persist.helpdesk.rec_format` / `ro.…` | `0` AAC (stock), `1` AMR-WB, `2` lossless WAV. Unset keeps the device's own preference |
| `persist.helpdesk.rec_probe_sources` / `ro.…` | `1` logs whether the two call legs can be captured separately. Off by default; see below |
| `persist.helpdesk.rec_dual_leg` / `ro.…` | `1` records the two call legs as separate channels (left operator, right caller) instead of one mixed stream. Off by default; falls back automatically if the device refuses |
Check what the app will actually use: `adb shell getprop | grep helpdesk`.
## Tuning recording quality
Transcription accuracy is capped by what the phone records, and the only way to judge a setting is to
place a real call and score the transcript. These are properties rather than build constants so one
image can test every candidate without a rebuild:
```
adb shell setprop persist.helpdesk.rec_bitrate 96000 # better AAC, ~6x the stock bits
adb shell setprop persist.helpdesk.rec_format 2 # lossless WAV, ~16x the bytes
adb shell setprop persist.helpdesk.rec_bitrate "" # back to stock
```
Then place a call, pull the recording, and score it with `components/transcription-worker/bench/ab.rb`.
Mind the size: WAV is about 1.9 MB per minute against the 64 MB upload cap, so a call over roughly 33
minutes would be refused. AAC at 96000 is about 0.7 MB per minute.
`rec_probe_sources=1` is a one-off diagnostic, not a feature. It logs whether `VOICE_UPLINK` and
`VOICE_DOWNLINK` can be opened on this device:
```
adb shell setprop persist.helpdesk.rec_probe_sources 1
adb logcat -s BaseCallRecorder:I | grep "source probe"
```
`AVAILABLE` on both means the two call legs could be recorded as separate channels, which would make
caller and callee exactly separable and remove the need for a diariser to guess. Google Dialer does
exactly that. `REFUSED` or `uninitialised` means the audio HAL or sepolicy will not allow it here and
that approach is closed off.
Note the probe opens the sources **one at a time**. Whether both can be held open SIMULTANEOUSLY is a
separate question and the one that actually matters, which only `rec_dual_leg` answers.
## Recording the legs separately
```
adb shell setprop persist.helpdesk.rec_dual_leg 1
adb shell setprop persist.helpdesk.rec_format 2 # WAV: the channels must survive intact
```
Left channel is the operator (uplink), right is the caller (downlink). This is worth having because it
makes speaker attribution exact instead of something a diariser infers from two similar voices on a
narrowband line.
It fails safe. If the audio HAL will not give both legs at once, `DualLegCapture.open` returns null and
recording carries on from the mixed source exactly as before - the log says which path is in use:
```
adb logcat -s HelpdeskDualLeg:*
both call legs open at 16000 Hz; recording in stereo <- working
falling back to the mixed source <- not supported here
```
The backend splits the stereo file and transcribes each channel separately
(`lib/helpdesk/wav_split.rb`). That step is not optional: Whisper downmixes to mono before decoding, so
a stereo file handed over whole would put both people back into one channel and waste the effort.
## Troubleshooting
Logcat tags: `HelpdeskDiscovery` · `HelpdeskDispatcher` · `HelpdeskCmdSvc` · `HelpdeskCmd` ·
`HelpdeskRecordingBridge` · `HelpdeskContacts` · `HelpdeskHeartbeat` · `CallEventEmitter` ·
`CallRecorderServiceV2`.
| Symptom | Likely cause | Check / fix |
|---------|--------------|-------------|
| `status=0`, zero packets, no crash | cleartext http blocked by the manifest | dev URLs need patch 0006's `usesCleartextTraffic`; https URLs are unaffected |
| pin set but ignored | property context unreadable, or set after zygote start | sepolicy patch present? then `adb reboot` |
| `401` on every request | token/secret mismatch, or clock skew > 300 s | `getprop \| grep helpdesk`; check the phone's NTP |
| `404` from the production door | path outside the device allowlist | only `/api/v1/calls/`, `/device/`, `/recordings/` exist on :8443 |
| `413` on upload | recording bigger than the door's 64 MB cap | check the recording length and `rec_bitrate`/`rec_format` |
| console stuck "waiting for the phone" | command service not running (e.g. never unlocked after reboot) | `HelpdeskCmdSvc` in logcat; unlock the phone once |
| console stuck "waiting" but `HelpdeskHeartbeat` shows the phone IS reaching the server | backend `500`s the device GETs, so neither presence nor the command drain land | backend log shows `heartbeat -> status=500`; a body-size guard read `req.content_length`, which raises on a GET with no Content-Length. Fixed in `server.rb` `read()` (`req["content-length"].to_i`) |
| recording captured but not uploaded | transient PUT failure | the bridge logs each of its 3 attempts |
| mDNS resolves but unreachable (dev) | phone latched onto an IPv6 AAAA | backend's avahi: `publish-aaaa-on-ipv4=no` |
## Re-flashing without losing the pin
`fastboot flashall` without `-w` preserves `/data` - WiFi credentials, adb keys, and the
`persist.helpdesk.*` pin all survive a Dialer rebuild and reflash. Only a `-w` wipe (or the initial
unlock) resets them; then re-pin and rejoin WiFi. See `docs/runbooks/pixel10a-flash-manual.md`.