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.
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:
persist.helpdesk.base_url- runtime-settable system property, survives reboot. The normal knob; the production phone is pinned this way tohttps://moje.al.army:8443/api/v1.ro.helpdesk.base_url- baked at build time (productbuild.prop).- mDNS discovery -
HelpdeskDiscoverybrowses_helpdesk._tcpon the current WiFi and returns e.g.http://192.168.87.234:4000/api/v1. Dev convenience; production doesn't rely on it. - 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_appcan't read. The build includescomponents/sepolicy-patch(ahelpdesk_propcontext forpersist.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,HttpURLConnectionrefuses http before opening a socket -status=0, zero packets, no crash, while a rawncto 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 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.