@devioarts/capacitor-mdns 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +272 -101
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +496 -175
  12. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +85 -49
  13. package/dist/docs.json +313 -15
  14. package/dist/esm/definitions.d.ts +89 -16
  15. package/dist/esm/definitions.js.map +1 -1
  16. package/dist/esm/index.js +22 -1
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/web.d.ts +8 -1
  19. package/dist/esm/web.js +35 -3
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +57 -4
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +57 -4
  24. package/dist/plugin.js.map +1 -1
  25. package/docs/electron.md +91 -0
  26. package/docs/platform-setup.md +58 -0
  27. package/docs/playground.md +50 -0
  28. package/docs/runtime-behavior.md +80 -0
  29. package/electron/dist/plugin-settings.js +22 -0
  30. package/electron/dist/plugin-settings.js.map +1 -0
  31. package/electron/dist/plugin.cjs.js +774 -0
  32. package/electron/dist/plugin.cjs.js.map +1 -0
  33. package/electron/global.d.ts +2 -0
  34. package/electron/mdns-bridge.cjs +33 -4
  35. package/electron/mdns.ts +628 -107
  36. package/electron/rollup.config.mjs +22 -0
  37. package/electron/src/index.ts +2 -0
  38. package/electron/src/plugin-settings.ts +19 -0
  39. package/electron/tsconfig.json +21 -0
  40. package/electron/types-public/index.d.ts +47 -0
  41. package/ios/Sources/mDNSPlugin/mDNS.swift +481 -124
  42. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +85 -23
  43. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  44. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  45. package/package.json +55 -20
  46. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -15
@@ -3,13 +3,20 @@ package com.devioarts.capacitor.mdns
3
3
 
4
4
  import android.content.Context
5
5
  import android.net.nsd.NsdManager
6
- import android.net.nsd.NsdServiceInfo
7
- import android.os.Build
8
- import android.os.Handler
9
- import android.os.Looper
10
- import kotlinx.coroutines.*
11
- import java.util.concurrent.Executor
12
- import java.util.concurrent.atomic.AtomicReference
6
+ import kotlinx.coroutines.CompletableDeferred
7
+ import kotlinx.coroutines.CoroutineDispatcher
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.Job
11
+ import kotlinx.coroutines.SupervisorJob
12
+ import kotlinx.coroutines.TimeoutCancellationException
13
+ import kotlinx.coroutines.cancel
14
+ import kotlinx.coroutines.delay
15
+ import kotlinx.coroutines.launch
16
+ import kotlinx.coroutines.sync.Mutex
17
+ import kotlinx.coroutines.sync.withLock
18
+ import kotlinx.coroutines.withContext
19
+ import kotlinx.coroutines.withTimeout
13
20
 
14
21
  /**
15
22
  * Thin Android NSD (Bonjour/mDNS) wrapper used by the Capacitor bridge.
@@ -21,42 +28,102 @@ import java.util.concurrent.atomic.AtomicReference
21
28
  * - Timebox discovery; early-exit when the target match is resolved.
22
29
  *
23
30
  * Threading:
24
- * - All public APIs are main-thread safe. Calls hop to the main looper internally,
25
- * since NSD APIs deliver callbacks on the main thread and expect a looper.
31
+ * - All mutable state is confined to the main thread. NSD delivers the legacy listener callbacks on
32
+ * its own internal thread, so every callback coming from [NsdBackend] hops to main via [runOnMain]
33
+ * before it touches any state.
26
34
  *
27
35
  * Platform notes:
28
- * - Android NSD has no public API for TXT records.
36
+ * - Resolved TXT records are available through NSD attributes.
37
+ * - Before API 34 only one `resolveService` may be active at a time; resolves are queued here.
29
38
  */
