1package soy.iko.opencode.data.network
3import android.content.Context
4import android.net.nsd.NsdManager
5import android.net.nsd.NsdServiceInfo
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"
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.
34open class NsdDiscovery(context: Context?) {
36 protected constructor() : this(null)
38 private val appContext = context?.applicationContext
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).
46 open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
47 val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
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 })
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))
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 })
117 override fun onDiscoveryStopped(serviceType: String?) {}
118 override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
119 Log.w(TAG, "mDNS discovery start failed: $errorCode")
122 override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
125 runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
126 .onFailure { close(it) }
131 runCatching { nsd.stopServiceDiscovery(listener) }
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)
149 override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
150 if (settled.compareAndSet(false, true)) {
151 runCatching { nsd.unregisterServiceInfoCallback(callback) }
152 if (cont.isActive) cont.resume(serviceInfo)
155 override fun onServiceLost() {
156 if (settled.compareAndSet(false, true)) {
157 runCatching { nsd.unregisterServiceInfoCallback(callback) }
158 if (cont.isActive) cont.resume(null)
161 override fun onServiceInfoCallbackUnregistered() {}
163 cont.invokeOnCancellation {
164 if (settled.compareAndSet(false, true)) {
165 runCatching { nsd.unregisterServiceInfoCallback(callback) }
168 runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
169 .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
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)
178 override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
179 if (cont.isActive) cont.resume(serviceInfo)
182 runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
183 .onFailure { if (cont.isActive) cont.resume(null) }
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
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list