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",