# 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 `, `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. ` (needs the sepolicy patch) or bake `ro.helpdesk.` 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`.