@devioarts/capacitor-mdns 0.1.0 → 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 (43) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +221 -37
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +467 -243
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +80 -57
  12. package/dist/docs.json +250 -14
  13. package/dist/esm/definitions.d.ts +64 -17
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/index.js +22 -2
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/web.d.ts +6 -1
  18. package/dist/esm/web.js +23 -0
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +45 -2
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +45 -2
  23. package/dist/plugin.js.map +1 -1
  24. package/docs/electron.md +29 -3
  25. package/docs/platform-setup.md +6 -0
  26. package/docs/playground.md +33 -2
  27. package/docs/runtime-behavior.md +68 -2
  28. package/electron/dist/plugin-settings.js +10 -3
  29. package/electron/dist/plugin-settings.js.map +1 -1
  30. package/electron/dist/plugin.cjs.js +519 -89
  31. package/electron/dist/plugin.cjs.js.map +1 -1
  32. package/electron/mdns-bridge.cjs +25 -2
  33. package/electron/mdns.ts +514 -81
  34. package/electron/rollup.config.mjs +1 -1
  35. package/electron/src/index.ts +1 -0
  36. package/electron/src/plugin-settings.ts +10 -3
  37. package/electron/types-public/index.d.ts +25 -3
  38. package/ios/Sources/mDNSPlugin/mDNS.swift +407 -117
  39. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +77 -24
  40. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  41. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  42. package/package.json +11 -4
  43. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -9
@@ -3,15 +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.*
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
11
16
  import kotlinx.coroutines.sync.Mutex
12
17
  import kotlinx.coroutines.sync.withLock
13
- import java.util.concurrent.Executor
14
- import java.util.concurrent.atomic.AtomicReference
18
+ import kotlinx.coroutines.withContext
19
+ import kotlinx.coroutines.withTimeout
15
20
 
16
21
  /**
17
22
  * Thin Android NSD (Bonjour/mDNS) wrapper used by the Capacitor bridge.
@@ -23,18 +28,38 @@ import java.util.concurrent.atomic.AtomicReference
23
28
  * - Timebox discovery; early-exit when the target match is resolved.
24
29
  *
25
30
  * Threading:
26
- * - All public APIs are main-thread safe. Calls hop to the main looper internally,
27
- * 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.
28
34
  *
29
35
  * Platform notes:
30
- * - 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.
31
38
  */
