-
Notifications
You must be signed in to change notification settings - Fork 10
feat(mobile): add composer-first threads and daemon discovery (CODE-587) #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5a1f87e
d5518de
f3dd874
e56a41f
67ee27f
f97d26c
e0293a4
b5eb2a3
be332d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| The MIT License (MIT) | ||
|
|
||
| Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| plugins { | ||
| id 'com.android.library' | ||
| id 'expo-module-gradle-plugin' | ||
| } | ||
|
|
||
| group = 'expo.modules.linkcodedaemondiscovery' | ||
| version = '0.1.0' | ||
|
|
||
| android { | ||
| namespace "expo.modules.linkcodedaemondiscovery" | ||
| defaultConfig { | ||
| versionCode 1 | ||
| versionName "0.1.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| <manifest> | ||
| </manifest> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| package expo.modules.linkcodedaemondiscovery | ||
|
|
||
| import android.content.Context | ||
| import android.net.nsd.NsdManager | ||
| import android.net.nsd.NsdServiceInfo | ||
| import android.net.wifi.WifiManager | ||
| import android.os.Bundle | ||
| import android.os.Handler | ||
| import android.os.Looper | ||
| import expo.modules.kotlin.modules.Module | ||
| import expo.modules.kotlin.modules.ModuleDefinition | ||
| import java.util.ArrayDeque | ||
|
|
||
| private const val EVENT_NAME = "onHostsChanged" | ||
| private const val SERVICE_TYPE = "_linkcode._tcp." | ||
| private const val MULTICAST_LOCK_TAG = "LinkCodeDaemonDiscovery" | ||
|
|
||
| private data class DiscoveredDaemon( | ||
| val id: String, | ||
| val name: String, | ||
| val host: String, | ||
| val port: Int | ||
| ) | ||
|
|
||
| class LinkCodeDaemonDiscoveryModule : Module() { | ||
| private val handler = Handler(Looper.getMainLooper()) | ||
| private val hosts = mutableMapOf<String, DiscoveredDaemon>() | ||
| private val activeServiceNames = mutableSetOf<String>() | ||
| private val pendingServices = ArrayDeque<NsdServiceInfo>() | ||
|
|
||
| private var manager: NsdManager? = null | ||
| private var discoveryListener: NsdManager.DiscoveryListener? = null | ||
| private var multicastLock: WifiManager.MulticastLock? = null | ||
| private var generation = 0 | ||
| private var isObserved = false | ||
| private var isInForeground = true | ||
| private var isResolving = false | ||
| private var status = "searching" | ||
| private var errorCode: String? = null | ||
|
|
||
| override fun definition() = ModuleDefinition { | ||
| Name("LinkCodeDaemonDiscovery") | ||
|
|
||
| Events(EVENT_NAME) | ||
|
|
||
| OnStartObserving(EVENT_NAME) { | ||
| handler.post { | ||
| isObserved = true | ||
| startDiscovery() | ||
| } | ||
| } | ||
|
|
||
| OnStopObserving(EVENT_NAME) { | ||
| handler.post { | ||
| isObserved = false | ||
| stopDiscovery() | ||
| } | ||
| } | ||
|
|
||
| OnActivityEntersBackground { | ||
| handler.post { | ||
| isInForeground = false | ||
| stopDiscovery() | ||
| } | ||
| } | ||
|
|
||
| OnActivityEntersForeground { | ||
| handler.post { | ||
| isInForeground = true | ||
| if (isObserved) { | ||
| startDiscovery() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| OnDestroy { | ||
| handler.post { | ||
| isObserved = false | ||
| isInForeground = false | ||
| stopDiscovery() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun startDiscovery() { | ||
| if (discoveryListener != null) { | ||
| emitSnapshot() | ||
| return | ||
| } | ||
|
|
||
| val context = appContext.reactContext?.applicationContext | ||
| val nsdManager = context?.getSystemService(Context.NSD_SERVICE) as? NsdManager | ||
| if (context == null || nsdManager == null) { | ||
| status = "error" | ||
| errorCode = "unavailable" | ||
| emitSnapshot() | ||
| return | ||
| } | ||
|
|
||
| generation += 1 | ||
| val currentGeneration = generation | ||
| manager = nsdManager | ||
| hosts.clear() | ||
| activeServiceNames.clear() | ||
| pendingServices.clear() | ||
| isResolving = false | ||
| status = "searching" | ||
| errorCode = null | ||
| emitSnapshot() | ||
| acquireMulticastLock(context) | ||
|
|
||
| val listener = object : NsdManager.DiscoveryListener { | ||
| override fun onDiscoveryStarted(serviceType: String) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| status = "ready" | ||
| errorCode = null | ||
| emitSnapshot() | ||
| } | ||
| } | ||
|
|
||
| override fun onServiceFound(serviceInfo: NsdServiceInfo) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| val serviceName = serviceInfo.serviceName ?: return@post | ||
| if (activeServiceNames.add(serviceName)) { | ||
| pendingServices.addLast(serviceInfo) | ||
| resolveNext(currentGeneration) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onServiceLost(serviceInfo: NsdServiceInfo) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| val serviceName = serviceInfo.serviceName ?: return@post | ||
| activeServiceNames.remove(serviceName) | ||
| if (hosts.remove(serviceName) != null) { | ||
| emitSnapshot() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onStartDiscoveryFailed(serviceType: String, error: Int) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| discoveryListener = null | ||
| status = "error" | ||
| errorCode = "failed" | ||
| releaseMulticastLock() | ||
| emitSnapshot() | ||
| } | ||
| } | ||
|
|
||
| override fun onStopDiscoveryFailed(serviceType: String, error: Int) { | ||
| handler.post { retryStop(this) } | ||
| } | ||
|
|
||
| override fun onDiscoveryStopped(serviceType: String) { | ||
| handler.post { completeStop(this) } | ||
| } | ||
| } | ||
|
|
||
| discoveryListener = listener | ||
| try { | ||
| nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) | ||
| } catch (_: SecurityException) { | ||
| discoveryListener = null | ||
| status = "error" | ||
| errorCode = "permissionDenied" | ||
| releaseMulticastLock() | ||
| emitSnapshot() | ||
| } catch (_: RuntimeException) { | ||
| discoveryListener = null | ||
| status = "error" | ||
| errorCode = "failed" | ||
| releaseMulticastLock() | ||
| emitSnapshot() | ||
| } | ||
| } | ||
|
|
||
| private fun stopDiscovery() { | ||
| generation += 1 | ||
| val listener = discoveryListener | ||
| isResolving = false | ||
| pendingServices.clear() | ||
| activeServiceNames.clear() | ||
| hosts.clear() | ||
| if (listener == null) { | ||
| releaseMulticastLock() | ||
| } else { | ||
| requestStop(listener) | ||
| } | ||
| } | ||
|
|
||
| private fun requestStop(listener: NsdManager.DiscoveryListener) { | ||
| if (discoveryListener !== listener) return | ||
|
|
||
| try { | ||
| manager?.stopServiceDiscovery(listener) | ||
| } catch (_: IllegalArgumentException) { | ||
| completeStop(listener) | ||
| } catch (_: SecurityException) { | ||
| completeStop(listener) | ||
| } catch (_: RuntimeException) { | ||
| retryStop(listener) | ||
| } | ||
| } | ||
|
|
||
| private fun retryStop(listener: NsdManager.DiscoveryListener) { | ||
| if (discoveryListener !== listener) return | ||
| handler.postDelayed({ requestStop(listener) }, 500) | ||
| } | ||
|
|
||
| private fun completeStop(listener: NsdManager.DiscoveryListener) { | ||
| if (discoveryListener !== listener) return | ||
| discoveryListener = null | ||
| releaseMulticastLock() | ||
| if (isObserved && isInForeground) { | ||
| startDiscovery() | ||
| } | ||
| } | ||
|
|
||
| // The executor-based resolver starts at API 34; this callback is required by the API 24 floor. | ||
| @Suppress("DEPRECATION") | ||
| private fun resolveNext(currentGeneration: Int) { | ||
| if (isResolving || currentGeneration != generation) return | ||
|
|
||
| val serviceInfo = pendingServices.pollFirst() ?: return | ||
| val serviceName = serviceInfo.serviceName ?: run { | ||
| resolveNext(currentGeneration) | ||
| return | ||
| } | ||
| if (!activeServiceNames.contains(serviceName)) { | ||
| resolveNext(currentGeneration) | ||
| return | ||
| } | ||
|
|
||
| val nsdManager = manager ?: return | ||
| isResolving = true | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The deprecated callback form of To be fair to the current code, this does self-heal: |
||
| try { | ||
| nsdManager.resolveService( | ||
| serviceInfo, | ||
| object : NsdManager.ResolveListener { | ||
| override fun onResolveFailed(serviceInfo: NsdServiceInfo, error: Int) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| isResolving = false | ||
| resolveNext(currentGeneration) | ||
| } | ||
| } | ||
|
|
||
| override fun onServiceResolved(serviceInfo: NsdServiceInfo) { | ||
| handler.post { | ||
| if (currentGeneration != generation) return@post | ||
| isResolving = false | ||
| val resolvedName = serviceInfo.serviceName ?: serviceName | ||
| val host = serviceInfo.host?.hostAddress | ||
| val port = serviceInfo.port | ||
| if ( | ||
| activeServiceNames.contains(resolvedName) && | ||
| !host.isNullOrBlank() && | ||
| port in 1..65_535 | ||
| ) { | ||
| hosts[resolvedName] = DiscoveredDaemon( | ||
| id = resolvedName, | ||
| name = resolvedName, | ||
| host = host, | ||
| port = port | ||
| ) | ||
| emitSnapshot() | ||
| } | ||
| resolveNext(currentGeneration) | ||
| } | ||
| } | ||
| } | ||
| ) | ||
| } catch (_: SecurityException) { | ||
| isResolving = false | ||
| status = "error" | ||
| errorCode = "permissionDenied" | ||
| emitSnapshot() | ||
| } catch (_: RuntimeException) { | ||
| isResolving = false | ||
| resolveNext(currentGeneration) | ||
| } | ||
| } | ||
|
|
||
| private fun acquireMulticastLock(context: Context) { | ||
| val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return | ||
| multicastLock = wifiManager.createMulticastLock(MULTICAST_LOCK_TAG).apply { | ||
| setReferenceCounted(false) | ||
| acquire() | ||
| } | ||
| } | ||
|
|
||
| private fun releaseMulticastLock() { | ||
| multicastLock?.let { lock -> | ||
| if (lock.isHeld) { | ||
| lock.release() | ||
| } | ||
| } | ||
| multicastLock = null | ||
| } | ||
|
|
||
| private fun emitSnapshot() { | ||
| val serializedHosts = ArrayList( | ||
| hosts.values | ||
| .sortedBy { it.name.lowercase() } | ||
| .map { host -> | ||
| Bundle().apply { | ||
| putString("id", host.id) | ||
| putString("name", host.name) | ||
| putString("host", host.host) | ||
| putInt("port", host.port) | ||
| } | ||
| } | ||
| ) | ||
| val payload = Bundle().apply { | ||
| putString("status", status) | ||
| putParcelableArrayList("hosts", serializedHosts) | ||
| errorCode?.let { putString("error", it) } | ||
| } | ||
| sendEvent(EVENT_NAME, payload) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "platforms": ["apple", "android"], | ||
| "apple": { | ||
| "modules": ["LinkCodeDaemonDiscoveryModule"] | ||
| }, | ||
| "android": { | ||
| "modules": ["expo.modules.linkcodedaemondiscovery.LinkCodeDaemonDiscoveryModule"] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export type { | ||
| DaemonDiscoveryError, | ||
| DaemonDiscoverySnapshot, | ||
| DaemonDiscoveryStatus, | ||
| LinkCodeDaemonDiscoveryModuleEvents, | ||
| NativeDiscoveredDaemon, | ||
| } from './src/LinkCodeDaemonDiscovery.types'; | ||
| export { default } from './src/LinkCodeDaemonDiscoveryModule'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This retry loop has no attempt counter and no deadline, and the wedged state it can reach is unrecoverable within the process.
The cycle is
onStopDiscoveryFailed(line 156) →retryStop→postDelayed(requestStop, 500)→ andrequestStop'sRuntimeExceptioncatch routes straight back toretryStop(line 206). Nothing bounds it.What makes that costly is that
completeStop(line 215) is the only nominal path that does both of these:So while the loop spins:
discoveryListenerstays non-null, which meansstartDiscovery'sdiscoveryListener != nullguard makes every subsequent start a no-op that only re-emits the stale snapshot — discovery is off for good. And theMulticastLockis held the whole time, which is a battery cost and can interfere with other apps' multicast reception.This needs a genuinely broken NSD stack to trigger, which does happen on some OEM builds, but the failure mode is silent and permanent rather than degraded. Capping the attempts (or a bounded overall deadline) and calling
completeStopon exhaustion would bound the damage to "discovery stopped" instead of "discovery stopped and the lock is still held."