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.
215 lines
9.2 KiB
Java
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
|
|
}
|
|
}
|