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; } }