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

185 lines
6.8 KiB
Java

/*
* Helpdesk Dialer patch — call-lifecycle webhook emitter.
* Part of the com.android.dialer.helpdesk overlay (see components/dialer-patch).
*/
package com.android.dialer.helpdesk;
import android.telecom.DisconnectCause;
import android.telecom.TelecomManager;
import com.android.dialer.helpdesk.util.CzE164;
import com.android.incallui.call.CallList;
import com.android.incallui.call.DialerCall;
import com.android.incallui.call.state.DialerCallState;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import org.json.JSONException;
import org.json.JSONObject;
/**
* A {@link CallList.Listener} that emits the three device→backend call webhooks: mints the
* {@code call_id} at RINGING and fires {@code /calls/incoming}; detects the ACTIVE edge in
* {@code onCallListChange} and fires {@code /calls/answered}; fires {@code /calls/ended} on disconnect
* with duration + {@link DisconnectCause}. All emission is enqueued on {@link HelpdeskDispatcher} — it
* never blocks the Telecom callback thread. This is the single emitter (one webhook per event).
*/
public final class CallEventEmitter implements CallList.Listener {
private final CallIdRegistry registry;
private final HelpdeskDispatcher dispatcher;
public CallEventEmitter(CallIdRegistry registry, HelpdeskDispatcher dispatcher) {
this.registry = registry;
this.dispatcher = dispatcher;
}
@Override
public void onIncomingCall(DialerCall call) {
CallIdRegistry.Entry e = registry.forCall(call.getId());
e.startReported = true; // inbound start announced here; the outbound-edge loop must not double-fire
JSONObject b = new JSONObject();
try {
b.put("call_id", e.callId);
b.put("ts", isoNow());
String num = CzE164.normalize(call.getNumber());
b.put("number", num == null ? JSONObject.NULL : num);
b.put("presentation", presentation(call.getNumberPresentation()));
b.put("device_id", HelpdeskConfig.deviceId());
// dialed_did (which of our DIDs was called) is not always available on inbound; the backend
// tolerates its absence and resolves the operator server-side when present.
} catch (JSONException ignored) {
// JSONObject.put only throws on a NaN/Infinity double; none used here.
}
dispatcher.enqueuePost(e.callId + ":incoming", "/calls/incoming", b.toString());
}
@Override
public void onCallListChange(CallList callList) {
// Outbound start edge. CallList.Listener has no onOutgoingCall() — an operator-placed call only ever
// surfaces here, in DIALING/CONNECTING. Mint the call_id and fire /calls/outgoing once, before it
// goes ACTIVE, so the console screen-pops the callee (with history) exactly like an inbound call.
// Recording, /calls/answered and /calls/ended already cover outbound unchanged (they don't look at
// direction), so this webhook is the only outbound-specific emission.
for (DialerCall call : callList.getAllCalls()) {
int state = call.getState();
if (state != DialerCallState.DIALING && state != DialerCallState.CONNECTING) {
continue;
}
CallIdRegistry.Entry e = registry.forCall(call.getId());
if (e.startReported) {
continue;
}
e.startReported = true;
JSONObject ob = new JSONObject();
try {
ob.put("call_id", e.callId);
ob.put("ts", isoNow());
String num = CzE164.normalize(call.getNumber());
ob.put("number", num == null ? JSONObject.NULL : num);
ob.put("device_id", HelpdeskConfig.deviceId());
} catch (JSONException ignored) {
}
dispatcher.enqueuePost(e.callId + ":outgoing", "/calls/outgoing", ob.toString());
}
// Fire the ACTIVE edge PER CALL, not just for getActiveCall() (the FIRST active call). With a second
// call answered from the PC while another is still ACTIVE, getActiveCall() returns the first and the
// second call's /calls/answered would be silently dropped (leaving it 'ringing' until the watchdog).
// The per-Entry answeredReported flag keeps each call's edge once-only.
for (DialerCall call : callList.getAllCalls()) {
if (call.getState() != DialerCallState.ACTIVE) {
continue;
}
CallIdRegistry.Entry e = registry.forCall(call.getId());
if (e.answeredReported) {
continue;
}
e.answeredReported = true;
JSONObject b = new JSONObject();
try {
b.put("call_id", e.callId);
b.put("ts", isoNow());
} catch (JSONException ignored) {
}
dispatcher.enqueuePost(e.callId + ":answered", "/calls/answered", b.toString());
}
}
@Override
public void onDisconnect(DialerCall call) {
CallIdRegistry.Entry e = registry.forCall(call.getId());
JSONObject b = new JSONObject();
try {
b.put("call_id", e.callId);
b.put("ts", isoNow());
long connect = call.getConnectTimeMillis();
long durationS = connect > 0 ? Math.max(0, (System.currentTimeMillis() - connect) / 1000L) : 0L;
b.put("duration_s", durationS);
b.put("disconnect_cause", disconnectCause(call));
b.put("recording_uuid", e.recordingUuid == null ? JSONObject.NULL : e.recordingUuid);
} catch (JSONException ignored) {
}
dispatcher.enqueuePost(e.callId + ":ended", "/calls/ended", b.toString());
registry.remove(call.getId());
}
// ---- presentation / disconnect mapping to the backend's enums ----
private static String presentation(int p) {
switch (p) {
case TelecomManager.PRESENTATION_ALLOWED:
return "allowed";
case TelecomManager.PRESENTATION_RESTRICTED:
return "restricted";
case TelecomManager.PRESENTATION_PAYPHONE:
return "payphone";
default:
return "unknown";
}
}
private static String disconnectCause(DialerCall call) {
DisconnectCause dc = call.getDisconnectCause();
if (dc == null) {
return "unknown";
}
switch (dc.getCode()) {
case DisconnectCause.LOCAL:
return "local";
case DisconnectCause.REMOTE:
return "remote";
case DisconnectCause.MISSED:
return "missed";
case DisconnectCause.REJECTED:
return "rejected";
case DisconnectCause.ERROR:
return "error";
default:
return "unknown";
}
}
private static String isoNow() {
SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
f.setTimeZone(TimeZone.getTimeZone("UTC"));
return f.format(new Date());
}
// ---- unused CallList.Listener methods (no-ops) ----
@Override
public void onUpgradeToVideo(DialerCall call) {}
@Override
public void onSessionModificationStateChange(DialerCall call) {}
@Override
public void onWiFiToLteHandover(DialerCall call) {}
@Override
public void onHandoverToWifiFailed(DialerCall call) {}
@Override
public void onInternationalCallOnWifi(DialerCall call) {}
}