@@ -1,195 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import android.content.Context
4−import android.net.nsd.NsdManager
5−import android.net.nsd.NsdServiceInfo
6−import android.os.Build
7−import android.util.Log
8−import kotlinx.coroutines.channels.Channel
9−import kotlinx.coroutines.flow.Flow
10−import kotlinx.coroutines.channels.awaitClose
11−import kotlinx.coroutines.flow.callbackFlow
12−import kotlinx.coroutines.launch
13−import kotlinx.coroutines.suspendCancellableCoroutine
14−import kotlinx.coroutines.withTimeoutOrNull
15−import kotlin.coroutines.resume
16−
17−/** A server discovered on the local network via mDNS/DNS-SD. */
18−data class DiscoveredServer(val name: String, val host: String, val port: Int) {
19− val baseUrl: String get() {
20− // IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed to form a valid
21− // URL authority; a link-local zone id's '%' is percent-encoded to "%25" per RFC 6874
22− // ("[fe80::1%25wlan0]") rather than dropped, so link-local hosts stay dialable. IPv4 unchanged.
23− val formattedHost = if (host.contains(':')) "[${host.replace("%", "%25")}]" else host
24− return "http://$formattedHost:$port"
25− }
26−}
27−
28−/**
29− * Discovers opencode servers advertised on the LAN. opencode's `--mdns` publishes a DNS-SD
30− * `_http._tcp` service named `opencode-<port>` (with a `path=/` TXT record), so we browse
31− * that service type and keep only names that look like opencode. Requires no extra
32− * permission — [NsdManager] manages the multicast lock internally.
33− */
34−open class NsdDiscovery(context: Context?) {
35−
36− protected constructor() : this(null)
37−
38− private val appContext = context?.applicationContext
39−
40− /**
41− * A cold [Flow] that starts a discovery when collected and stops it when the collector
42− * cancels. Emits the growing/shrinking set of discovered servers. Resolutions are
43− * serialized through a channel because [NsdManager.resolveService] can only process one
44− * at a time on older platforms (a concurrent resolve fails with ALREADY_ACTIVE).
45− */
46− open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
47− val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
48− if (nsd == null) {
49− trySend(emptyList())
50− close()
51− return@callbackFlow
52− }
53− // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
54− val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56− // epoch that was current when it was enqueued; the resolver discards its result if the
57− // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58− // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59− // multi-homed devices), or a resolve still in flight when the service departs — is
60− // dropped, while a resolve queued AFTER a loss (a genuine re-announcement of a restarted
61− // server) still inserts. It fixes the phantom a plain "lost-pending" flag missed:
62− // onServiceLost removing an already-resolved entry couldn't tell a second, still-queued
63− // duplicate resolve to skip re-inserting the departed service — yet it also avoids that
64− // flag's converse hazard of leaking a marker that suppresses a later legitimate re-appearance.
65− val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
66− val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
67−
68− val resolver = launch {
69− for ((info, stampedEpoch) in toResolve) {
70− // Key the map by the name from discovery: onServiceFound/onServiceLost both use
71− // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
72− // keying by the resolved name would leave a departed server that onServiceLost
73− // can't match, so it would linger in the emitted list forever.
74− val discoveredName = info.serviceName.orEmpty()
75− // NsdManager.resolveService occasionally never invokes its listener. Without a
76− // timeout, one stuck resolve would hang this single serialized resolver forever,
77− // so every later-discovered service queued behind it is never processed and the
78− // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
79− val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
80− // Drop the result if the service was reported lost since this resolve was queued
81− // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
82− if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
83− val name = resolved.serviceName.orEmpty()
84− // Keep only opencode's own advertisements, not every _http._tcp service
85− // (printers, routers, other web servers) on the network.
86− if (!name.startsWith("opencode", ignoreCase = true)) continue
87− @Suppress("DEPRECATION")
88− val host = resolved.host?.hostAddress ?: continue
89− found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
90− // Close the check-then-insert race: an onServiceLost firing in the non-suspending
91− // window since the check above bumped the epoch but couldn't remove this name from
92− // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
93− // monotonic, so any loss that raced the insert is still visible here.
94− if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
95− trySend(found.values.sortedBy { it.name })
96− }
97− }
98−
99− val listener = object : NsdManager.DiscoveryListener {
100− override fun onDiscoveryStarted(serviceType: String?) {}
101− override fun onServiceFound(serviceInfo: NsdServiceInfo) {
102− // Stamp the resolve with the name's current epoch so a loss after this point (but
103− // before the serialized resolver reaches it) invalidates the result.
104− val name = serviceInfo.serviceName.orEmpty()
105− toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
106− }
107− override fun onServiceLost(serviceInfo: NsdServiceInfo) {
108− val name = serviceInfo.serviceName ?: return
109− // Advance the epoch so any resolve queued before now (including a duplicate
110− // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
111− // this service, then drop it from the emitted list if it was already resolved.
112− epoch.merge(name, 1) { old, _ -> old + 1 }
113− if (found.remove(name) != null) {
114− trySend(found.values.sortedBy { it.name })
115− }
116− }
117− override fun onDiscoveryStopped(serviceType: String?) {}
118− override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
119− Log.w(TAG, "mDNS discovery start failed: $errorCode")
120− close()
121− }
122− override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
123− }
124−
125− runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
126− .onFailure { close(it) }
127−
128− awaitClose {
129− resolver.cancel()
130− toResolve.close()
131− runCatching { nsd.stopServiceDiscovery(listener) }
132− }
133− }
134−
135− private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
136− suspendCancellableCoroutine { cont ->
137− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
138− // API 34 deprecated the one-shot resolveService (which has no cancel API and
139− // whose listener leaks on timeout, wedging the platform's serialized slot so the
140− // next resolve fails with ALREADY_ACTIVE) in favour of a persistent callback that
141− // can be detached. A timed-out (cancelled) resolve unregisters its callback in
142− // invokeOnCancellation, and a settled one unregisters itself, so nothing lingers.
143− val settled = java.util.concurrent.atomic.AtomicBoolean(false)
144− lateinit var callback: NsdManager.ServiceInfoCallback
145− callback = object : NsdManager.ServiceInfoCallback {
146− override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
147− if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null)
148− }
149− override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
150− if (settled.compareAndSet(false, true)) {
151− runCatching { nsd.unregisterServiceInfoCallback(callback) }
152− if (cont.isActive) cont.resume(serviceInfo)
153− }
154− }
155− override fun onServiceLost() {
156− if (settled.compareAndSet(false, true)) {
157− runCatching { nsd.unregisterServiceInfoCallback(callback) }
158− if (cont.isActive) cont.resume(null)
159− }
160− }
161− override fun onServiceInfoCallbackUnregistered() {}
162− }
163− cont.invokeOnCancellation {
164− if (settled.compareAndSet(false, true)) {
165− runCatching { nsd.unregisterServiceInfoCallback(callback) }
166− }
167− }
168− runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
169− .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
170− } else {
171− // Pre-34: the deprecated one-shot resolveService has no cancel API, so a stuck
172− // resolve abandoned by RESOLVE_TIMEOUT_MS can still wedge the platform's single
173− // serialized slot; this is a platform limitation we can only bound, not clear.
174− val listener = object : NsdManager.ResolveListener {
175− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
176− if (cont.isActive) cont.resume(null)
177− }
178− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
179− if (cont.isActive) cont.resume(serviceInfo)
180− }
181− }
182− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
183− .onFailure { if (cont.isActive) cont.resume(null) }
184− }
185− }
186−
187− private companion object {
188− const val TAG = "NsdDiscovery"
189− // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
190− const val SERVICE_TYPE = "_http._tcp."
191− // Upper bound on a single mDNS resolve before it's abandoned so a stuck platform
192− // callback can't wedge the serialized resolver. Local-network resolves are sub-second.
193− const val RESOLVE_TIMEOUT_MS = 5_000L
194− }
195−}