git.iko.soy/opencode-android
on 61cdc75 help sign in
195 lines 11 kB history raw
1package soy.iko.opencode.data.network
3import android.content.Context
4import android.net.nsd.NsdManager
5import android.net.nsd.NsdServiceInfo
6import android.os.Build
7import android.util.Log
8import kotlinx.coroutines.channels.Channel
9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.channels.awaitClose
11import kotlinx.coroutines.flow.callbackFlow
12import kotlinx.coroutines.launch
13import kotlinx.coroutines.suspendCancellableCoroutine
14import kotlinx.coroutines.withTimeoutOrNull
15import kotlin.coroutines.resume
17/** A server discovered on the local network via mDNS/DNS-SD. */
18data 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 }
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 */
34open class NsdDiscovery(context: Context?) {
36 protected constructor() : this(null)
38 private val appContext = context?.applicationContext
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)
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 }
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 }
125 runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
126 .onFailure { close(it) }
128 awaitClose {
129 resolver.cancel()
130 toResolve.close()
131 runCatching { nsd.stopServiceDiscovery(listener) }
132 }
133 }
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 }
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 }