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.
185 lines
6.8 KiB
Java
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) {}
|
|
}
|