diff --git a/README.md b/README.md index 6b7d2d7..b16da73 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Kadb is a Kotlin Multiplatform ADB client library for talking directly to `adbd` It is intended for apps and tools that need shell, sync, install, pairing, or port forwarding without embedding the full `adb` CLI or server stack. -[Platform Notes](docs/platform.md) · [Host Identity](docs/kadbcert.md) · [Docs Index](docs/README.md) +[Platform Notes](docs/platform.md) · [mDNS Discovery](docs/mdns.md) · [Host Identity](docs/kadbcert.md) · [Docs Index](docs/README.md) ## Overview @@ -21,7 +21,15 @@ Kadb is not a full adb server replacement. USB discovery, transport brokering, a ```kotlin dependencies { - implementation("com.flyfishxu:kadb:2.1.1") + implementation("com.flyfishxu:kadb:2.1.2") +} +``` + +Optional mDNS discovery: + +```kotlin +dependencies { + implementation("com.flyfishxu:kadb-mdns:2.1.2") } ``` @@ -43,6 +51,20 @@ Pair with a new Android 11+ device: Kadb.pair("10.0.0.175", 37755, "643102") ``` +Discover wireless debugging endpoints with the optional mDNS artifact: + +```kotlin +val mdns = KadbMdnsAndroid(context) +mdns.start() +``` + +```kotlin +val mdns = KadbMdnsJvm() +mdns.start() +``` + +Discovered endpoints can be passed to `Kadb.create(host, port)` or `Kadb.pair(host, port, code)`. + ## API Overview | Capability | API | @@ -54,6 +76,7 @@ Kadb.pair("10.0.0.175", 37755, "643102") | APK install | `install(...)`, `installMultiple(...)`, `uninstall(...)` | | Port forwarding | `tcpForward(...)` | | Transport reuse | `resetConnection()` | +| Optional mDNS discovery | `KadbMdnsAndroid`, `KadbMdnsJvm` | ## Examples @@ -96,7 +119,7 @@ Example JVM pairing dependency: ```kotlin dependencies { - implementation("com.flyfishxu:kadb:2.1.1") + implementation("com.flyfishxu:kadb:2.1.2") implementation("org.conscrypt:conscrypt-openjdk-uber:2.5.2") } ``` @@ -112,6 +135,7 @@ More detail: [docs/platform.md](docs/platform.md) - [Documentation Index](docs/README.md) - [Platform Notes](docs/platform.md) +- [mDNS Discovery](docs/mdns.md) - [KadbCert](docs/kadbcert.md) ## Acknowledgements diff --git a/docs/README.md b/docs/README.md index 0b18c6d..48da842 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # Documentation - [Platform Notes](platform.md) +- [mDNS Discovery](mdns.md) - [KadbCert](kadbcert.md) The main project overview and quick-start examples live in [../README.md](../README.md). diff --git a/docs/mdns.md b/docs/mdns.md new file mode 100644 index 0000000..7817981 --- /dev/null +++ b/docs/mdns.md @@ -0,0 +1,76 @@ +# mDNS Discovery + +Kadb mDNS discovery is available as the optional `com.flyfishxu:kadb-mdns` artifact. + +The module discovers ADB-related mDNS services only. It does not open ADB connections, perform pairing, or manage device authorization. Consumers decide how to use discovered endpoints with `Kadb.create(host, port)` or `Kadb.pair(host, port, code)`. + +## Installation + +```kotlin +dependencies { + implementation("com.flyfishxu:kadb-mdns:2.1.2") +} +``` + +Use the core Kadb artifact separately when you also need ADB connections: + +```kotlin +dependencies { + implementation("com.flyfishxu:kadb:2.1.2") + implementation("com.flyfishxu:kadb-mdns:2.1.2") +} +``` + +## Service Types + +The default configuration searches for: + +- `_adb._tcp` +- `_adb-tls-connect._tcp` +- `_adb-tls-pairing._tcp` + +`_adb._tcp` and `_adb-tls-connect._tcp` are exposed as connect endpoints. `_adb-tls-pairing._tcp` is exposed as pairing endpoints. + +## Android + +Android discovery requires an explicit `Context`: + +```kotlin +val mdns = KadbMdnsAndroid(context) +mdns.start() +``` + +The Android implementation uses platform `NsdManager`, stores `context.applicationContext`, and supports API 23+. Android API 34 and newer use `NsdManager.ServiceInfoCallback`; older versions use `resolveService`. + +## JVM + +JVM discovery does not require Android concepts: + +```kotlin +val mdns = KadbMdnsJvm() +mdns.start() +``` + +The JVM implementation uses JmDNS internally. It attempts to create discovery backends for eligible active network interfaces and falls back to a default JmDNS instance if interface enumeration cannot provide a usable address. + +## Lifecycle + +`KadbMdns` exposes a `StateFlow`: + +```kotlin +mdns.state.collect { state -> + val target = state.connectDevices.firstOrNull() + if (target != null) { + // Kadb.create(target.host, target.port) + } +} +``` + +Call `start()` to begin discovery. Call `stop()` or `close()` to stop listeners and clear state. + +## Notes + +- No default logging is emitted by the module. +- Discovery data is best-effort and network-dependent. +- The library trusts mDNS host/port data; connection failures should be handled by the caller. +- USB discovery remains out of scope. diff --git a/docs/platform.md b/docs/platform.md index 643f4d4..6c46afa 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -12,6 +12,7 @@ This page documents the platform-specific runtime requirements and behavior diff | APK install / uninstall | Yes | Yes | | TCP forward | Yes | Yes | | Wireless pairing | Yes | Yes | +| Optional mDNS discovery | Yes | Yes | | USB discovery | No | No | ## Pairing Requirements @@ -31,6 +32,8 @@ Basic connect / shell / sync / install usage does not require the same provider - `minSdk 23` - direct client features are supported on Android targets - pairing support depends on TLS provider availability +- optional mDNS discovery is provided by `com.flyfishxu:kadb-mdns` +- mDNS discovery requires an explicit Android `Context` and uses platform `NsdManager` ### Pairing @@ -58,6 +61,9 @@ The JVM target supports: - APK install / uninstall - TCP forward - wireless pairing +- optional mDNS discovery through `com.flyfishxu:kadb-mdns` + +The JVM mDNS implementation uses JmDNS internally and does not require Android concepts such as `Context`. ### Pairing @@ -67,7 +73,7 @@ Recommended setup: ```kotlin dependencies { - implementation("com.flyfishxu:kadb:2.1.1") + implementation("com.flyfishxu:kadb:2.1.2") implementation("org.conscrypt:conscrypt-openjdk-uber:2.5.2") } ``` @@ -87,4 +93,5 @@ Further detail: [kadbcert.md](kadbcert.md) ## Related Docs - [Project README](../README.md) +- [mDNS Discovery](mdns.md) - [KadbCert](kadbcert.md) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6954d89..8e1cdd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ documentfile = "1.1.0" dokka = "2.1.0" hiddenapibypass = "6.1" conscrypt-java = "2.5.2" +jmdns = "3.6.3" [libraries] @@ -29,3 +30,4 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" } documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } conscrypt-java = { module = "org.conscrypt:conscrypt-openjdk-uber", version.ref = "conscrypt-java" } +jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" } diff --git a/kadb-mdns/build.gradle.kts b/kadb-mdns/build.gradle.kts new file mode 100644 index 0000000..12f5cba --- /dev/null +++ b/kadb-mdns/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + id("com.vanniktech.maven.publish") + id("signing") +} + +kotlin { + jvm { + kotlin { + jvmToolchain(21) + } + } + + android { + namespace = "com.flyfishxu.kadb.mdns" + compileSdk = 37 + minSdk = 23 + + withJava() + } + + sourceSets { + commonMain.dependencies { + api(libs.kotlinx.coroutines.core) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + } + + jvmMain.dependencies { + implementation(libs.jmdns) + } + } +} + +tasks.withType>().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } +} + +signing { + useGpgCmd() +} + +mavenPublishing { + publishToMavenCentral() + if (providers.gradleProperty("signAllPublications").map { it.toBoolean() }.orElse(true).get()) { + signAllPublications() + } + + coordinates("com.flyfishxu", "kadb-mdns", "2.1.2") + + pom { + name.set("Kadb mDNS") + description.set("Optional Kotlin Multiplatform mDNS discovery module for ADB services.") + url.set("https://github.com/flyfishxu/Kadb.git") + + licenses { + license { + name.set("Apache License 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("flyfishxu") + name.set("Flyfish Xu") + url.set("https://github.com/flyfishxu") + organization.set("Flyfish Studio") + email.set("flyfishxu@outlook.com") + } + } + scm { + connection.set("scm:git:git://github.com/flyfishxu/Kadb.git") + developerConnection.set("scm:git:ssh://github.com/flyfishxu/Kadb.git") + url.set("https://github.com/flyfishxu/Kadb.git") + } + } +} diff --git a/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsAndroid.kt b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsAndroid.kt new file mode 100644 index 0000000..8539ba3 --- /dev/null +++ b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsAndroid.kt @@ -0,0 +1,218 @@ +package com.flyfishxu.kadb.mdns + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import com.flyfishxu.kadb.mdns.internal.AndroidDiscoveryListener +import com.flyfishxu.kadb.mdns.internal.AndroidResolveListener +import com.flyfishxu.kadb.mdns.internal.AndroidServiceInfoCallback +import com.flyfishxu.kadb.mdns.internal.MdnsRegistry +import com.flyfishxu.kadb.mdns.internal.MdnsServiceKey +import com.flyfishxu.kadb.mdns.internal.toMdnsEndpoint +import com.flyfishxu.kadb.mdns.internal.toMdnsServiceKey +import kotlinx.coroutines.flow.StateFlow +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class KadbMdnsAndroid( + context: Context, + private val config: MdnsConfig = MdnsConfig() +) : KadbMdns { + private val applicationContext = context.applicationContext + private val nsdManager = applicationContext.getSystemService(NsdManager::class.java) + private val registry = MdnsRegistry(config) + private val callbackExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val callbacks = mutableMapOf() + private val lock = Any() + private var started = false + private var closed = false + + private val discoveryListeners = config.serviceTypes.associateWith { serviceType -> + AndroidDiscoveryListener( + serviceType = serviceType, + onStatusChanged = ::onStatusChanged, + onServiceFound = ::onServiceFound, + onServiceLost = ::onServiceLost + ) + } + + override val state: StateFlow = registry.state + + override fun start() { + val listeners = synchronized(lock) { + if (closed || started) return + started = true + callbacks.clear() + registry.starting() + discoveryListeners.toList() + } + + if (listeners.isEmpty()) { + onStatusChanged(MdnsStatus.STARTED) + return + } + + listeners.forEach { (serviceType, listener) -> + runCatching { + nsdManager.discoverServices( + serviceType.dnsType, + NsdManager.PROTOCOL_DNS_SD, + listener + ) + }.onFailure { + onStatusChanged(MdnsStatus.FAILED) + } + } + } + + override fun stop() { + val listeners = synchronized(lock) { + if (!started) return + started = false + discoveryListeners.values.toList() + } + + listeners.forEach { listener -> + runCatching { + nsdManager.stopServiceDiscovery(listener) + } + } + clearServiceInfoCallbacks() + + synchronized(lock) { + callbacks.clear() + registry.stopped() + } + } + + override fun close() { + stop() + val shouldShutdown = synchronized(lock) { + if (closed) { + false + } else { + closed = true + true + } + } + if (shouldShutdown) { + callbackExecutor.shutdownNow() + } + } + + private fun onStatusChanged(status: MdnsStatus) { + synchronized(lock) { + when (status) { + MdnsStatus.STARTING -> if (started) registry.starting() + MdnsStatus.STARTED -> if (started) registry.started() + MdnsStatus.STOPPED -> if (!started) registry.stopped() + MdnsStatus.FAILED -> registry.failed() + } + } + } + + private fun onServiceFound(serviceInfo: NsdServiceInfo, serviceType: MdnsServiceType) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + registerServiceInfoCallback(serviceInfo, serviceType) + } else { + @Suppress("DEPRECATION") + nsdManager.resolveService( + serviceInfo, + AndroidResolveListener( + onServiceResolved = { resolvedInfo -> upsertService(resolvedInfo, serviceType) }, + onResolveFailed = { onStatusChanged(MdnsStatus.FAILED) } + ) + ) + } + } + + private fun onServiceLost(serviceInfo: NsdServiceInfo, serviceType: MdnsServiceType) { + val key = serviceInfo.toMdnsServiceKey(fallbackServiceType = serviceType) ?: return + unregisterServiceInfoCallback(key) + synchronized(lock) { + if (started) { + registry.remove(name = key.name, serviceType = key.serviceType) + } + } + } + + private fun upsertService(serviceInfo: NsdServiceInfo, fallbackServiceType: MdnsServiceType) { + val endpoint = serviceInfo.toMdnsEndpoint( + config = config, + fallbackServiceType = fallbackServiceType + ) ?: return + synchronized(lock) { + if (started) { + registry.upsert(endpoint) + } + } + } + + private fun registerServiceInfoCallback( + serviceInfo: NsdServiceInfo, + serviceType: MdnsServiceType + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + + val key = serviceInfo.toMdnsServiceKey(fallbackServiceType = serviceType) ?: return + val callback = AndroidServiceInfoCallback( + onRegistrationFailed = { onStatusChanged(MdnsStatus.FAILED) }, + onServiceUpdated = { updatedInfo -> upsertService(updatedInfo, serviceType) }, + onServiceLost = { removeServiceByKey(key) }, + onUnregistered = { + synchronized(lock) { + callbacks.remove(key) + } + } + ) + + val shouldRegister = synchronized(lock) { + if (!started || closed || callbacks.containsKey(key)) { + false + } else { + callbacks[key] = callback + true + } + } + if (!shouldRegister) return + + runCatching { + nsdManager.registerServiceInfoCallback(serviceInfo, callbackExecutor, callback) + }.onFailure { + synchronized(lock) { + callbacks.remove(key, callback) + } + onStatusChanged(MdnsStatus.FAILED) + } + } + + private fun removeServiceByKey(key: MdnsServiceKey) { + synchronized(lock) { + if (started) { + registry.remove(name = key.name, serviceType = key.serviceType) + } + } + unregisterServiceInfoCallback(key) + } + + private fun unregisterServiceInfoCallback(key: MdnsServiceKey) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + val callback = synchronized(lock) { callbacks.remove(key) } ?: return + runCatching { + nsdManager.unregisterServiceInfoCallback(callback) + } + } + + private fun clearServiceInfoCallbacks() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + val currentCallbacks = synchronized(lock) { + callbacks.values.toList().also { callbacks.clear() } + } + currentCallbacks.forEach { callback -> + runCatching { + nsdManager.unregisterServiceInfoCallback(callback) + } + } + } +} diff --git a/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidDiscoveryListener.kt b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidDiscoveryListener.kt new file mode 100644 index 0000000..0eb5150 --- /dev/null +++ b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidDiscoveryListener.kt @@ -0,0 +1,41 @@ +package com.flyfishxu.kadb.mdns.internal + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import com.flyfishxu.kadb.mdns.MdnsServiceType +import com.flyfishxu.kadb.mdns.MdnsStatus + +internal class AndroidDiscoveryListener( + private val serviceType: MdnsServiceType, + private val onStatusChanged: (MdnsStatus) -> Unit, + private val onServiceFound: (NsdServiceInfo, MdnsServiceType) -> Unit, + private val onServiceLost: (NsdServiceInfo, MdnsServiceType) -> Unit +) : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) { + onStatusChanged(MdnsStatus.STARTED) + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + onStatusChanged(MdnsStatus.FAILED) + } + + override fun onDiscoveryStopped(serviceType: String) { + onStatusChanged(MdnsStatus.STOPPED) + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + onStatusChanged(MdnsStatus.FAILED) + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + val foundType = parseMdnsServiceType(serviceInfo.serviceType) + if (foundType != serviceType) return + onServiceFound(serviceInfo, serviceType) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val lostType = parseMdnsServiceType(serviceInfo.serviceType) + if (lostType != null && lostType != serviceType) return + onServiceLost(serviceInfo, serviceType) + } +} diff --git a/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidResolveListener.kt b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidResolveListener.kt new file mode 100644 index 0000000..1f966f7 --- /dev/null +++ b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidResolveListener.kt @@ -0,0 +1,17 @@ +package com.flyfishxu.kadb.mdns.internal + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo + +internal class AndroidResolveListener( + private val onServiceResolved: (NsdServiceInfo) -> Unit, + private val onResolveFailed: (Int) -> Unit +) : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + onResolveFailed(errorCode) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + onServiceResolved(serviceInfo) + } +} diff --git a/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidServiceInfoCallback.kt b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidServiceInfoCallback.kt new file mode 100644 index 0000000..8834473 --- /dev/null +++ b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/AndroidServiceInfoCallback.kt @@ -0,0 +1,27 @@ +package com.flyfishxu.kadb.mdns.internal + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo + +internal class AndroidServiceInfoCallback( + private val onRegistrationFailed: (Int) -> Unit, + private val onServiceUpdated: (NsdServiceInfo) -> Unit, + private val onServiceLost: () -> Unit, + private val onUnregistered: () -> Unit +) : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + onRegistrationFailed(errorCode) + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + onServiceUpdated(serviceInfo) + } + + override fun onServiceLost() { + onServiceLost() + } + + override fun onServiceInfoCallbackUnregistered() { + onUnregistered() + } +} diff --git a/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/NsdServiceInfoMapper.kt b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/NsdServiceInfoMapper.kt new file mode 100644 index 0000000..7c4767d --- /dev/null +++ b/kadb-mdns/src/androidMain/kotlin/com/flyfishxu/kadb/mdns/internal/NsdServiceInfoMapper.kt @@ -0,0 +1,50 @@ +package com.flyfishxu.kadb.mdns.internal + +import android.net.nsd.NsdServiceInfo +import android.os.Build +import com.flyfishxu.kadb.mdns.MdnsConfig +import com.flyfishxu.kadb.mdns.MdnsEndpoint +import com.flyfishxu.kadb.mdns.MdnsServiceType +import java.net.Inet4Address +import java.net.InetAddress + +internal fun NsdServiceInfo.toMdnsEndpoint( + config: MdnsConfig, + fallbackServiceType: MdnsServiceType +): MdnsEndpoint? { + val key = toMdnsServiceKey(fallbackServiceType) ?: return null + val hostAddress = resolveHostAddress(config.preferIpv4) ?: return null + val safePort = port.takeIf { it in 1..65535 } ?: return null + return MdnsEndpoint( + name = key.name, + host = hostAddress, + port = safePort, + serviceType = key.serviceType + ) +} + +internal fun NsdServiceInfo.toMdnsServiceKey( + fallbackServiceType: MdnsServiceType +): MdnsServiceKey? { + val name = serviceName?.trim().orEmpty() + if (name.isBlank()) return null + val parsedType = parseMdnsServiceType(serviceType) ?: fallbackServiceType + return MdnsServiceKey(name = name, serviceType = parsedType) +} + +private fun NsdServiceInfo.resolveHostAddress(preferIpv4: Boolean): String? { + val address = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + selectAddress(hostAddresses, preferIpv4) + } else { + @Suppress("DEPRECATION") + host + } + return address?.hostAddress?.takeIf { it.isNotBlank() } +} + +private fun selectAddress(addresses: List, preferIpv4: Boolean): InetAddress? = + if (preferIpv4) { + addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull() + } else { + addresses.firstOrNull() + } diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdns.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdns.kt new file mode 100644 index 0000000..8039553 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdns.kt @@ -0,0 +1,13 @@ +package com.flyfishxu.kadb.mdns + +import kotlinx.coroutines.flow.StateFlow + +interface KadbMdns : AutoCloseable { + val state: StateFlow + + fun start() + + fun stop() + + override fun close() = stop() +} diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsConfig.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsConfig.kt new file mode 100644 index 0000000..ed2ac80 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsConfig.kt @@ -0,0 +1,6 @@ +package com.flyfishxu.kadb.mdns + +data class MdnsConfig( + val serviceTypes: Set = MdnsServiceType.entries.toSet(), + val preferIpv4: Boolean = true +) diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsDiscoveryState.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsDiscoveryState.kt new file mode 100644 index 0000000..7286487 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsDiscoveryState.kt @@ -0,0 +1,11 @@ +package com.flyfishxu.kadb.mdns + +data class MdnsDiscoveryState( + val status: MdnsStatus = MdnsStatus.STOPPED, + val loading: Boolean = false, + val connectDevices: List = emptyList(), + val pairDevices: List = emptyList() +) { + val allDevices: List + get() = connectDevices + pairDevices +} diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsEndpoint.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsEndpoint.kt new file mode 100644 index 0000000..ced5132 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsEndpoint.kt @@ -0,0 +1,8 @@ +package com.flyfishxu.kadb.mdns + +data class MdnsEndpoint( + val name: String, + val host: String, + val port: Int, + val serviceType: MdnsServiceType +) diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceType.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceType.kt new file mode 100644 index 0000000..66b217e --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceType.kt @@ -0,0 +1,7 @@ +package com.flyfishxu.kadb.mdns + +enum class MdnsServiceType(val dnsType: String) { + ADB("_adb._tcp"), + TLS_CONNECT("_adb-tls-connect._tcp"), + TLS_PAIRING("_adb-tls-pairing._tcp") +} diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsStatus.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsStatus.kt new file mode 100644 index 0000000..6c7aa45 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/MdnsStatus.kt @@ -0,0 +1,8 @@ +package com.flyfishxu.kadb.mdns + +enum class MdnsStatus { + STOPPED, + STARTING, + STARTED, + FAILED +} diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsRegistry.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsRegistry.kt new file mode 100644 index 0000000..610b817 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsRegistry.kt @@ -0,0 +1,70 @@ +package com.flyfishxu.kadb.mdns.internal + +import com.flyfishxu.kadb.mdns.MdnsConfig +import com.flyfishxu.kadb.mdns.MdnsDiscoveryState +import com.flyfishxu.kadb.mdns.MdnsEndpoint +import com.flyfishxu.kadb.mdns.MdnsServiceType +import com.flyfishxu.kadb.mdns.MdnsStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class MdnsRegistry( + private val config: MdnsConfig +) { + private val endpoints = linkedMapOf() + private val mutableState = MutableStateFlow(MdnsDiscoveryState()) + + val state: StateFlow = mutableState + + fun starting() { + endpoints.clear() + mutableState.value = MdnsDiscoveryState(status = MdnsStatus.STARTING, loading = true) + } + + fun started() { + emit(status = MdnsStatus.STARTED) + } + + fun failed() { + mutableState.value = mutableState.value.copy(status = MdnsStatus.FAILED, loading = false) + } + + fun stopped() { + endpoints.clear() + mutableState.value = MdnsDiscoveryState(status = MdnsStatus.STOPPED, loading = false) + } + + fun upsert(endpoint: MdnsEndpoint) { + if (endpoint.serviceType !in config.serviceTypes) return + if (endpoint.name.isBlank()) return + if (endpoint.host.isBlank()) return + if (endpoint.port !in 1..65535) return + + endpoints[MdnsServiceKey(endpoint.name, endpoint.serviceType)] = endpoint + emit(status = currentActiveStatus()) + } + + fun remove(name: String, serviceType: MdnsServiceType) { + endpoints.remove(MdnsServiceKey(name, serviceType)) + emit(status = currentActiveStatus()) + } + + private fun currentActiveStatus(): MdnsStatus = + if (mutableState.value.status == MdnsStatus.STOPPED) MdnsStatus.STOPPED else MdnsStatus.STARTED + + private fun emit(status: MdnsStatus) { + val values = endpoints.values.sortedWith( + compareBy(MdnsEndpoint::name, MdnsEndpoint::host, MdnsEndpoint::port) + ) + val connectDevices = values.filter { + it.serviceType == MdnsServiceType.ADB || it.serviceType == MdnsServiceType.TLS_CONNECT + } + val pairDevices = values.filter { it.serviceType == MdnsServiceType.TLS_PAIRING } + mutableState.value = MdnsDiscoveryState( + status = status, + loading = status != MdnsStatus.STOPPED && status != MdnsStatus.FAILED && values.isEmpty(), + connectDevices = connectDevices, + pairDevices = pairDevices + ) + } +} diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceKey.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceKey.kt new file mode 100644 index 0000000..5ccb474 --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceKey.kt @@ -0,0 +1,8 @@ +package com.flyfishxu.kadb.mdns.internal + +import com.flyfishxu.kadb.mdns.MdnsServiceType + +internal data class MdnsServiceKey( + val name: String, + val serviceType: MdnsServiceType +) diff --git a/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceTypeParser.kt b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceTypeParser.kt new file mode 100644 index 0000000..9e6333b --- /dev/null +++ b/kadb-mdns/src/commonMain/kotlin/com/flyfishxu/kadb/mdns/internal/MdnsServiceTypeParser.kt @@ -0,0 +1,15 @@ +package com.flyfishxu.kadb.mdns.internal + +import com.flyfishxu.kadb.mdns.MdnsServiceType + +internal fun parseMdnsServiceType(serviceType: String?): MdnsServiceType? { + val normalized = serviceType.normalizeMdnsServiceType() + return MdnsServiceType.entries.firstOrNull { it.dnsType == normalized } +} + +internal fun String?.normalizeMdnsServiceType(): String = + this.orEmpty() + .trim() + .removeSuffix(".") + .removeSuffix(".local") + .lowercase() diff --git a/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsRegistryTest.kt b/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsRegistryTest.kt new file mode 100644 index 0000000..3ae46d8 --- /dev/null +++ b/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsRegistryTest.kt @@ -0,0 +1,56 @@ +package com.flyfishxu.kadb.mdns + +import com.flyfishxu.kadb.mdns.internal.MdnsRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MdnsRegistryTest { + @Test + fun groupsConnectAndPairingDevices() { + val registry = MdnsRegistry(MdnsConfig()) + registry.starting() + registry.started() + + registry.upsert(endpoint("phone", "192.168.1.8", 37123, MdnsServiceType.TLS_CONNECT)) + registry.upsert(endpoint("phone-pair", "192.168.1.8", 43011, MdnsServiceType.TLS_PAIRING)) + + val state = registry.state.value + assertEquals(MdnsStatus.STARTED, state.status) + assertFalse(state.loading) + assertEquals(listOf("phone"), state.connectDevices.map { it.name }) + assertEquals(listOf("phone-pair"), state.pairDevices.map { it.name }) + } + + @Test + fun removesEndpointByNameAndServiceType() { + val registry = MdnsRegistry(MdnsConfig()) + registry.starting() + registry.started() + registry.upsert(endpoint("phone", "192.168.1.8", 37123, MdnsServiceType.TLS_CONNECT)) + + registry.remove(name = "phone", serviceType = MdnsServiceType.TLS_CONNECT) + + val state = registry.state.value + assertTrue(state.connectDevices.isEmpty()) + assertTrue(state.loading) + } + + @Test + fun ignoresInvalidEndpointsAndDisabledServiceTypes() { + val registry = MdnsRegistry(MdnsConfig(serviceTypes = setOf(MdnsServiceType.TLS_PAIRING))) + registry.starting() + registry.started() + + registry.upsert(endpoint("phone", "192.168.1.8", 37123, MdnsServiceType.TLS_CONNECT)) + registry.upsert(endpoint("", "192.168.1.8", 37123, MdnsServiceType.TLS_PAIRING)) + registry.upsert(endpoint("phone", "", 37123, MdnsServiceType.TLS_PAIRING)) + registry.upsert(endpoint("phone", "192.168.1.8", 0, MdnsServiceType.TLS_PAIRING)) + + assertTrue(registry.state.value.allDevices.isEmpty()) + } + + private fun endpoint(name: String, host: String, port: Int, serviceType: MdnsServiceType) = + MdnsEndpoint(name = name, host = host, port = port, serviceType = serviceType) +} diff --git a/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceTypeParserTest.kt b/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceTypeParserTest.kt new file mode 100644 index 0000000..6604cac --- /dev/null +++ b/kadb-mdns/src/commonTest/kotlin/com/flyfishxu/kadb/mdns/MdnsServiceTypeParserTest.kt @@ -0,0 +1,24 @@ +package com.flyfishxu.kadb.mdns + +import com.flyfishxu.kadb.mdns.internal.parseMdnsServiceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MdnsServiceTypeParserTest { + @Test + fun parsesAdbServiceTypesWithLocalSuffixAndCaseDifferences() { + assertEquals(MdnsServiceType.ADB, parseMdnsServiceType("_adb._tcp")) + assertEquals(MdnsServiceType.ADB, parseMdnsServiceType("_ADB._TCP.")) + assertEquals(MdnsServiceType.ADB, parseMdnsServiceType("_adb._tcp.local")) + assertEquals(MdnsServiceType.TLS_CONNECT, parseMdnsServiceType("_adb-tls-connect._tcp.local.")) + assertEquals(MdnsServiceType.TLS_PAIRING, parseMdnsServiceType("_adb-tls-pairing._tcp")) + } + + @Test + fun returnsNullForUnknownServiceType() { + assertNull(parseMdnsServiceType("_printer._tcp")) + assertNull(parseMdnsServiceType("")) + assertNull(parseMdnsServiceType(null)) + } +} diff --git a/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvm.kt b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvm.kt new file mode 100644 index 0000000..6926b5d --- /dev/null +++ b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvm.kt @@ -0,0 +1,139 @@ +package com.flyfishxu.kadb.mdns + +import com.flyfishxu.kadb.mdns.internal.JmDnsBackend +import com.flyfishxu.kadb.mdns.internal.JmDnsServiceEvents +import com.flyfishxu.kadb.mdns.internal.MdnsRegistry +import com.flyfishxu.kadb.mdns.internal.createDefaultJmDnsBackends +import com.flyfishxu.kadb.mdns.internal.mapJmDnsEndpoint +import kotlinx.coroutines.flow.StateFlow + +class KadbMdnsJvm( + private val config: MdnsConfig = MdnsConfig() +) : KadbMdns { + private constructor( + config: MdnsConfig, + backendFactory: () -> List, + internalMarker: Unit + ) : this(config) { + this.backendFactory = backendFactory + } + + internal constructor( + config: MdnsConfig, + backendFactory: () -> List + ) : this(config, backendFactory, Unit) + + private val registry = MdnsRegistry(config) + private val lock = Any() + private var backendFactory: () -> List = ::createDefaultJmDnsBackends + private var backends = emptyList() + private var registrations = emptyList() + private var started = false + + override val state: StateFlow = registry.state + + override fun start() { + synchronized(lock) { + if (started) return + started = true + registry.starting() + } + + val createdBackends = runCatching { backendFactory() } + .getOrElse { + synchronized(lock) { + started = false + registry.failed() + } + return + } + + if (createdBackends.isEmpty()) { + synchronized(lock) { + started = false + registry.failed() + } + return + } + + val newRegistrations = buildList { + createdBackends.forEach { backend -> + config.serviceTypes.forEach { serviceType -> + val listener = JvmMdnsServiceEvents(backend) + backend.addServiceListener(serviceType.dnsType, listener) + add(Registration(backend, serviceType.dnsType, listener)) + } + } + } + + synchronized(lock) { + if (!started) { + newRegistrations.forEach { it.backend.removeServiceListener(it.type, it.listener) } + createdBackends.forEach { it.close() } + return + } + backends = createdBackends + registrations = newRegistrations + registry.started() + } + } + + override fun stop() { + val currentRegistrations: List + val currentBackends: List + synchronized(lock) { + if (!started && backends.isEmpty()) return + started = false + currentRegistrations = registrations + currentBackends = backends + registrations = emptyList() + backends = emptyList() + } + + currentRegistrations.forEach { registration -> + runCatching { + registration.backend.removeServiceListener(registration.type, registration.listener) + } + } + currentBackends.forEach { backend -> + runCatching { + backend.close() + } + } + synchronized(lock) { + registry.stopped() + } + } + + private inner class JvmMdnsServiceEvents( + private val backend: JmDnsBackend + ) : JmDnsServiceEvents { + override fun serviceAdded(type: String, name: String) { + backend.requestServiceInfo(type, name) + } + + override fun serviceResolved(type: String, name: String, hostAddresses: Array, port: Int) { + val endpoint = mapJmDnsEndpoint(type, name, hostAddresses, port, config) ?: return + synchronized(lock) { + if (started) { + registry.upsert(endpoint) + } + } + } + + override fun serviceRemoved(type: String, name: String) { + val serviceType = com.flyfishxu.kadb.mdns.internal.parseMdnsServiceType(type) ?: return + synchronized(lock) { + if (started) { + registry.remove(name, serviceType) + } + } + } + } + + private data class Registration( + val backend: JmDnsBackend, + val type: String, + val listener: JmDnsServiceEvents + ) +} diff --git a/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsBackend.kt b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsBackend.kt new file mode 100644 index 0000000..2d6dc46 --- /dev/null +++ b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsBackend.kt @@ -0,0 +1,17 @@ +package com.flyfishxu.kadb.mdns.internal + +internal interface JmDnsBackend : AutoCloseable { + fun addServiceListener(type: String, listener: JmDnsServiceEvents) + + fun removeServiceListener(type: String, listener: JmDnsServiceEvents) + + fun requestServiceInfo(type: String, name: String) +} + +internal interface JmDnsServiceEvents { + fun serviceAdded(type: String, name: String) + + fun serviceResolved(type: String, name: String, hostAddresses: Array, port: Int) + + fun serviceRemoved(type: String, name: String) +} diff --git a/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsEndpointMapper.kt b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsEndpointMapper.kt new file mode 100644 index 0000000..35bd5e6 --- /dev/null +++ b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsEndpointMapper.kt @@ -0,0 +1,35 @@ +package com.flyfishxu.kadb.mdns.internal + +import com.flyfishxu.kadb.mdns.MdnsConfig +import com.flyfishxu.kadb.mdns.MdnsEndpoint + +internal fun mapJmDnsEndpoint( + type: String, + name: String, + hostAddresses: Array, + port: Int, + config: MdnsConfig +): MdnsEndpoint? { + val serviceType = parseMdnsServiceType(type) ?: return null + if (serviceType !in config.serviceTypes) return null + val safeName = name.trim().takeIf { it.isNotBlank() } ?: return null + val host = selectHostAddress(hostAddresses, config.preferIpv4) ?: return null + val safePort = port.takeIf { it in 1..65535 } ?: return null + return MdnsEndpoint( + name = safeName, + host = host, + port = safePort, + serviceType = serviceType + ) +} + +private fun selectHostAddress(addresses: Array, preferIpv4: Boolean): String? { + val candidates = addresses.mapNotNull { address -> + address.trim().takeIf { it.isNotBlank() } + } + return if (preferIpv4) { + candidates.firstOrNull { ':' !in it } ?: candidates.firstOrNull() + } else { + candidates.firstOrNull() + } +} diff --git a/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceListener.kt b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceListener.kt new file mode 100644 index 0000000..0e24cb9 --- /dev/null +++ b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceListener.kt @@ -0,0 +1,26 @@ +package com.flyfishxu.kadb.mdns.internal + +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceListener + +internal class JmDnsServiceListener( + private val events: JmDnsServiceEvents +) : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + events.serviceAdded(event.type, event.name) + } + + override fun serviceRemoved(event: ServiceEvent) { + events.serviceRemoved(event.type, event.name) + } + + override fun serviceResolved(event: ServiceEvent) { + val info = event.info ?: return + events.serviceResolved( + type = event.type, + name = event.name.ifBlank { info.name }, + hostAddresses = info.hostAddresses, + port = info.port + ) + } +} diff --git a/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/RealJmDnsBackend.kt b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/RealJmDnsBackend.kt new file mode 100644 index 0000000..0fad11d --- /dev/null +++ b/kadb-mdns/src/jvmMain/kotlin/com/flyfishxu/kadb/mdns/internal/RealJmDnsBackend.kt @@ -0,0 +1,76 @@ +package com.flyfishxu.kadb.mdns.internal + +import java.net.InetAddress +import java.net.NetworkInterface +import javax.jmdns.JmDNS +import javax.jmdns.ServiceListener + +internal class RealJmDnsBackend( + private val jmDns: JmDNS +) : JmDnsBackend { + private val listeners = mutableMapOf() + + override fun addServiceListener(type: String, listener: JmDnsServiceEvents) { + val serviceListener = JmDnsServiceListener(listener) + val jmDnsType = type.toJmDnsServiceType() + listeners[ListenerKey(type, listener)] = ListenerRegistration(jmDnsType, serviceListener) + jmDns.addServiceListener(jmDnsType, serviceListener) + } + + override fun removeServiceListener(type: String, listener: JmDnsServiceEvents) { + val registration = listeners.remove(ListenerKey(type, listener)) ?: return + jmDns.removeServiceListener(registration.type, registration.listener) + } + + override fun requestServiceInfo(type: String, name: String) { + jmDns.requestServiceInfo(type.toJmDnsServiceType(), name, true) + } + + override fun close() { + jmDns.close() + } + + private data class ListenerKey( + val type: String, + val listener: JmDnsServiceEvents + ) + + private data class ListenerRegistration( + val type: String, + val listener: ServiceListener + ) +} + +internal fun createDefaultJmDnsBackends(): List { + val interfaceBackends = runCatching { + eligibleInterfaceAddresses().map { address -> + RealJmDnsBackend(JmDNS.create(address)) + } + }.getOrElse { emptyList() } + + return interfaceBackends.ifEmpty { + listOf(RealJmDnsBackend(JmDNS.create())) + } +} + +private fun eligibleInterfaceAddresses(): List = + NetworkInterface.getNetworkInterfaces() + .asSequence() + .filter { networkInterface -> + runCatching { + networkInterface.isUp && !networkInterface.isLoopback && !networkInterface.isVirtual + }.getOrDefault(false) + } + .flatMap { networkInterface -> + networkInterface.inetAddresses.asSequence() + } + .filter { address -> + !address.isAnyLocalAddress && + !address.isLoopbackAddress && + !address.isLinkLocalAddress && + !address.isMulticastAddress + } + .toList() + +internal fun String.toJmDnsServiceType(): String = + "${normalizeMdnsServiceType()}.local." diff --git a/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/JmDnsEndpointMapperTest.kt b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/JmDnsEndpointMapperTest.kt new file mode 100644 index 0000000..c21bbcb --- /dev/null +++ b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/JmDnsEndpointMapperTest.kt @@ -0,0 +1,60 @@ +package com.flyfishxu.kadb.mdns + +import com.flyfishxu.kadb.mdns.internal.mapJmDnsEndpoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class JmDnsEndpointMapperTest { + @Test + fun mapsValidResolvedService() { + val endpoint = mapJmDnsEndpoint( + type = "_adb-tls-connect._tcp.local.", + name = "Pixel", + hostAddresses = arrayOf("fe80::1", "192.168.1.20"), + port = 37123, + config = MdnsConfig(preferIpv4 = true) + ) + + assertEquals( + MdnsEndpoint( + name = "Pixel", + host = "192.168.1.20", + port = 37123, + serviceType = MdnsServiceType.TLS_CONNECT + ), + endpoint + ) + } + + @Test + fun rejectsUnknownTypeBlankHostAndInvalidPort() { + assertNull( + mapJmDnsEndpoint( + type = "_printer._tcp.local.", + name = "Printer", + hostAddresses = arrayOf("192.168.1.2"), + port = 1234, + config = MdnsConfig() + ) + ) + assertNull( + mapJmDnsEndpoint( + type = "_adb-tls-connect._tcp.local.", + name = "Pixel", + hostAddresses = emptyArray(), + port = 37123, + config = MdnsConfig() + ) + ) + assertNull( + mapJmDnsEndpoint( + type = "_adb-tls-connect._tcp.local.", + name = "Pixel", + hostAddresses = arrayOf("192.168.1.20"), + port = 0, + config = MdnsConfig() + ) + ) + } +} diff --git a/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvmTest.kt b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvmTest.kt new file mode 100644 index 0000000..8e86a20 --- /dev/null +++ b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/KadbMdnsJvmTest.kt @@ -0,0 +1,49 @@ +package com.flyfishxu.kadb.mdns + +import com.flyfishxu.kadb.mdns.internal.FakeJmDnsBackend +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KadbMdnsJvmTest { + @Test + fun startsListenersAndPublishesResolvedServices() { + val backend = FakeJmDnsBackend() + val mdns = KadbMdnsJvm(config = MdnsConfig(), backendFactory = { listOf(backend) }) + + mdns.start() + backend.emitAdded("_adb-tls-connect._tcp.local.", "Pixel", "192.168.1.20", 37123) + + val state = mdns.state.value + assertEquals(MdnsStatus.STARTED, state.status) + assertEquals("Pixel", state.connectDevices.single().name) + assertEquals("192.168.1.20", state.connectDevices.single().host) + assertEquals(37123, state.connectDevices.single().port) + } + + @Test + fun removesLostServices() { + val backend = FakeJmDnsBackend() + val mdns = KadbMdnsJvm(config = MdnsConfig(), backendFactory = { listOf(backend) }) + + mdns.start() + backend.emitAdded("_adb-tls-pairing._tcp.local.", "Pixel Pair", "192.168.1.20", 43011) + backend.emitRemoved("_adb-tls-pairing._tcp.local.", "Pixel Pair") + + assertEquals(emptyList(), mdns.state.value.pairDevices) + } + + @Test + fun stopRemovesListenersClosesBackendsAndClearsState() { + val backend = FakeJmDnsBackend() + val mdns = KadbMdnsJvm(config = MdnsConfig(), backendFactory = { listOf(backend) }) + + mdns.start() + mdns.stop() + + assertEquals(MdnsStatus.STOPPED, mdns.state.value.status) + assertTrue(mdns.state.value.allDevices.isEmpty()) + assertEquals(MdnsServiceType.entries.map { it.dnsType }.toSet(), backend.removedListenerTypes) + assertTrue(backend.closed) + } +} diff --git a/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/FakeJmDnsBackend.kt b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/FakeJmDnsBackend.kt new file mode 100644 index 0000000..f843f3b --- /dev/null +++ b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/FakeJmDnsBackend.kt @@ -0,0 +1,36 @@ +package com.flyfishxu.kadb.mdns.internal + +internal class FakeJmDnsBackend : JmDnsBackend { + private val listeners = linkedMapOf() + val removedListenerTypes = linkedSetOf() + var closed = false + private set + + override fun addServiceListener(type: String, listener: JmDnsServiceEvents) { + listeners[type] = listener + } + + override fun removeServiceListener(type: String, listener: JmDnsServiceEvents) { + if (listeners[type] == listener) { + listeners.remove(type) + } + removedListenerTypes += type + } + + override fun requestServiceInfo(type: String, name: String) = Unit + + override fun close() { + closed = true + } + + fun emitAdded(type: String, name: String, host: String, port: Int) { + val listener = listeners[type.removeSuffix(".local.").removeSuffix(".local")] ?: listeners[type] + listener?.serviceAdded(type, name) + listener?.serviceResolved(type, name, arrayOf(host), port) + } + + fun emitRemoved(type: String, name: String) { + val listener = listeners[type.removeSuffix(".local.").removeSuffix(".local")] ?: listeners[type] + listener?.serviceRemoved(type, name) + } +} diff --git a/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceTypeFormatterTest.kt b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceTypeFormatterTest.kt new file mode 100644 index 0000000..c605eeb --- /dev/null +++ b/kadb-mdns/src/jvmTest/kotlin/com/flyfishxu/kadb/mdns/internal/JmDnsServiceTypeFormatterTest.kt @@ -0,0 +1,16 @@ +package com.flyfishxu.kadb.mdns.internal + +import kotlin.test.Test +import kotlin.test.assertEquals + +class JmDnsServiceTypeFormatterTest { + @Test + fun formatsCommonServiceTypeForJmDns() { + assertEquals("_adb-tls-connect._tcp.local.", "_adb-tls-connect._tcp".toJmDnsServiceType()) + } + + @Test + fun keepsAlreadyQualifiedServiceTypeStable() { + assertEquals("_adb-tls-connect._tcp.local.", "_adb-tls-connect._tcp.local.".toJmDnsServiceType()) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7616ff8..16da5a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,3 +22,4 @@ plugins { rootProject.name = "Kadb" include(":kadb") +include(":kadb-mdns")