diff --git a/app/src/androidTest/java/com/gaurav/avnc/viewmodel/service/DiscoveryTest.kt b/app/src/androidTest/java/com/gaurav/avnc/viewmodel/service/DiscoveryTest.kt new file mode 100644 index 0000000..eefd84b --- /dev/null +++ b/app/src/androidTest/java/com/gaurav/avnc/viewmodel/service/DiscoveryTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 Gaurav Ujjwal. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * See COPYING.txt for more details. + */ + +package com.gaurav.avnc.viewmodel.service + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import androidx.core.content.ContextCompat +import com.gaurav.avnc.pollingAssert +import com.gaurav.avnc.runOnMainSync +import com.gaurav.avnc.targetContext +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Before +import org.junit.Test + +class DiscoveryTest { + private val TAG = "DiscoveryTest" + private var nsdManager: NsdManager? = null + private var listeners = mutableListOf() + + /** + * If advertisement fails, this method will cause the calling test to be skipped. + * Advertisement can fail if there is no suitable network on the device + * (e.g. in Airplane mode on newer Android versions). + */ + private fun advertiseService(advertisedName: String, advertisedPort: Int) { + var registeredService = false + val listener = object : NsdManager.RegistrationListener { + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { + Log.e(TAG, "Registration failed: si: $serviceInfo, error:$errorCode") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {} + override fun onServiceRegistered(serviceInfo: NsdServiceInfo?) { + Log.d(TAG, "Registered si: $serviceInfo") + registeredService = true + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo?) {} + } + val si = NsdServiceInfo().apply { + serviceType = "_rfb._tcp" + serviceName = advertisedName + port = advertisedPort + } + nsdManager?.registerService(si, NsdManager.PROTOCOL_DNS_SD, listener) + listeners.add(listener) + + Assume.assumeTrue(runCatching { pollingAssert { assertTrue(registeredService) } }.isSuccess) + } + + private fun assertDiscoveryState(test: Discovery.() -> Boolean) { + pollingAssert { runOnMainSync { assertTrue(Discovery.test()) } } + } + + @Before + fun before() { + nsdManager = ContextCompat.getSystemService(targetContext, NsdManager::class.java) + } + + @After + fun after() { + assertDiscoveryState { Log.d(TAG, "AfterTest: state: ${isRunning.value}, list: ${servers.value}"); true } + listeners.forEach { nsdManager?.unregisterService(it) } + listeners.clear() + assertDiscoveryState { servers.value!!.isEmpty() } + Discovery.stop() + assertDiscoveryState { isRunning.value == false } + } + + @Test + fun startStop() { + assertDiscoveryState { isRunning.value == false } + Discovery.start(targetContext) + assertDiscoveryState { isRunning.value == true } + Discovery.stop() + assertDiscoveryState { isRunning.value == false } + } + + @Test + fun singleService() { + Discovery.start(targetContext) + advertiseService("Server 1", 5999) + assertDiscoveryState { + isRunning.value == true && servers.value!!.size == 1 && servers.value!![0].port == 5999 + } + } + + @Test + fun multipleServices() { + Discovery.start(targetContext) + + val count = 10 + for (i in 1..count) + advertiseService("Server $i", 5900 + i) + + assertDiscoveryState { + isRunning.value == true && servers.value!!.size == count && + servers.value!!.find { it.port == 5901 } != null && + servers.value!!.find { it.port == 5904 } != null && + servers.value!!.find { it.port == 5908 } != null + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a755ff7..b4581d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ xmlns:tools="http://schemas.android.com/tools"> + ()) + val servers = MutableLiveData>() /** * Status of discovery. */ val isRunning = MutableLiveData(false) - - private val service = "_rfb._tcp" - private var nsdManager: NsdManager? = null - private val listener = DiscoveryListener() + private val impl by lazy { Impl() } /** * Starts discovery. - * Must be called on main thread. + */ + fun start(context: Context) = impl.start(context) + + /** + * Stops discovery. + */ + fun stop() = impl.stop() + + + /** + * Due to Android API limitations, service discovery is more complicated than necessary: * - * [NsdManager] starts/stops service discovery asynchronously, and notifies - * us through callbacks in [listener]. Also, it does not allow us to request - * start/stop if a previous start/stop request is yet to complete. + * - [NsdManager] is asynchronous, which means every command's result is communicated later + * on a separate thread. Also, [NsdManager] throws up if more than one request is made by same + * listener. You can't call [NsdManager.discoverServices] even if discovery is already started + * So to avoid race conditions (and keep my sanity, because its one of those APIs in Android + * where I wish some day the API designers are forced to use this crap themselves), all callbacks + * of [Impl] are run on a dedicated [executor], and [startRequested] is used to track pending start. * - * So, we set [isRunning] to true 'optimistically' in [start] without waiting - * for the confirmation in [listener], and revert it if starting fails. - * This way we don't have to track previously issued start/stop requests. + * - Only one service can resolved at a time via [NsdManager.resolveService]. To handle this, + * newly found service is first added to [pendingResolves]. When resolution finishes for a + * service, we remove that service form [pendingResolves], and start resolution for the next. * - * Status change: - *- - *- [start] :isRunning = true - *- | - *- +-----------------------------> start failed :isRunning = false - *- | - *- V - *- started - *- | - *- | - *- V - *- [stop] - *- | - *- +-----------------------------> stop failed - *- | - *- V - *- stopped :isRunning = false - *- + * - Android can filter/drop multicast WiFi packets to save power. Devices, like Pixel phone, enable + * this feature. This can be turned off by acquiring a multicast lock, but [NsdManager] doesn't + * doesn't do this automatically. So we have to acquire it manually. */ - fun start(scope: CoroutineScope) { - if (isRunning.value == true) { - return + @Suppress("DEPRECATION") // Yeah, f**k you too Google + private class Impl { + private val serviceType = "_rfb._tcp" + private var wifiManager: WifiManager? = null + private var multicastLock: MulticastLock? = null + private var nsdManager: NsdManager? = null + private val listener = DiscoveryListener() + private val executor = Executors.newSingleThreadExecutor() + + private var started = false + private var startRequested = false + private val pendingResolves = mutableMapOf() + private val resolvedProfiles = mutableSetOf() + + private fun execute(action: Runnable) { + runCatching { executor.execute(action) }.onFailure { Log.e(TAG, "Cannot execute action", it) } } - isRunning.value = true - if (servers.value?.size != 0) servers.value = ArrayList() //Forget known servers + private fun postResolvedProfiles() { + servers.postValue(resolvedProfiles.toList()) + } - // Construction of NSD manager is done on a background thread because it appears to be quite heavy. - scope.launch(Dispatchers.IO) { - if (nsdManager == null) - nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + fun start(context: Context) = execute { + if (startRequested || started) + return@execute - runCatching { nsdManager?.discoverServices(service, NsdManager.PROTOCOL_DNS_SD, listener) } - .onFailure { Log.e(javaClass.simpleName, "Unable to start Discovery", it) } + val appContext = context.applicationContext // Need app context to avoid possibility of WiFiManager leaks + wifiManager = wifiManager ?: ContextCompat.getSystemService(appContext, WifiManager::class.java) + nsdManager = nsdManager ?: ContextCompat.getSystemService(appContext, NsdManager::class.java) + nsdManager!!.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener) + startRequested = true + + // Forget old profiles + resolvedProfiles.clear() + postResolvedProfiles() } - } - /** - * Stops discovery. - * Must be called on main thread. - */ - fun stop() { - if (isRunning.value == true) { - runCatching { nsdManager?.stopServiceDiscovery(listener) } - .onFailure { Log.e(javaClass.simpleName, "Unable to stop Discovery", it) } + fun stop() = execute { + if (started) + nsdManager?.stopServiceDiscovery(listener) } - } - /** - * Adds a new profile with given details to list. - */ - private fun addProfile(name: String, host: String, port: Int) { - val profile = ServerProfile( - name = name, - host = host, - port = port - ) - - runBlocking(Dispatchers.Main) { - val currentList = servers.value!! - - if (!currentList.contains(profile)) { - val newList = ArrayList(currentList) - newList.add(profile) - servers.value = newList - } + fun onStarted() = execute { + started = true + startRequested = false + isRunning.postValue(true) + + multicastLock = wifiManager?.createMulticastLock(TAG) + multicastLock?.acquire() } - } - /** - * Remove given profile from list. - */ - private fun removeProfile(name: String) { - runBlocking(Dispatchers.Main) { - val newList = ArrayList(servers.value!!) - val profiles = newList.filter { it.name == name } - newList.removeAll(profiles) - servers.value = newList + fun onStopped() = execute { + started = false + isRunning.postValue(false) + multicastLock?.release() + multicastLock = null } - } - /** - * Listener for discovery process. - */ - private inner class DiscoveryListener : NsdManager.DiscoveryListener { - override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - @Suppress("DEPRECATION") - nsdManager?.resolveService(serviceInfo, ResolveListener()) + fun onStartFailed() = execute { + startRequested = false } - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - removeProfile(serviceInfo.serviceName) + fun onServiceFound(serviceInfo: NsdServiceInfo) = execute { + val listener = ResolveListener() + pendingResolves[listener] = serviceInfo + if (pendingResolves.size == 1) // Kick-start the resolution chain + nsdManager?.resolveService(serviceInfo, listener) } - override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { - Log.w(javaClass.simpleName, "Service discovery failed to stop [E: $errorCode ]") + fun onServiceLost(serviceInfo: NsdServiceInfo) = execute { + resolvedProfiles.removeAll { it.name == serviceInfo.serviceName } + postResolvedProfiles() } - override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { - Log.e(javaClass.simpleName, "Service discovery failed to start [E: $errorCode ]") + fun onResolved(si: NsdServiceInfo) = execute { + resolvedProfiles.add(ServerProfile(name = si.serviceName, host = si.host.hostAddress!!, port = si.port)) + postResolvedProfiles() + } - runBlocking(Dispatchers.Main) { - isRunning.value = false //Go Back - } + fun onResolveFinished(finishedResolve: ResolveListener) = execute { + pendingResolves.remove(finishedResolve) + pendingResolves.keys.firstOrNull()?.let { nsdManager?.resolveService(pendingResolves[it], it) } } + } - override fun onDiscoveryStarted(serviceType: String?) {} + /** + * Listener for discovery process. + */ + private class DiscoveryListener : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String?) = impl.onStarted() + override fun onDiscoveryStopped(serviceType: String?) = impl.onStopped() + override fun onServiceFound(serviceInfo: NsdServiceInfo) = impl.onServiceFound(serviceInfo) + override fun onServiceLost(serviceInfo: NsdServiceInfo) = impl.onServiceLost(serviceInfo) - override fun onDiscoveryStopped(serviceType: String?) { - runBlocking(Dispatchers.Main) { - isRunning.value = false - } + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.e(TAG, "Service discovery failed to start [E: $errorCode ]") + impl.onStartFailed() + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.w(TAG, "Service discovery failed to stop [E: $errorCode ]") + // From our perspective, this is same as onDiscoveryStopped(). + // We can't retry stopping it because NsdManager will clear the listener + // before invoking this callback. + impl.onStopped() } } /** * Listener for service resolution result. */ - private inner class ResolveListener : NsdManager.ResolveListener { + private class ResolveListener : NsdManager.ResolveListener { override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - @Suppress("DEPRECATION") - addProfile(serviceInfo.serviceName, serviceInfo.host.hostAddress!!, serviceInfo.port) + impl.onResolved(serviceInfo) + impl.onResolveFinished(this) } - override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { - Log.w(javaClass.simpleName, "Service resolution failed for '${serviceInfo}' [E: $errorCode]") + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Service resolution failed for '${serviceInfo}' [E: $errorCode]") + impl.onResolveFinished(this) } } } \ No newline at end of file