32
- class mDNS(
33
- context: Context,
34
- 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
35
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
+
36
57
  private companion object {
37
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+\)$""")
38
63
  }
39
64
 
40
65
  /** Normalized service representation returned to the bridge. */
@@ -42,37 +67,63 @@ class mDNS(
42
67
  val name: String,
43
68
  val type: String,
44
69
  val hosts: List<String>, // numeric addresses (v4/v6)
45
- val port: Int
70
+ val port: Int,
71
+ val txt: Map<String, String> = emptyMap()
46
72
  )
47
73
 
48
74
  private class RegistrationSession(
49
- val listener: NsdManager.RegistrationListener,
50
- val timeoutJob: Job,
51
75
  val onSuccess: (String) -> Unit,
52
76
  val onError: (Throwable) -> Unit
53
77
  ) {
78
+ var timeoutJob: Job? = null
79
+ var token: Any? = null
54
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
55
87
  }
56
88
 
57
- /** NSD system service. */
58
- private val nsd: NsdManager =
59
- context.getSystemService(Context.NSD_SERVICE) as NsdManager
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
+ }
60
95
 
61
- /** Keep strong references to listeners so they outlive registration/discovery. */
62
- private var activeRegistration: RegistrationSession? = null
63
- private val discListenerRef = AtomicReference<NsdManager.DiscoveryListener?>(null)
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
+ }
64
104
 
65
105
  /** Optional external scope (from plugin); otherwise create our own on Main. */
66
- private val scope = externalScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
67
- private val discoverMutex = Mutex()
68
-
69
- /** Main looper utilities for non-suspending entry points. */
70
- private val mainHandler = Handler(Looper.getMainLooper())
71
- private val mainExecutor = Executor { command -> mainHandler.post(command) }
72
- private inline fun runOnMain(crossinline block: () -> Unit) {
73
- if (Looper.myLooper() === Looper.getMainLooper()) block() else mainHandler.post { block() }
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)
74
123
  }
75
124
 
125
+ // ---- Broadcast -----------------------------------------------------------
126
+
76
127
  /**
77
128
  * Start advertising a service. If already registered, the previous one is unregistered first.
78
129
  *
@@ -87,59 +138,67 @@ class mDNS(
87
138
  name: String,
88
139
  port: Int,
89
140
  onSuccess: (String) -> Unit,
90
- onError: (Throwable) -> Unit
141
+ onError: (Throwable) -> Unit,
142
+ txt: Map<String, String> = emptyMap()
91
143
  ) {
92
144
  require(port in 1..65535) { "Port must be between 1 and 65535" }
93
- val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
94
-
95
- runOnMain {
96
- try {
97
- // Restarting while publish is pending must complete the old JS promise.
98
- stopActiveRegistration("Replaced by a new broadcast request")
145
+ val type = MdnsValidation.type(typeRaw)
146
+ val normalizedName = MdnsValidation.name(name).ifEmpty { "DevIOArtsMDNS" }
147
+ MdnsValidation.txt(txt)
99
148
 
100
- val info = NsdServiceInfo().apply {
101
- serviceType = type
102
- serviceName = name
103
- setPort(port)
104
- }
105
-
106
- lateinit var session: RegistrationSession
107
- val listener = object : NsdManager.RegistrationListener {
108
- override fun onServiceRegistered(nsi: NsdServiceInfo) =
109
- completeRegistrationSuccess(session, nsi.serviceName)
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
+ }
110
169
 
111
- override fun onRegistrationFailed(nsi: NsdServiceInfo, errorCode: Int) =
112
- completeRegistrationError(
113
- session,
114
- IllegalStateException("Registration failed: $errorCode"),
115
- clearActive = true
116
- )
170
+ override fun onUnregistered() = runOnMain { finishRegistration(session) }
117
171
 
118
- override fun onServiceUnregistered(nsi: NsdServiceInfo) { /* no-op */ }
119
- override fun onUnregistrationFailed(nsi: NsdServiceInfo, errorCode: Int) { /* no-op */ }
120
- }
172
+ override fun onUnregistrationFailed(errorCode: Int) = runOnMain {
173
+ session.stopping = false
174
+ session.stopped.complete(IllegalStateException("Unregistration failed: $errorCode"))
175
+ }
176
+ }
121
177
 
122
- val timeoutJob = scope.launch {
123
- delay(PUBLISH_TIMEOUT_MS)
124
- runOnMain {
178
+ session.timeoutJob = scope.launch(mainDispatcher) {
179
+ delay(publishTimeoutMs)
180
+ session.cancelled = true
125
181
  completeRegistrationError(
126
182
  session,
127
- IllegalStateException("Timed out waiting for service publish after ${PUBLISH_TIMEOUT_MS}ms"),
128
- clearActive = true,
129
- unregister = true
183
+ IllegalStateException("Timed out waiting for service publish after ${publishTimeoutMs}ms")
130
184
  )
185
+ unregister(session)
186
+ releaseRegistrationLock(session)
131
187
  }
132
- }
133
188
 
134
- session = RegistrationSession(listener, timeoutJob, onSuccess, onError)
135
- activeRegistration = session
136
- try {
137
- nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
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
+ }
138
199
  } catch (t: Throwable) {
139
- completeRegistrationError(session, t, clearActive = true)
200
+ onError(t)
140
201
  }
141
- } catch (t: Throwable) {
142
- onError(t)
143
202
  }
144
203
  }
145
204
  }
@@ -147,17 +206,25 @@ class mDNS(
147
206
  /**
148
207
  * Stop advertising the currently registered service (if any). Safe to call multiple times.
149
208
  */
150
- fun stopBroadcast() {
151
- runOnMain {
152
- stopActiveRegistration("Publish stopped before completion")
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
+ }
153
218
  }
154
219
  }
155
220
 
221
+ // ---- Discover ------------------------------------------------------------
222
+
156
223
  /**
157
224
  * Discover services, optionally filtering by instance name using a normalized exact-or-prefix match.
158
225
  *
159
226
  * Behavior:
160
- * - Runs on the main dispatcher.
227
+ * - Runs on the main dispatcher; a new valid call replaces the previous one.
161
228
  * - Resolves candidates to host/port.
162
229
  * - If `targetName` is provided, compare using normalized names and accept exact OR prefix match.
163
230
  * - Early-exits when a matching service is resolved (stops discovery immediately).
@@ -172,216 +239,373 @@ class mDNS(
172
239
  typeRaw: String,
173
240
  targetName: String? = null,
174
241
  timeoutMs: Int = 3000
175
- ): List<MdnsService> = discoverMutex.withLock { withContext(Dispatchers.Main.immediate) {
176
- val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
177
-
178
- // Prepare state
179
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
180
- val found = mutableListOf<MdnsService>()
181
- val serviceInfoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>()
182
- val result = CompletableDeferred<List<MdnsService>>()
183
-
184
- // Normalization for "(n)" suffix appended by the OS.
185
- fun normalize(s: String): String = s.replace(Regex(" \\(\\d+\\)\$"), "")
186
- fun matchesTarget(candidate: String): Boolean {
187
- val c = normalize(candidate)
188
- val t = normalize(targetName ?: return true) // if no target, accept all
189
- return (c == t) || c.startsWith(t)
190
- }
191
- fun upsert(item: MdnsService) {
192
- val index = found.indexOfFirst { it.name == item.name && it.type == item.type && it.port == item.port }
193
- if (index >= 0) found[index] = item else found.add(item)
194
- }
195
- fun unregisterServiceInfoCallbacks() {
196
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
197
- serviceInfoCallbacks.toList().forEach { safeUnregisterServiceInfoCallback(it) }
198
- serviceInfoCallbacks.clear()
199
- }
200
- fun completeWithCurrentResults(discoveryListener: NsdManager.DiscoveryListener) {
201
- unregisterServiceInfoCallbacks()
202
- safeStopDiscovery(discoveryListener)
203
- discListenerRef.compareAndSet(discoveryListener, null)
204
- if (!result.isCompleted) result.complete(found.toList())
205
- }
206
- fun completeWithError(discoveryListener: NsdManager.DiscoveryListener, error: Throwable) {
207
- unregisterServiceInfoCallbacks()
208
- safeStopDiscovery(discoveryListener)
209
- discListenerRef.compareAndSet(discoveryListener, null)
210
- if (!result.isCompleted) result.completeExceptionally(error)
211
- }
212
-
213
- @Suppress("DEPRECATION")
214
- fun resolveLegacy(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
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
215
249
  try {
216
- nsd.resolveService(si, object : NsdManager.ResolveListener {
217
- override fun onResolveFailed(s: NsdServiceInfo, errorCode: Int) { /* ignore */ }
218
-
219
- override fun onServiceResolved(s: NsdServiceInfo) {
220
- if (result.isCompleted) return
221
- upsert(toMdnsService(s))
222
-
223
- // Early-exit for exact/prefix target match.
224
- if (targetName != null && matchesTarget(s.serviceName)) {
225
- completeWithCurrentResults(discoveryListener)
226
- }
227
- }
228
- })
229
- } catch (_: Throwable) {
230
- // Some Android releases throw when the NSD daemon is busy; ignore this candidate.
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
231
256
  }
232
- }
233
-
234
- fun registerServiceInfoCallback(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
235
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
236
- resolveLegacy(si, discoveryListener)
237
- return
238
- }
239
-
240
- val callback = object : NsdManager.ServiceInfoCallback {
241
- override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { /* ignore */ }
242
- override fun onServiceInfoCallbackUnregistered() { /* no-op */ }
243
- override fun onServiceLost() { /* no-op */ }
244
-
245
- override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
246
- if (result.isCompleted) return
247
- upsert(toMdnsService(serviceInfo))
257
+ }
248
258
 
249
- // Early-exit for exact/prefix target match.
250
- if (targetName != null && matchesTarget(serviceInfo.serviceName)) {
251
- completeWithCurrentResults(discoveryListener)
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")
252
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()
253
282
  }
254
283
  }
255
-
256
- serviceInfoCallbacks.add(callback)
257
- try {
258
- nsd.registerServiceInfoCallback(si, mainExecutor, callback)
259
- } catch (_: Throwable) {
260
- serviceInfoCallbacks.remove(callback)
261
- }
262
284
  }
285
+ }
263
286
 
264
- val listener = object : NsdManager.DiscoveryListener {
265
- override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
266
- completeWithError(this, IllegalStateException("Discovery failed to start: $errorCode"))
267
- }
268
- override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { /* no-op */ }
269
- override fun onDiscoveryStarted(serviceType: String) { /* no-op */ }
270
- override fun onDiscoveryStopped(serviceType: String) { /* no-op */ }
287
+ // ---- Registration helpers ------------------------------------------------
271
288
 
272
- override fun onServiceFound(si: NsdServiceInfo) {
273
- if (result.isCompleted) return
274
- if (targetName != null && !matchesTarget(si.serviceName)) return
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
295
+ }
296
+ completeRegistrationSuccess(session, publishedName)
297
+ }
275
298
 
276
- val discoveryListener = this
277
- registerServiceInfoCallback(si, discoveryListener)
278
- }
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
+ }
279
305
 
280
- override fun onServiceLost(serviceInfo: NsdServiceInfo) { /* no-op */ }
281
- }
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
+ }
282
315
 
283
- // Start discovery and schedule timeout
284
- discListenerRef.set(listener)
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)
285
323
  try {
286
- nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
324
+ withTimeout(stopTimeoutMs) { session.stopped.await() }?.let { throw it }
287
325
  } catch (t: Throwable) {
288
- discListenerRef.compareAndSet(listener, null)
326
+ releaseRegistrationLock(session)
327
+ if (t is TimeoutCancellationException) {
328
+ throw IllegalStateException("Timed out waiting for service stop after ${stopTimeoutMs}ms")
329
+ }
289
330
  throw t
290
331
  }
332
+ }
291
333
 
292
- val timeoutJob = scope.launch {
293
- delay(timeoutMs.coerceAtLeast(0).toLong())
294
- completeWithCurrentResults(listener)
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)
295
343
  }
344
+ }
345
+
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
+ }
296
352
 
353
+ private fun releaseRegistrationLock(session: RegistrationSession) {
354
+ if (!session.lockHeld) return
355
+ session.lockHeld = false
356
+ backend.releaseMulticastLock()
357
+ }
358
+
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
297
370
  try {
298
- result.await()
299
- } finally {
300
- // Cleanup in all cases (early-exit or timeout)
301
- timeoutJob.cancel()
302
- unregisterServiceInfoCallbacks()
303
- if (discListenerRef.compareAndSet(listener, null)) safeStopDiscovery(listener)
371
+ session.start(null)
372
+ types.size
373
+ } catch (t: Throwable) {
374
+ session.close()
375
+ if (activeWatch === session) activeWatch = null
376
+ throw t
304
377
  }
305
378
  } }
306
379
 
307
- /**
308
- * Close the manager and release any outstanding NSD listeners.
309
- * Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
310
- */
311
- fun close() {
312
- runOnMain {
313
- stopActiveRegistration("mDNS manager closed before publish completed")
314
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
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 }
315
392
  }
316
- if (externalScope == null) scope.cancel()
393
+ return errors.firstOrNull()
317
394
  }
318
395
 
319
- // ---- Internal helpers ----------------------------------------------------
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
+ }
320
403
 
321
- /** Best-effort unregister; NSD may throw if already unregistered. */
322
- private fun safeUnregister(l: NsdManager.RegistrationListener) {
323
- try { nsd.unregisterService(l) } catch (_: Throwable) {}
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() }
324
409
  }
325
410
 
326
- private fun completeRegistrationSuccess(session: RegistrationSession, publishedName: String) {
327
- if (session.completed) return
328
- session.completed = true
329
- session.timeoutJob.cancel()
330
- session.onSuccess(publishedName)
411
+ private fun pump() {
412
+ if (resolving.isNotEmpty()) return
413
+ val p = resolveQueue.removeFirstOrNull() ?: return
414
+ startResolve(p)
331
415
  }
332
416
 
333
- private fun completeRegistrationError(
334
- session: RegistrationSession,
335
- error: Throwable,
336
- clearActive: Boolean,
337
- unregister: Boolean = false
338
- ) {
339
- if (session.completed) return
340
- session.completed = true
341
- session.timeoutJob.cancel()
342
- if (unregister) safeUnregister(session.listener)
343
- if (clearActive && activeRegistration === session) activeRegistration = null
344
- session.onError(error)
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
434
+ }
435
+ finishResolve(p)
436
+ p.owner.resolved(p, service)
437
+ pump()
438
+ }
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)
443
+ }
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
+ }
345
451
  }
346
452
 
347
- private fun stopActiveRegistration(reason: String) {
348
- val session = activeRegistration ?: return
349
- activeRegistration = null
350
- safeUnregister(session.listener)
351
- if (!session.completed) {
352
- completeRegistrationError(session, IllegalStateException(reason), clearActive = false)
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)
463
+ }
353
464
  } else {
354
- session.timeoutJob.cancel()
465
+ p.done = true
466
+ p.owner.resolveFailed(p, message)
355
467
  }
468
+ pump()
356
469
  }
357
470
 
358
- /** Best-effort stop discovery; NSD may throw if discovery is not active. */
359
- private fun safeStopDiscovery(l: NsdManager.DiscoveryListener) {
360
- try { nsd.stopServiceDiscovery(l) } catch (_: Throwable) {}
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) }
361
480
  }
362
481
 
363
- /** Best-effort stop service info updates; NSD may throw if not registered. */
364
- private fun safeUnregisterServiceInfoCallback(l: NsdManager.ServiceInfoCallback) {
365
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
366
- try { nsd.unregisterServiceInfoCallback(l) } catch (_: Throwable) {}
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
537
+ }
367
538
  }
368
- }
369
539
 
370
- private fun toMdnsService(s: NsdServiceInfo): MdnsService =
371
- MdnsService(
372
- name = s.serviceName,
373
- type = s.serviceType,
374
- hosts = hostAddresses(s),
375
- port = s.port
376
- )
377
-
378
- private fun hostAddresses(s: NsdServiceInfo): List<String> =
379
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
380
- s.hostAddresses.mapNotNull { it.hostAddress }
381
- } else {
382
- legacyHostAddress(s)?.let { listOf(it) } ?: emptyList()
540
+ fun finish() {
541
+ if (closed) return
542
+ val snapshot = found.values.toList()
543
+ close()
544
+ result.complete(snapshot)
545
+ }
546
+
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)
383
554
  }
384
555
 
385
- @Suppress("DEPRECATION")
386
- private fun legacyHostAddress(s: NsdServiceInfo): String? = s.host?.hostAddress
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
+ }
571
+ }
572
+
573
+ private fun key(name: String, type: String) = "$name|$type"
574
+
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
+ }
583
+
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
+ }
592
+
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
+ }
597
+
598
+ private fun matchesTarget(candidate: String): Boolean {
599
+ val target = targetName ?: return true
600
+ return normalize(candidate).startsWith(normalize(target))
601
+ }
602
+ }
603
+
604
+ /** Normalization for the " (n)" suffix appended by the OS. */
605
+ private fun normalize(s: String): String = s.replace(NAME_SUFFIX, "").replace('.', '-')
606
+
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
+ }
387
611
  }