From 5b30e9b50209bdb2fb3821e65147e8fbd7c746a1 Mon Sep 17 00:00:00 2001 From: Ash Davies <1892070+ashdavies@users.noreply.github.com> Date: Sun, 20 Aug 2023 20:26:44 +0200 Subject: [PATCH] Android/Jvm NsdManager (#479) * Create NsdManager proxy service for android with stub JVM * Adjust code style * Remove code sensing --- .../kotlin/io.ashdavies.spotless.gradle.kts | 1 + nsd-manager/build.gradle.kts | 13 ++ .../src/androidMain/AndroidManifest.xml | 2 + .../io/ashdavies/nsd/NsdManager.android.kt | 152 ++++++++++++++++++ .../kotlin/io/ashdavies/nsd/NsdAgent.kt | 62 +++++++ .../kotlin/io/ashdavies/nsd/NsdManager.kt | 36 +++++ .../kotlin/io/ashdavies/nsd/NsdManager.jvm.kt | 34 ++++ settings.gradle.kts | 1 + 8 files changed, 301 insertions(+) create mode 100644 nsd-manager/build.gradle.kts create mode 100644 nsd-manager/src/androidMain/AndroidManifest.xml create mode 100644 nsd-manager/src/androidMain/kotlin/io/ashdavies/nsd/NsdManager.android.kt create mode 100644 nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdAgent.kt create mode 100644 nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdManager.kt create mode 100644 nsd-manager/src/jvmMain/kotlin/io/ashdavies/nsd/NsdManager.jvm.kt diff --git a/build-plugins/src/main/kotlin/io.ashdavies.spotless.gradle.kts b/build-plugins/src/main/kotlin/io.ashdavies.spotless.gradle.kts index 08bdb022e..c2d2a3626 100644 --- a/build-plugins/src/main/kotlin/io.ashdavies.spotless.gradle.kts +++ b/build-plugins/src/main/kotlin/io.ashdavies.spotless.gradle.kts @@ -8,6 +8,7 @@ spotless { val ktLintVersion = libs.versions.pinterest.ktlint.get() val editorConfig = mapOf( "ij_kotlin_allow_trailing_comma_on_call_site" to "true", + "ktlint_standard_comment-wrapping" to "disabled", "ktlint_standard_function-naming" to "disabled", "ktlint_standard_property-naming" to "disabled", "ij_kotlin_allow_trailing_comma" to "true", diff --git a/nsd-manager/build.gradle.kts b/nsd-manager/build.gradle.kts new file mode 100644 index 000000000..53a223c26 --- /dev/null +++ b/nsd-manager/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("io.ashdavies.default") +} + +android { + namespace = "io.ashdavies.nsd" +} + +kotlin { + commonMain.dependencies { + implementation(projects.platformSupport) + } +} diff --git a/nsd-manager/src/androidMain/AndroidManifest.xml b/nsd-manager/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/nsd-manager/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/nsd-manager/src/androidMain/kotlin/io/ashdavies/nsd/NsdManager.android.kt b/nsd-manager/src/androidMain/kotlin/io/ashdavies/nsd/NsdManager.android.kt new file mode 100644 index 000000000..54255fc64 --- /dev/null +++ b/nsd-manager/src/androidMain/kotlin/io/ashdavies/nsd/NsdManager.android.kt @@ -0,0 +1,152 @@ +package io.ashdavies.nsd + +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.net.Inet4Address +import java.net.Inet6Address +import kotlin.coroutines.resumeWithException +import android.net.nsd.NsdManager as AndroidNsdManager +import android.net.nsd.NsdServiceInfo as AndroidNsdServiceInfo + +public actual typealias NsdManager = AndroidNsdManager + +public actual typealias NsdServiceInfo = AndroidNsdServiceInfo + +public actual fun NsdServiceInfo.getHostAddressOrNull( + type: NsdHostAddress.Type, +): NsdHostAddress? { + val hostAddressList = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> hostAddresses + else -> { + @Suppress("DEPRECATION") + listOf(host) + } + } + + return hostAddressList + .firstOrNull { + when (type) { + NsdHostAddress.Type.IPv4 -> it is Inet4Address + NsdHostAddress.Type.IPv6 -> it is Inet6Address + } + } + ?.hostAddress + ?.let { NsdHostAddress(it, type) } +} + +public actual fun NsdManager.discoverServices( + serviceType: String, + protocolType: Int, +): Flow> = channelFlow { + val servicesFound = mutableListOf() + val listener = object : AndroidNsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Service start discovery failed ($errorCode)") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Service stop discovery failed ($errorCode)") + } + + override fun onDiscoveryStarted(serviceType: String) = Unit + + override fun onDiscoveryStopped(serviceType: String) { + channel.close() + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + servicesFound += serviceInfo + trySend(servicesFound) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + servicesFound -= serviceInfo + trySend(servicesFound) + } + } + + discoverServices( + /* serviceType = */ serviceType, + /* protocolType = */ protocolType, + /* listener = */ listener, + ) + + awaitClose { + stopServiceDiscovery(listener) + } +} + +public actual fun NsdManager.resolveService( + serviceInfo: NsdServiceInfo, + coroutineDispatcher: CoroutineDispatcher, +): Flow { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return resolveServiceApi34(serviceInfo, coroutineDispatcher) + } + + return flow { + withContext(coroutineDispatcher) { + emit(resolveServiceApi16(serviceInfo)) + } + } +} + +private suspend fun NsdManager.resolveServiceApi16( + serviceInfo: NsdServiceInfo, +): NsdServiceInfo = suspendCancellableCoroutine { continuation -> + val listener = object : AndroidNsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + continuation.resumeWithException(IllegalStateException("Service resolution failed with an error ($errorCode)")) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + continuation.resumeWith(Result.success(serviceInfo)) + } + } + + @Suppress("DEPRECATION") + resolveService( + /* serviceInfo = */ serviceInfo, + /* listener = */ listener, + ) +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +private fun NsdManager.resolveServiceApi34( + serviceInfo: NsdServiceInfo, + coroutineDispatcher: CoroutineDispatcher, +): Flow = callbackFlow { + val callback = object : AndroidNsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + cancel("Service info callback registration failed ($errorCode)") + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + trySend(serviceInfo) + } + + override fun onServiceLost() = Unit + + override fun onServiceInfoCallbackUnregistered() { + channel.close() + } + } + + registerServiceInfoCallback( + /* serviceInfo = */ serviceInfo, + /* executor = */ coroutineDispatcher.asExecutor(), + /* listener = */ callback, + ) + + awaitClose { unregisterServiceInfoCallback(callback) } +} diff --git a/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdAgent.kt b/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdAgent.kt new file mode 100644 index 000000000..06bb1722b --- /dev/null +++ b/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdAgent.kt @@ -0,0 +1,62 @@ +package io.ashdavies.nsd + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flowOf + +private const val HTTP_TCP = "_http._tcp" +private const val PROTOCOL_DNS_SD = 1 + +public fun interface NsdAgent { + public fun resolve(serviceName: String): Flow +} + +public sealed interface NsdState { + + public data object Discovering : NsdState + + public data class Discovered( + val services: List, + ) : NsdState + + public data class Resolved( + val serviceName: String, + val hostAddress: String, + val port: Int, + ) : NsdState +} + +public fun NsdAgent(manager: NsdManager): NsdAgent = NsdAgent { serviceName -> + channelFlow { + send(NsdState.Discovering) + + @OptIn(ExperimentalCoroutinesApi::class) + manager + .discoverServices(HTTP_TCP, PROTOCOL_DNS_SD) + .flatMapLatest { serviceList -> + val serviceInfo = serviceList.filter { it.getServiceName() == serviceName } + val serviceNames = serviceInfo.map { it.getServiceName() } + + send(NsdState.Discovered(serviceNames)) + flowOf(*serviceInfo.toTypedArray()) + } + .flatMapMerge { serviceInfo -> + manager.resolveService(serviceInfo) + } + .collect { serviceInfo -> + val hostAddress = serviceInfo.getHostAddressOrNull() + if (hostAddress != null) { + val resolved = NsdState.Resolved( + serviceName = serviceInfo.getServiceName(), + hostAddress = hostAddress.value, + port = serviceInfo.getPort(), + ) + + send(resolved) + } + } + } +} diff --git a/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdManager.kt b/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdManager.kt new file mode 100644 index 000000000..b06246f27 --- /dev/null +++ b/nsd-manager/src/commonMain/kotlin/io/ashdavies/nsd/NsdManager.kt @@ -0,0 +1,36 @@ +package io.ashdavies.nsd + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow + +public data class NsdHostAddress( + public val value: String, + public val type: Type, +) { + public enum class Type { + IPv4, + IPv6, + } +} + +public expect class NsdManager + +public expect class NsdServiceInfo { + public fun getServiceName(): String + public fun getPort(): Int +} + +public expect fun NsdServiceInfo.getHostAddressOrNull( + type: NsdHostAddress.Type = NsdHostAddress.Type.IPv4, +): NsdHostAddress? + +public expect fun NsdManager.discoverServices( + serviceType: String, + protocolType: Int, +): Flow> + +public expect fun NsdManager.resolveService( + serviceInfo: NsdServiceInfo, + coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +): Flow diff --git a/nsd-manager/src/jvmMain/kotlin/io/ashdavies/nsd/NsdManager.jvm.kt b/nsd-manager/src/jvmMain/kotlin/io/ashdavies/nsd/NsdManager.jvm.kt new file mode 100644 index 000000000..a97000e00 --- /dev/null +++ b/nsd-manager/src/jvmMain/kotlin/io/ashdavies/nsd/NsdManager.jvm.kt @@ -0,0 +1,34 @@ +package io.ashdavies.nsd + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow + +public actual class NsdManager + +public actual class NsdServiceInfo { + public actual fun getServiceName(): String { + unsupportedOperation() + } + + public actual fun getPort(): Int { + unsupportedOperation() + } +} + +public actual fun NsdServiceInfo.getHostAddressOrNull( + type: NsdHostAddress.Type, +): NsdHostAddress? = unsupportedOperation() + +public actual fun NsdManager.discoverServices( + serviceType: String, + protocolType: Int, +): Flow> = unsupportedOperation() + +public actual fun NsdManager.resolveService( + serviceInfo: NsdServiceInfo, + coroutineDispatcher: CoroutineDispatcher, +): Flow = unsupportedOperation() + +private fun unsupportedOperation(): Nothing { + error("UnsupportedOperation") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index faa3ca99f..b099ea692 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,7 @@ include( ":local-storage", ":micro-yaml", ":notion-client", + ":nsd-manager", ":notion-console", ":parcelable-support", ":platform-support",