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

135 lines
5.2 KiB
Java

/*
* Helpdesk Dialer patch — one-shot contacts sync (phone address book -> backend).
* Part of the com.android.dialer.helpdesk overlay (see components/dialer-patch).
*/
package com.android.dialer.helpdesk;
import android.content.Context;
import android.database.Cursor;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Email;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.CommonDataKinds.StructuredName;
import com.android.dialer.helpdesk.http.DeviceAuthSigner;
import com.android.dialer.helpdesk.http.HelpdeskHttpClient;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Pushes the office Pixel's address book to the backend (POST {@code /device/contacts}, device-authed)
* so the console's People directory reflects the phone's contacts. Runs off the main thread, at most
* once per {@link #MIN_INTERVAL_MS} (a process-wide timestamp guard), and is fire-and-forget: any error
* is logged and dropped — a missed sync just retries on the next call. The backend upserts by phone
* number; the phone owns name/email/numbers, the console owns per-caller context (never touched by sync).
*/
public final class ContactsSyncer {
private static final String TAG = "HelpdeskContacts";
private static final long MIN_INTERVAL_MS = 30 * 60 * 1000L; // don't re-read the address book more than every 30 min
private static volatile long lastSyncMs = 0L;
private final Context ctx;
private final HelpdeskHttpClient client;
public ContactsSyncer(Context context) {
this.ctx = context.getApplicationContext();
DeviceAuthSigner signer = new DeviceAuthSigner(HelpdeskConfig.deviceToken(), HelpdeskConfig.deviceSecret());
this.client = new HelpdeskHttpClient(HelpdeskConfig::baseUrl, signer);
}
/** Kick off a sync on a daemon thread if enough time has passed since the last one. */
public void syncSoon() {
long now = System.currentTimeMillis();
if (now - lastSyncMs < MIN_INTERVAL_MS) {
return;
}
lastSyncMs = now;
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "helpdesk-contacts");
t.setDaemon(true);
return t;
}).execute(this::sync);
}
private void sync() {
try {
JSONArray contacts = readAddressBook();
if (contacts.length() == 0) {
android.util.Log.i(TAG, "no contacts to sync");
return;
}
JSONObject body = new JSONObject();
body.put("contacts", contacts);
HelpdeskHttpClient.Response resp = client.send("POST", "/device/contacts", body.toString());
android.util.Log.i(TAG, "synced " + contacts.length() + " contacts -> status=" + resp.status);
} catch (Throwable t) {
android.util.Log.e(TAG, "contacts sync failed", t);
}
}
/** Read StructuredName + Phone + Email rows from ContactsContract, grouped per contact. */
private JSONArray readAddressBook() throws JSONException {
Map<Long, JSONObject> byContact = new LinkedHashMap<>();
String[] projection = {
ContactsContract.Data.CONTACT_ID, ContactsContract.Data.MIMETYPE,
ContactsContract.Data.DISPLAY_NAME, ContactsContract.Data.DATA1
};
String selection = ContactsContract.Data.MIMETYPE + " IN (?,?,?)";
String[] args = { StructuredName.CONTENT_ITEM_TYPE, Phone.CONTENT_ITEM_TYPE, Email.CONTENT_ITEM_TYPE };
Cursor c = null;
try {
c = ctx.getContentResolver().query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null);
if (c == null) {
android.util.Log.w(TAG, "contacts query returned null (permission not granted?)");
return new JSONArray();
}
int ci = c.getColumnIndex(ContactsContract.Data.CONTACT_ID);
int mi = c.getColumnIndex(ContactsContract.Data.MIMETYPE);
int ni = c.getColumnIndex(ContactsContract.Data.DISPLAY_NAME);
int di = c.getColumnIndex(ContactsContract.Data.DATA1);
while (c.moveToNext()) {
long id = c.getLong(ci);
String mime = c.getString(mi);
JSONObject o = byContact.get(id);
if (o == null) {
o = new JSONObject();
o.put("name", "");
o.put("numbers", new JSONArray());
o.put("emails", new JSONArray());
byContact.put(id, o);
}
String display = c.getString(ni);
if (display != null && o.getString("name").isEmpty()) {
o.put("name", display);
}
String data = c.getString(di);
if (data == null || data.trim().isEmpty()) {
continue;
}
if (Phone.CONTENT_ITEM_TYPE.equals(mime)) {
o.getJSONArray("numbers").put(data);
} else if (Email.CONTENT_ITEM_TYPE.equals(mime)) {
o.getJSONArray("emails").put(data);
}
}
} catch (SecurityException e) {
android.util.Log.w(TAG, "READ_CONTACTS not granted — skipping sync");
return new JSONArray();
} finally {
if (c != null) {
c.close();
}
}
JSONArray out = new JSONArray();
for (JSONObject o : byContact.values()) {
if (o.getJSONArray("numbers").length() > 0) {
out.put(o);
}
}
return out;
}
}