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

127 lines
5.3 KiB
Java

/*
* Helpdesk Dialer patch — backend discovery (mDNS / DNS-SD).
* Part of the com.android.dialer.helpdesk overlay (see components/dialer-patch).
*/
package com.android.dialer.helpdesk;
import android.content.Context;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import java.net.InetAddress;
/**
* Finds the Helpdesk backend on the local network via mDNS/DNS-SD (service type {@code _helpdesk._tcp}),
* so the phone reaches it with <em>no hardcoded IP</em> — it just has to be on the same WiFi. The backend
* host advertises the service (e.g. an avahi {@code .service} file, port 4000); we resolve host:port and
* expose {@code http://<host>:<port>/api/v1}. {@link HelpdeskConfig#baseUrl()} prefers an explicit
* persist/ro override, then this discovered value, then the dev default — so discovery is the zero-config
* path but an operator can always pin an address.
*
* <p>Best-effort + self-healing: discovery keeps running, so a backend that (re)appears or changes DHCP
* address is picked up on the next resolve. Singleton because the HTTP clients read {@link #baseUrl()}
* (via a supplier) per request; a late resolution reaches even the long-lived command poll.
*/
public final class HelpdeskDiscovery {
private static final String SERVICE_TYPE = "_helpdesk._tcp.";
private static final String API_PATH = "/api/v1";
private static final String TAG = "HelpdeskDiscovery";
private static final HelpdeskDiscovery INSTANCE = new HelpdeskDiscovery();
public static HelpdeskDiscovery get() {
return INSTANCE;
}
private volatile String baseUrl; // http://host:port/api/v1, or null until discovered
private NsdManager nsd;
private NsdManager.DiscoveryListener discoveryListener;
private boolean started;
private boolean resolveInFlight; // NsdManager allows one resolveService at a time
private HelpdeskDiscovery() {}
/** The discovered backend base URL, or null if not (yet) found. */
public String baseUrl() {
return baseUrl;
}
/** Start mDNS discovery (idempotent). Call from InCallServiceImpl.onBind with any Context. */
public synchronized void start(Context context) {
if (started || context == null) {
return;
}
nsd = (NsdManager) context.getApplicationContext().getSystemService(Context.NSD_SERVICE);
if (nsd == null) {
return;
}
discoveryListener = new NsdManager.DiscoveryListener() {
@Override public void onServiceFound(NsdServiceInfo info) {
if (info != null && info.getServiceType() != null && info.getServiceType().contains("_helpdesk._tcp")) {
android.util.Log.i(TAG, "service found: " + info.getServiceName());
resolve(info);
}
}
@Override public void onServiceLost(NsdServiceInfo info) { /* keep last known; re-discovery refreshes it */ }
@Override public void onDiscoveryStarted(String serviceType) { android.util.Log.i(TAG, "discovery started: " + serviceType); }
@Override public void onDiscoveryStopped(String serviceType) {}
@Override public void onStartDiscoveryFailed(String serviceType, int errorCode) { started = false; android.util.Log.w(TAG, "discovery start failed: " + errorCode); }
@Override public void onStopDiscoveryFailed(String serviceType, int errorCode) {}
};
try {
nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener);
started = true;
} catch (Exception ignored) {
// NSD unavailable — HelpdeskConfig falls back to the persist/ro override or default.
}
}
private void resolve(NsdServiceInfo info) {
synchronized (this) {
if (resolveInFlight) {
return; // one resolve at a time; the periodic re-discovery will retry
}
resolveInFlight = true;
}
try {
nsd.resolveService(info, new NsdManager.ResolveListener() {
@Override public void onServiceResolved(NsdServiceInfo resolved) {
finishResolve();
InetAddress host = resolved.getHost();
int port = resolved.getPort();
if (host == null || port <= 0) {
android.util.Log.w(TAG, "resolved with no host/port");
return;
}
if (host.isLinkLocalAddress() || host.isAnyLocalAddress()) {
// fe80:… link-local needs a scope id and isn't usable as a plain URL host — wait for a better one
android.util.Log.w(TAG, "resolved to unusable address " + host.getHostAddress() + " — ignoring");
return;
}
String addr = host.getHostAddress();
String hostPart;
if (host instanceof java.net.Inet6Address) {
int pct = addr.indexOf('%'); // strip the zone id if present
if (pct >= 0) {
addr = addr.substring(0, pct);
}
hostPart = "[" + addr + "]"; // IPv6 literals MUST be bracketed in a URL
} else {
hostPart = addr;
}
baseUrl = "http://" + hostPart + ":" + port + API_PATH;
android.util.Log.i(TAG, "backend discovered: " + baseUrl);
}
@Override public void onResolveFailed(NsdServiceInfo failed, int errorCode) {
finishResolve();
android.util.Log.w(TAG, "resolve failed: " + errorCode);
}
});
} catch (Exception e) {
finishResolve();
}
}
private synchronized void finishResolve() {
resolveInFlight = false;
}
}