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

12 KiB

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):

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 CallEventEmitterHelpdeskDispatcher 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 HelpdeskCommandServiceCommandChannelClient 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 500s 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.