/* * 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 } }