Prd/components/dialer-patch/overlay/java/com/android/dialer/helpdesk/CommandChannelClient.java
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

215 lines
9.2 KiB
Java

/*
* Helpdesk Dialer patch — reverse command channel client (PC console -> device).
* Part of the com.android.dialer.helpdesk overlay (see components/dialer-patch).
*/
package com.android.dialer.helpdesk;
import android.os.Handler;
import android.os.Looper;
import com.android.dialer.helpdesk.command.CommandGate;
import com.android.dialer.helpdesk.command.CommandGate.CallPhase;
import com.android.dialer.helpdesk.command.CommandGate.Decision;
import com.android.dialer.helpdesk.http.DeviceAuthSigner;
import com.android.dialer.helpdesk.http.HelpdeskHttpClient;
import com.android.incallui.call.CallList;
import com.android.incallui.call.DialerCall;
import com.android.incallui.call.TelecomAdapter;
import com.android.incallui.call.state.DialerCallState;
import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Polls the backend's reverse channel ({@code GET /device/commands}) and applies operator commands to
* the live call via the same Telecom paths the on-screen buttons use. This is the "accept/mute/hold
* from the PC console" device half. Validation + idempotency + monotonic-drop are the verified
* {@link CommandGate}; the transport is a long-poll (a push channel could replace it later). Commands
* are applied on the main thread (Telecom UI thread); the poll runs on a daemon.
*/
public final class CommandChannelClient {
// Long-poll: the backend holds GET /device/commands up to LONG_POLL_S and returns the instant a command
// is enqueued, so a PC command (esp. mute/hangup) applies in ~1 RTT instead of up to a poll interval.
// The read timeout MUST exceed LONG_POLL_S or the client aborts its own idle poll. On a failed poll
// (server down / timeout) we back off ERROR_BACKOFF_MS so we don't hot-loop.
private static final long LONG_POLL_S = 25;
private static final int READ_TIMEOUT_MS = 30000; // > LONG_POLL_S * 1000
private static final long ERROR_BACKOFF_MS = 2000;
private final android.content.Context appContext;
private final CallIdRegistry registry;
private final HelpdeskHttpClient client;
private final CommandGate gate = new CommandGate();
private final Handler main = new Handler(Looper.getMainLooper());
private volatile boolean running = true;
public CommandChannelClient(android.content.Context context, CallIdRegistry registry) {
this.appContext = context.getApplicationContext();
this.registry = registry;
DeviceAuthSigner signer = new DeviceAuthSigner(HelpdeskConfig.deviceToken(), HelpdeskConfig.deviceSecret());
this.client = new HelpdeskHttpClient(HelpdeskConfig::baseUrl, signer).timeouts(8000, READ_TIMEOUT_MS);
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "helpdesk-cmd");
t.setDaemon(true);
return t;
}).execute(this::pollLoop);
}
public void stop() {
running = false;
}
private void pollLoop() {
String path = "/device/commands?device_id=" + HelpdeskConfig.deviceId() + "&wait=" + LONG_POLL_S;
while (running) {
HelpdeskHttpClient.Response resp = client.send("GET", path, null);
if (resp.status == 200 && resp.body != null) {
drain(resp.body);
// long-poll already absorbed the idle wait server-side; loop straight back for the next command.
} else {
try {
Thread.sleep(ERROR_BACKOFF_MS); // poll failed — pause before retry so we don't hot-loop
} catch (InterruptedException e) {
return;
}
}
}
}
private void drain(String body) {
try {
JSONArray cmds = new JSONObject(body).optJSONArray("commands");
if (cmds == null) {
return;
}
for (int i = 0; i < cmds.length(); i++) {
JSONObject c = cmds.getJSONObject(i);
String id = c.optString("id", null);
String callId = c.optString("call_id", null);
String verb = c.optString("verb", null);
String arg = c.isNull("arg") ? null : c.optString("arg", null);
main.post(() -> apply(id, callId, verb, arg));
}
} catch (Exception ignored) {
// malformed poll payload — skip; the next poll recovers.
}
}
private void apply(String id, String callId, String verb, String arg) {
DialerCall call = resolve(callId);
CallPhase phase = phaseOf(call);
Decision decision = gate.decide(id, verb, arg, phase);
android.util.Log.i("HelpdeskCmd", "apply verb=" + verb + " arg=" + arg + " callId=" + callId
+ " resolved=" + (call != null) + " state=" + (call != null ? call.getState() : -1)
+ " phase=" + phase + " decision=" + decision);
if (decision != Decision.APPLY) {
return;
}
TelecomAdapter tel = TelecomAdapter.getInstance();
try {
switch (verb) {
case "answer":
if (call != null) {
android.util.Log.i("HelpdeskCmd", "answer -> call.answer() on state=" + call.getState());
call.answer();
// Helpdesk patch: accepting from the PC must wake the Pixel and bring up its (landscape)
// call screen so the operator can grab the handset. This mirrors AOSP's own notification
// "Answer" action (NotificationBroadcastReceiver: call.answer(); showInCall(false,false)).
// BAL-safe: while a call exists Telecom binds the InCallService with
// BIND_ALLOW_BACKGROUND_ACTIVITY_STARTS, exempting this (same-process) service. InCallActivity
// carries FLAG_TURN_SCREEN_ON + SHOW_WHEN_LOCKED, so it lights up a locked, screen-off phone.
com.android.incallui.InCallPresenter.getInstance().showInCall(false /*showDialpad*/, false /*newOutgoingCall*/);
} else {
android.util.Log.w("HelpdeskCmd", "answer -> call is null, cannot answer");
}
break;
case "reject":
if (call != null) { call.reject(false, null); }
break;
case "hangup":
if (call != null) { call.disconnect(); }
break;
case "hold":
if (call != null) { call.hold(); }
break;
case "resume":
if (call != null) { call.unhold(); }
break;
case "mute":
tel.mute(true);
break;
case "unmute":
tel.mute(false);
break;
case "dtmf":
if (call != null && arg != null && !arg.isEmpty()) {
final String dialerId = call.getId();
tel.playDtmfTone(dialerId, arg.charAt(0));
// Stop after a short tone: playing and stopping in the same tick can emit a zero-width tone that
// far-end IVRs miss. ~120ms is enough to register; re-check the call still exists on the delay.
main.postDelayed(() -> {
if (CallList.getInstance().getCallById(dialerId) != null) {
TelecomAdapter.getInstance().stopDtmfTone(dialerId);
}
}, 120);
}
break;
case "dial":
// Device-scoped (no target call): place a NEW outgoing call. It then surfaces through the
// normal outbound path (CallEventEmitter fires /calls/outgoing, the recorder captures it), so
// no call_id correlation is needed here — the phone mints it on the DIALING edge.
placeCall(arg);
break;
default:
// "route" (audio-endpoint change) handled via CallAudioState elsewhere; no-op here.
break;
}
} catch (Throwable t) {
// An exception here (e.g. a Telecom SecurityException) previously crashed the main thread
// silently on main.post — now it's contained and logged so the reverse channel stays alive.
android.util.Log.e("HelpdeskCmd", "apply verb=" + verb + " threw", t);
}
}
// Click-to-dial: place a new outgoing call via Telecom. The Dialer is the default dialer and holds
// CALL_PHONE, so placeCall is permitted; the resulting call is auto-recorded and reported outbound.
private void placeCall(String number) {
String num = number == null ? "" : number.trim();
if (num.isEmpty()) {
android.util.Log.w("HelpdeskCmd", "dial -> empty number, ignoring");
return;
}
try {
android.telecom.TelecomManager tm = appContext.getSystemService(android.telecom.TelecomManager.class);
android.net.Uri uri = android.net.Uri.fromParts("tel", num, null);
tm.placeCall(uri, new android.os.Bundle());
android.util.Log.i("HelpdeskCmd", "dial -> placeCall " + num);
} catch (SecurityException se) {
android.util.Log.e("HelpdeskCmd", "dial -> placeCall denied (CALL_PHONE / default-dialer?)", se);
} catch (Throwable t) {
android.util.Log.e("HelpdeskCmd", "dial -> placeCall threw for " + num, t);
}
}
private DialerCall resolve(String callId) {
if (callId == null) {
return null;
}
String dialerId = registry.dialerIdForCallId(callId);
return dialerId == null ? null : CallList.getInstance().getCallById(dialerId);
}
private CallPhase phaseOf(DialerCall call) {
if (call == null) {
return CallPhase.ENDED;
}
int s = call.getState();
if (s == DialerCallState.INCOMING) {
return CallPhase.RINGING;
}
if (s == DialerCallState.DISCONNECTED || s == DialerCallState.IDLE) {
return CallPhase.ENDED;
}
return CallPhase.ACTIVE; // active / dialing / onhold are all commandable
}
}