30
- class mDNS(
31
- context: Context,
32
- private val externalScope: CoroutineScope? = null
39
+ class mDNS internal constructor(
40
+ private val backend: NsdBackend,
41
+ private val mainThread: MainThread,
42
+ private val mainDispatcher: CoroutineDispatcher,
43
+ private val externalScope: CoroutineScope?,
44
+ private val publishTimeoutMs: Long,
45
+ private val resolveTimeoutMs: Long,
46
+ private val stopTimeoutMs: Long = 3000L
33
47
  ) {
48
+ constructor(context: Context, externalScope: CoroutineScope? = null) : this(
49
+ AndroidNsdBackend(context),
50
+ AndroidMainThread(),
51
+ Dispatchers.Main.immediate,
52
+ externalScope,
53
+ PUBLISH_TIMEOUT_MS,
54
+ RESOLVE_TIMEOUT_MS
55
+ )
56
+
57
+ private companion object {
58
+ const val PUBLISH_TIMEOUT_MS = 5000L
59
+ const val RESOLVE_TIMEOUT_MS = 5000L
60
+ const val RESOLVE_RETRY_DELAY_MS = 150L
61
+ const val MAX_RESOLVE_RETRIES = 3
62
+ val NAME_SUFFIX = Regex(""" \(\d+\)$""")
63
+ }
64
+
34
65
  /** Normalized service representation returned to the bridge. */
35
66
  data class MdnsService(
36
67
  val name: String,
37
68
  val type: String,
38
69
  val hosts: List<String>, // numeric addresses (v4/v6)
39
- val port: Int
70
+ val port: Int,
71
+ val txt: Map<String, String> = emptyMap()
40
72
  )
41
73
 
42
- /** NSD system service. */
43
- private val nsd: NsdManager =
44
- context.getSystemService(Context.NSD_SERVICE) as NsdManager
74
+ private class RegistrationSession(
75
+ val onSuccess: (String) -> Unit,
76
+ val onError: (Throwable) -> Unit
77
+ ) {
78
+ var timeoutJob: Job? = null
79
+ var token: Any? = null
80
+ var completed = false
81
+ var finished = false
82
+ /** True once the caller no longer wants it; a late confirmation must be unregistered. */
83
+ var cancelled = false
84
+ var stopping = false
85
+ var stopped = CompletableDeferred<Throwable?>()
86
+ var lockHeld = false
87
+ }
45
88
 
46
- /** Keep strong references to listeners so they outlive registration/discovery. */
47
- private val regListenerRef = AtomicReference<NsdManager.RegistrationListener?>(null)
48
- private val discListenerRef = AtomicReference<NsdManager.DiscoveryListener?>(null)
89
+ /** A discovered candidate waiting for (or undergoing) resolution. */
90
+ interface WatchListener {
91
+ fun onServiceFound(service: MdnsService)
92
+ fun onServiceLost(service: MdnsService)
93
+ fun onError(type: String, message: String, watching: Boolean)
94
+ }
49
95
 
50
- /** Optional external scope (from plugin); otherwise create our own on Main. */
51
- private val scope = externalScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
96
+ private inner class Pending(val owner: DiscoverySession, val service: DiscoveredService) {
97
+ var attempts = 0
98
+ var done = false
99
+ var token: Any? = null
100
+ var timeoutJob: Job? = null
101
+ var retryJob: Job? = null
102
+ var generation = 0
103
+ }
52
104
 
53
- /** Main looper utilities for non-suspending entry points. */
54
- private val mainHandler = Handler(Looper.getMainLooper())
55
- private val mainExecutor = Executor { command -> mainHandler.post(command) }
56
- private inline fun runOnMain(crossinline block: () -> Unit) {
57
- if (Looper.myLooper() === Looper.getMainLooper()) block() else mainHandler.post { block() }
105
+ /** Optional external scope (from plugin); otherwise create our own on Main. */
106
+ private val scope = externalScope ?: CoroutineScope(SupervisorJob() + mainDispatcher)
107
+ private val broadcastMutex = Mutex()
108
+ private val watchMutex = Mutex()
109
+ private var closed = false
110
+
111
+ // Main-thread confined state.
112
+ private var activeRegistration: RegistrationSession? = null
113
+ private var activeDiscovery: DiscoverySession? = null
114
+ private var activeWatch: DiscoverySession? = null
115
+ private val resolveQueue = ArrayDeque<Pending>()
116
+ private val resolving = mutableSetOf<Pending>()
117
+ private val discoveryStops = mutableMapOf<Any, CompletableDeferred<Throwable?>>()
118
+ private val retryDiscoveryStops = mutableSetOf<Any>()
119
+ internal val isPublishing: Boolean get() = activeRegistration != null
120
+
121
+ private fun runOnMain(block: () -> Unit) {
122
+ if (mainThread.isCurrent()) block() else mainThread.post(block)
58
123
  }
59
124
 
125
+ // ---- Broadcast -----------------------------------------------------------
126
+
60
127
  /**
61
128
  * Start advertising a service. If already registered, the previous one is unregistered first.
62
129
  *
@@ -71,48 +138,93 @@ class mDNS(
71
138
  name: String,
72
139
  port: Int,
73
140
  onSuccess: (String) -> Unit,
74
- onError: (Throwable) -> Unit
141
+ onError: (Throwable) -> Unit,
142
+ txt: Map<String, String> = emptyMap()
75
143
  ) {
76
- require(port > 0) { "Port must be > 0" }
77
- val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
78
-
79
- runOnMain {
80
- // Ensure a clean state before registering again.
81
- regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
144
+ require(port in 1..65535) { "Port must be between 1 and 65535" }
145
+ val type = MdnsValidation.type(typeRaw)
146
+ val normalizedName = MdnsValidation.name(name).ifEmpty { "DevIOArtsMDNS" }
147
+ MdnsValidation.txt(txt)
148
+
149
+ scope.launch(mainDispatcher) {
150
+ broadcastMutex.withLock {
151
+ try {
152
+ check(!closed) { "mDNS manager is closed" }
153
+ // Restarting while publish is pending must complete the old JS promise.
154
+ stopActiveRegistration("Replaced by a new broadcast request")
155
+ check(!closed) { "mDNS manager is closed" }
156
+
157
+ val session = RegistrationSession(onSuccess, onError)
158
+ val listener = object : NsdBackend.RegistrationListener {
159
+ override fun onRegistered(publishedName: String) =
160
+ runOnMain { handleRegistered(session, publishedName) }
161
+
162
+ override fun onFailed(errorCode: Int) = runOnMain {
163
+ finishRegistration(session)
164
+ completeRegistrationError(
165
+ session,
166
+ IllegalStateException("Registration failed: $errorCode")
167
+ )
168
+ }
169
+
170
+ override fun onUnregistered() = runOnMain { finishRegistration(session) }
171
+
172
+ override fun onUnregistrationFailed(errorCode: Int) = runOnMain {
173
+ session.stopping = false
174
+ session.stopped.complete(IllegalStateException("Unregistration failed: $errorCode"))
175
+ }
176
+ }
82
177
 
83
- val info = NsdServiceInfo().apply {
84
- serviceType = type
85
- serviceName = name
86
- setPort(port)
87
- }
178
+ session.timeoutJob = scope.launch(mainDispatcher) {
179
+ delay(publishTimeoutMs)
180
+ session.cancelled = true
181
+ completeRegistrationError(
182
+ session,
183
+ IllegalStateException("Timed out waiting for service publish after ${publishTimeoutMs}ms")
184
+ )
185
+ unregister(session)
186
+ releaseRegistrationLock(session)
187
+ }
88
188
 
89
- val listener = object : NsdManager.RegistrationListener {
90
- override fun onServiceRegistered(nsi: NsdServiceInfo) = onSuccess(nsi.serviceName)
91
- override fun onRegistrationFailed(nsi: NsdServiceInfo, errorCode: Int) =
92
- onError(IllegalStateException("Registration failed: $errorCode"))
93
- override fun onServiceUnregistered(nsi: NsdServiceInfo) { /* no-op */ }
94
- override fun onUnregistrationFailed(nsi: NsdServiceInfo, errorCode: Int) { /* no-op */ }
189
+ activeRegistration = session
190
+ try {
191
+ backend.acquireMulticastLock()
192
+ session.lockHeld = true
193
+ session.token = backend.registerService(type, normalizedName, port, txt.toMap(), listener)
194
+ if (session.cancelled) unregister(session)
195
+ } catch (t: Throwable) {
196
+ finishRegistration(session)
197
+ completeRegistrationError(session, t)
198
+ }
199
+ } catch (t: Throwable) {
200
+ onError(t)
201
+ }
95
202
  }
