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.
135 lines
5.2 KiB
Java
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;
|
|
}
|
|
}
|