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.
195 lines
12 KiB
Markdown
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`.
|