96
-
97
- regListenerRef.set(listener)
98
- nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
99
203
  }
100
204
  }
101
205
 
102
206
  /**
103
207
  * Stop advertising the currently registered service (if any). Safe to call multiple times.
104
208
  */
105
- fun stopBroadcast() {
106
- runOnMain {
107
- regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
209
+ fun stopBroadcast(onComplete: (Boolean, Throwable?) -> Unit = { _, _ -> }) {
210
+ scope.launch(mainDispatcher) {
211
+ broadcastMutex.withLock {
212
+ val error = try {
213
+ stopActiveRegistration("Publish stopped before completion")
214
+ null
215
+ } catch (t: Throwable) { t }
216
+ onComplete(isPublishing, error)
217
+ }
108
218
  }
109
219
  }
110
220
 
221
+ // ---- Discover ------------------------------------------------------------
222
+
111
223
  /**
112
224
  * Discover services, optionally filtering by instance name using a normalized exact-or-prefix match.
113
225
  *
114
226
  * Behavior:
115
- * - Runs on the main dispatcher.
227
+ * - Runs on the main dispatcher; a new valid call replaces the previous one.
116
228
  * - Resolves candidates to host/port.
117
229
  * - If `targetName` is provided, compare using normalized names and accept exact OR prefix match.
118
230
  * - Early-exits when a matching service is resolved (stops discovery immediately).
@@ -127,164 +239,373 @@ class mDNS(
127
239
  typeRaw: String,
128
240
  targetName: String? = null,
129
241
  timeoutMs: Int = 3000
130
- ): List<MdnsService> = withContext(Dispatchers.Main.immediate) {
131
- val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
132
-
133
- // Prepare state
134
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
135
- val found = mutableListOf<MdnsService>()
136
- val serviceInfoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>()
137
- val result = CompletableDeferred<List<MdnsService>>()
242
+ ): List<MdnsService> = withContext(mainDispatcher) {
243
+ check(!closed) { "mDNS manager is closed" }
244
+ val type = MdnsValidation.type(typeRaw)
245
+ val target = MdnsValidation.name(targetName).takeIf { it.isNotEmpty() }
246
+ activeDiscovery?.fail(IllegalStateException("Discovery replaced by a new request"))
247
+ val session = DiscoverySession(listOf(type), target)
248
+ activeDiscovery = session
249
+ try {
250
+ session.start(timeoutMs.coerceAtLeast(0).toLong())
251
+ session.result.await()
252
+ } finally {
253
+ // Cleanup in all cases (early-exit, timeout, error or cancellation).
254
+ session.close()
255
+ if (activeDiscovery === session) activeDiscovery = null
256
+ }
257
+ }
138
258
 
139
- // Normalization for "(n)" suffix appended by the OS.
140
- fun normalize(s: String): String = s.replace(Regex(" \\(\\d+\\)\$"), "")
141
- fun matchesTarget(candidate: String): Boolean {
142
- val c = normalize(candidate)
143
- val t = normalize(targetName ?: return true) // if no target, accept all
144
- return (c == t) || c.startsWith(t)
259
+ /**
260
+ * Close the manager and release any outstanding NSD listeners.
261
+ * Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
262
+ */
263
+ fun close(onComplete: () -> Unit = {}) {
264
+ runOnMain {
265
+ closed = true
266
+ val watch = activeWatch
267
+ activeWatch = null
268
+ watch?.close()
269
+ activeDiscovery?.fail(IllegalStateException("mDNS manager closed before discovery completed"))
270
+ scope.launch(mainDispatcher) {
271
+ try {
272
+ watchMutex.withLock { stopWatch() }
273
+ broadcastMutex.withLock {
274
+ stopActiveRegistration("mDNS manager closed before publish completed")
275
+ }
276
+ } catch (_: Throwable) {
277
+ // Keep the registration for cleanup if the OS confirms it after close.
278
+ } finally {
279
+ activeRegistration?.let { releaseRegistrationLock(it) }
280
+ onComplete()
281
+ if (externalScope == null) scope.cancel()
282
+ }
283
+ }
145
284
  }
146
- fun upsert(item: MdnsService) {
147
- val index = found.indexOfFirst { it.name == item.name && it.type == item.type && it.port == item.port }
148
- if (index >= 0) found[index] = item else found.add(item)
285
+ }
286
+
287
+ // ---- Registration helpers ------------------------------------------------
288
+
289
+ private fun handleRegistered(session: RegistrationSession, publishedName: String) {
290
+ if (session.finished) return
291
+ if (session.cancelled) {
292
+ // The caller gave up (stop/replace/timeout) before the OS confirmed: undo it now.
293
+ unregister(session)
294
+ return
149
295
  }
150
- fun unregisterServiceInfoCallbacks() {
151
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
152
- serviceInfoCallbacks.toList().forEach { safeUnregisterServiceInfoCallback(it) }
153
- serviceInfoCallbacks.clear()
296
+ completeRegistrationSuccess(session, publishedName)
297
+ }
298
+
299
+ private fun completeRegistrationSuccess(session: RegistrationSession, publishedName: String) {
300
+ if (session.completed) return
301
+ session.completed = true
302
+ session.timeoutJob?.cancel()
303
+ session.onSuccess(publishedName)
304
+ }
305
+
306
+ private fun completeRegistrationError(
307
+ session: RegistrationSession,
308
+ error: Throwable
309
+ ) {
310
+ if (session.completed) return
311
+ session.completed = true
312
+ session.timeoutJob?.cancel()
313
+ session.onError(error)
314
+ }
315
+
316
+ private suspend fun stopActiveRegistration(reason: String) {
317
+ val session = activeRegistration ?: return
318
+ session.cancelled = true
319
+ completeRegistrationError(session, IllegalStateException(reason))
320
+ if (session.stopped.isCompleted) session.stopped = CompletableDeferred()
321
+ // NsdManager identifies pending requests by their listener, so cancellation need not wait for confirmation.
322
+ unregister(session)
323
+ try {
324
+ withTimeout(stopTimeoutMs) { session.stopped.await() }?.let { throw it }
325
+ } catch (t: Throwable) {
326
+ releaseRegistrationLock(session)
327
+ if (t is TimeoutCancellationException) {
328
+ throw IllegalStateException("Timed out waiting for service stop after ${stopTimeoutMs}ms")
329
+ }
330
+ throw t
154
331
  }
155
- fun completeWithCurrentResults(discoveryListener: NsdManager.DiscoveryListener) {
156
- unregisterServiceInfoCallbacks()
157
- safeStopDiscovery(discoveryListener)
158
- if (!result.isCompleted) result.complete(found.toList())
332
+ }
333
+
334
+ private fun unregister(session: RegistrationSession) {
335
+ if (session.stopping) return
336
+ val token = session.token ?: return
337
+ session.stopping = true
338
+ try {
339
+ backend.unregisterService(token)
340
+ } catch (t: Throwable) {
341
+ session.stopping = false
342
+ session.stopped.complete(t)
159
343
  }
344
+ }
160
345
 
161
- @Suppress("DEPRECATION")
162
- fun resolveLegacy(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
163
- nsd.resolveService(si, object : NsdManager.ResolveListener {
164
- override fun onResolveFailed(s: NsdServiceInfo, errorCode: Int) { /* ignore */ }
346
+ private fun finishRegistration(session: RegistrationSession) {
347
+ session.finished = true
348
+ if (activeRegistration === session) activeRegistration = null
349
+ releaseRegistrationLock(session)
350
+ session.stopped.complete(null)
351
+ }
165
352
 
166
- override fun onServiceResolved(s: NsdServiceInfo) {
167
- upsert(toMdnsService(s))
353
+ private fun releaseRegistrationLock(session: RegistrationSession) {
354
+ if (!session.lockHeld) return
355
+ session.lockHeld = false
356
+ backend.releaseMulticastLock()
357
+ }
168
358
 
169
- // Early-exit for exact/prefix target match.
170
- if (targetName != null && matchesTarget(s.serviceName)) {
171
- completeWithCurrentResults(discoveryListener)
172
- }
173
- }
174
- })
359
+ // ---- Discovery session ---------------------------------------------------
360
+
361
+ /** Thanks to Idrimi for the original continuous discovery feature (PR #7). */
362
+ suspend fun watchServices(typesRaw: List<String>, listener: WatchListener): Int = watchMutex.withLock { withContext(mainDispatcher) {
363
+ check(!closed) { "mDNS manager is closed" }
364
+ require(typesRaw.isNotEmpty()) { "Missing or empty 'types' array" }
365
+ val types = typesRaw.map { MdnsValidation.type(it.also { raw -> require(raw.isNotBlank()) { "Invalid service type" } }) }.distinct()
366
+ stopWatch()?.let { throw it }
367
+ check(!closed) { "mDNS manager is closed" }
368
+ val session = DiscoverySession(types, null, listener)
369
+ activeWatch = session
370
+ try {
371
+ session.start(null)
372
+ types.size
373
+ } catch (t: Throwable) {
374
+ session.close()
375
+ if (activeWatch === session) activeWatch = null
376
+ throw t
175
377
  }
378
+ } }
379
+
380
+ suspend fun stopWatching(): Throwable? = watchMutex.withLock { withContext(mainDispatcher) { stopWatch() } }
381
+
382
+ private suspend fun stopWatch(): Throwable? {
383
+ val old = activeWatch
384
+ activeWatch = null
385
+ val errors = mutableListOf<Throwable>()
386
+ old?.close()
387
+ for ((token, initial) in discoveryStops.toMap()) {
388
+ val result = if (retryDiscoveryStops.remove(token)) requestDiscoveryStop(token) else initial
389
+ val error = try { withTimeout(stopTimeoutMs) { result.await() } }
390
+ catch (t: Throwable) { if (t is TimeoutCancellationException) IllegalStateException("Timed out waiting for discovery stop") else t }
391
+ if (error != null) { retryDiscoveryStops += token; errors += error }
392
+ }
393
+ return errors.firstOrNull()
394
+ }
176
395
 
177
- fun registerServiceInfoCallback(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
178
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
179
- resolveLegacy(si, discoveryListener)
180
- return
181
- }
396
+ private fun requestDiscoveryStop(token: Any): CompletableDeferred<Throwable?> {
397
+ val result = CompletableDeferred<Throwable?>()
398
+ discoveryStops[token] = result
399
+ try { backend.stopDiscovery(token) }
400
+ catch (t: Throwable) { result.complete(t) }
401
+ return result
402
+ }
182
403
 
183
- val callback = object : NsdManager.ServiceInfoCallback {
184
- override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { /* ignore */ }
185
- override fun onServiceInfoCallbackUnregistered() { /* no-op */ }
186
- override fun onServiceLost() { /* no-op */ }
404
+ /** Discovery and watching share the device's single legacy resolver slot. */
405
+ private fun enqueue(p: Pending) {
406
+ if (p.done) return
407
+ if (backend.supportsConcurrentResolve) startResolve(p)
408
+ else { resolveQueue.addLast(p); pump() }
409
+ }
187
410
 
188
- override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
189
- upsert(toMdnsService(serviceInfo))
411
+ private fun pump() {
412
+ if (resolving.isNotEmpty()) return
413
+ val p = resolveQueue.removeFirstOrNull() ?: return
414
+ startResolve(p)
415
+ }
190
416
 
191
- // Early-exit for exact/prefix target match.
192
- if (targetName != null && matchesTarget(serviceInfo.serviceName)) {
193
- completeWithCurrentResults(discoveryListener)
194
- }
417
+ private fun startResolve(p: Pending) {
418
+ if (p.done) return
419
+ val generation = ++p.generation
420
+ resolving.add(p)
421
+ p.timeoutJob = scope.launch(mainDispatcher) {
422
+ delay(resolveTimeoutMs)
423
+ if (p.generation != generation) return@launch
424
+ if (p.done) { finishResolve(p); pump(); return@launch }
425
+ resolveFailed(p, "Timed out resolving ${p.service.name}", retry = p.owner.isWatching)
426
+ }
427
+ val listener = object : NsdBackend.ResolveListener {
428
+ override fun onResolved(service: MdnsService) = runOnMain {
429
+ if (p.generation != generation || p !in resolving) return@runOnMain
430
+ if (p.done) { finishResolve(p); pump(); return@runOnMain }
431
+ if (service.hosts.isEmpty()) {
432
+ resolveFailed(p, "Resolved service has no IP address", retry = true)
433
+ return@runOnMain
195
434
  }
435
+ finishResolve(p)
436
+ p.owner.resolved(p, service)
437
+ pump()
196
438
  }
197
-
198
- serviceInfoCallbacks.add(callback)
199
- try {
200
- nsd.registerServiceInfoCallback(si, mainExecutor, callback)
201
- } catch (_: Throwable) {
202
- serviceInfoCallbacks.remove(callback)
439
+ override fun onFailed(errorCode: Int) = runOnMain {
440
+ if (p.generation != generation) return@runOnMain
441
+ if (p.done) { finishResolve(p); pump(); return@runOnMain }
442
+ resolveFailed(p, "Resolve failed: $errorCode", p.owner.isWatching || errorCode == NsdManager.FAILURE_ALREADY_ACTIVE)
203
443
  }
204
444
  }
445
+ try {
446
+ p.token = backend.resolve(p.service, listener)
447
+ if (p.done) p.token?.let { safeCancelResolve(it) }
448
+ } catch (t: Throwable) {
449
+ resolveFailed(p, t.message ?: "Resolve failed", retry = p.owner.isWatching)
450
+ }
451
+ }
205
452
 
206
- val listener = object : NsdManager.DiscoveryListener {
207
- override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
208
- // Fail fast: stop discovery and complete with whatever we have (likely empty).
209
- completeWithCurrentResults(this)
453
+ private fun resolveFailed(p: Pending, message: String, retry: Boolean) {
454
+ if (p.done || p !in resolving) return
455
+ p.timeoutJob?.cancel()
456
+ resolving.remove(p)
457
+ p.token?.let { safeCancelResolve(it) }
458
+ p.token = null
459
+ if (retry && p.attempts++ < MAX_RESOLVE_RETRIES) {
460
+ p.retryJob = scope.launch(mainDispatcher) {
461
+ delay(RESOLVE_RETRY_DELAY_MS)
462
+ enqueue(p)
210
463
  }
211
- override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { /* no-op */ }
212
- override fun onDiscoveryStarted(serviceType: String) { /* no-op */ }
213
- override fun onDiscoveryStopped(serviceType: String) { /* no-op */ }
464
+ } else {
465
+ p.done = true
466
+ p.owner.resolveFailed(p, message)
467
+ }
468
+ pump()
469
+ }
214
470
 
215
- override fun onServiceFound(si: NsdServiceInfo) {
216
- if (targetName != null && !matchesTarget(si.serviceName)) return
471
+ private fun finishResolve(p: Pending, cancelled: Boolean = false) {
472
+ p.done = true
473
+ p.retryJob?.cancel()
474
+ resolveQueue.remove(p)
475
+ // Legacy NSD cannot cancel a resolve; reserve its slot until its callback or timeout.
476
+ if (cancelled && !backend.supportsConcurrentResolve && p in resolving) return
477
+ p.timeoutJob?.cancel()
478
+ resolving.remove(p)
479
+ p.token?.let { safeCancelResolve(it) }
480
+ }
217
481
 
218
- val discoveryListener = this
219
- registerServiceInfoCallback(si, discoveryListener)
482
+ /** One browser group, with its own membership, callbacks and multicast lock. */
483
+ private inner class DiscoverySession(
484
+ private val types: List<String>,
485
+ private val targetName: String?,
486
+ private val watchListener: WatchListener? = null
487
+ ) {
488
+ val result = CompletableDeferred<List<MdnsService>>()
489
+ val isWatching get() = watchListener != null
490
+ private val found = linkedMapOf<String, MdnsService>()
491
+ private val pending = mutableMapOf<String, Pending>()
492
+ private val discoveryTokens = mutableListOf<Any>()
493
+ private var timeoutJob: Job? = null
494
+ private var closed = false
495
+ private var lockHeld = false
496
+
497
+ fun start(timeoutMs: Long?) {
498
+ backend.acquireMulticastLock()
499
+ lockHeld = true
500
+ if (timeoutMs != null) timeoutJob = scope.launch(mainDispatcher) {
501
+ delay(timeoutMs)
502
+ finish()
503
+ }
504
+ for (type in types) {
505
+ if (closed) return
506
+ var token: Any? = null
507
+ var startFailed = false
508
+ val listener = object : NsdBackend.DiscoveryListener {
509
+ override fun onStartFailed(errorCode: Int) = runOnMain {
510
+ startFailed = true
511
+ discoveryTokens.remove(token)
512
+ fail(IllegalStateException("Discovery failed to start: $errorCode"), type)
513
+ }
514
+ override fun onStopFailed(errorCode: Int) = runOnMain {
515
+ val error = IllegalStateException("Discovery failed to stop: $errorCode")
516
+ if (closed) token?.let { discoveryStops[it]?.complete(error) }
517
+ else fail(error, type)
518
+ }
519
+ override fun onStopped() = runOnMain {
520
+ token?.let { discoveryStops.remove(it)?.complete(null); retryDiscoveryStops.remove(it) }
521
+ if (!closed) fail(IllegalStateException("Discovery stopped unexpectedly"), type)
522
+ }
523
+ override fun onFound(service: DiscoveredService) = runOnMain {
524
+ handleFound(DiscoveredService(service.name, type, service.handle))
525
+ }
526
+ override fun onLost(service: DiscoveredService) = runOnMain {
527
+ if (closed || !isWatching) return@runOnMain
528
+ val key = key(service.name, type)
529
+ pending.remove(key)?.let { finishResolve(it, cancelled = true); pump() }
530
+ found.remove(key)?.let { watchListener?.onServiceLost(it) }
531
+ }
532
+ }
533
+ val startedToken = backend.startDiscovery(type, listener)
534
+ token = startedToken
535
+ if (startFailed) continue
536
+ if (closed) requestDiscoveryStop(startedToken) else discoveryTokens += startedToken
220
537
  }
221
-
222
- override fun onServiceLost(serviceInfo: NsdServiceInfo) { /* no-op */ }
223
538
  }
224
539
 
225
- // Start discovery and schedule timeout
226
- discListenerRef.set(listener)
227
- nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
540
+ fun finish() {
541
+ if (closed) return
542
+ val snapshot = found.values.toList()
543
+ close()
544
+ result.complete(snapshot)
545
+ }
228
546
 
229
- val timeoutJob = scope.launch {
230
- delay(timeoutMs.toLong())
231
- completeWithCurrentResults(listener)
547
+ fun fail(error: Throwable, type: String = types.first()) {
548
+ if (closed) return
549
+ close()
550
+ if (isWatching) {
551
+ if (activeWatch === this) activeWatch = null
552
+ watchListener?.onError(type, error.message ?: "Discovery failed", false)
553
+ } else result.completeExceptionally(error)
232
554
  }
233
555
 
234
- try {
235
- result.await()
236
- } finally {
237
- // Cleanup in all cases (early-exit or timeout)
238
- timeoutJob.cancel()
239
- unregisterServiceInfoCallbacks()
240
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
556
+ fun close() {
557
+ if (closed) return
558
+ closed = true
559
+ timeoutJob?.cancel()
560
+ pending.values.toList().forEach { finishResolve(it, cancelled = true) }
561
+ pending.clear()
562
+ pump()
563
+ for (token in discoveryTokens) {
564
+ requestDiscoveryStop(token)
565
+ }
566
+ discoveryTokens.clear()
567
+ if (lockHeld) {
568
+ lockHeld = false
569
+ backend.releaseMulticastLock()
570
+ }
241
571
  }
242
- }
243
572
 
244
- /**
245
- * Close the manager and release any outstanding NSD listeners.
246
- * Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
247
- */
248
- fun close() {
249
- stopBroadcast()
250
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
251
- if (externalScope == null) scope.cancel()
252
- }
573
+ private fun key(name: String, type: String) = "$name|$type"
253
574
 
254
- // ---- Internal helpers ----------------------------------------------------
575
+ private fun handleFound(service: DiscoveredService) {
576
+ if (closed || !matchesTarget(service.name)) return
577
+ val key = key(service.name, service.type)
578
+ if (key in found || key in pending) return
579
+ val p = Pending(this, service)
580
+ pending[key] = p
581
+ enqueue(p)
582
+ }
255
583
 
256
- /** Best-effort unregister; NSD may throw if already unregistered. */
257
- private fun safeUnregister(l: NsdManager.RegistrationListener) {
258
- try { nsd.unregisterService(l) } catch (_: Throwable) {}
259
- }
584
+ fun resolved(p: Pending, service: MdnsService) {
585
+ val key = key(p.service.name, p.service.type)
586
+ if (closed || pending.remove(key) !== p) return
587
+ val item = service.copy(type = p.service.type, hosts = service.hosts.sortedBy { ':' in it }, txt = service.txt.toMap())
588
+ found[key] = item
589
+ if (isWatching) watchListener?.onServiceFound(item)
590
+ else if (targetName != null && matchesTarget(item.name)) finish()
591
+ }
260
592
 
261
- /** Best-effort stop discovery; NSD may throw if discovery is not active. */
262
- private fun safeStopDiscovery(l: NsdManager.DiscoveryListener) {
263
- try { nsd.stopServiceDiscovery(l) } catch (_: Throwable) {}
264
- }
593
+ fun resolveFailed(p: Pending, message: String) {
594
+ if (closed || pending.remove(key(p.service.name, p.service.type)) !== p) return
595
+ watchListener?.onError(p.service.type, message, true)
596
+ }
265
597
 
266
- /** Best-effort stop service info updates; NSD may throw if not registered. */
267
- private fun safeUnregisterServiceInfoCallback(l: NsdManager.ServiceInfoCallback) {
268
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
269
- try { nsd.unregisterServiceInfoCallback(l) } catch (_: Throwable) {}
598
+ private fun matchesTarget(candidate: String): Boolean {
599
+ val target = targetName ?: return true
600
+ return normalize(candidate).startsWith(normalize(target))
270
601
  }
271
602
  }
272
603
 
273
- private fun toMdnsService(s: NsdServiceInfo): MdnsService =
274
- MdnsService(
275
- name = s.serviceName,
276
- type = s.serviceType,
277
- hosts = hostAddresses(s),
278
- port = s.port
279
- )
280
-
281
- private fun hostAddresses(s: NsdServiceInfo): List<String> =
282
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
283
- s.hostAddresses.mapNotNull { it.hostAddress }
284
- } else {
285
- legacyHostAddress(s)?.let { listOf(it) } ?: emptyList()
286
- }
604
+ /** Normalization for the " (n)" suffix appended by the OS. */
605
+ private fun normalize(s: String): String = s.replace(NAME_SUFFIX, "").replace('.', '-')
287
606
 
288
- @Suppress("DEPRECATION")
289
- private fun legacyHostAddress(s: NsdServiceInfo): String? = s.host?.hostAddress
607
+ /** Best-effort stop service info updates; NSD may throw if not registered. */
608
+ private fun safeCancelResolve(token: Any) {
609
+ try { backend.cancelResolve(token) } catch (_: Throwable) {}
610
+ }
290
611
  }