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