feat: Implement KMP ServiceDiscovery for TCP devices (#4854)

This commit is contained in:
James Rich 2026-03-19 12:19:58 -05:00 committed by GitHub
parent a5d3914149
commit b982b145e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 523 additions and 77 deletions

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import android.net.ConnectivityManager
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
@Single
class AndroidNetworkMonitor(private val connectivityManager: ConnectivityManager) : NetworkMonitor {
override val networkAvailable: Flow<Boolean> = connectivityManager.networkAvailable()
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import android.net.nsd.NsdManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
@Single
class AndroidServiceDiscovery(private val nsdManager: NsdManager) : ServiceDiscovery {
override val resolvedServices: Flow<List<DiscoveredService>> =
nsdManager.serviceList(NetworkConstants.SERVICE_TYPE).map { list ->
list.map { info ->
val txtMap = mutableMapOf<String, ByteArray>()
info.attributes.forEach { (key, value) -> txtMap[key] = value }
@Suppress("DEPRECATION")
DiscoveredService(
name = info.serviceName,
hostAddress = info.host?.hostAddress ?: "",
port = info.port,
txt = txtMap,
)
}
}
}

View file

@ -27,24 +27,56 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
@OptIn(ExperimentalCoroutinesApi::class)
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
private const val RESOLVE_TIMEOUT_MS = 10000L
private const val RESOLVE_BACKOFF_MS = 1000L
private fun NsdManager.discoverServices(
@Suppress("TooGenericExceptionCaught")
@OptIn(ExperimentalCoroutinesApi::class)
internal fun NsdManager.serviceList(
serviceType: String,
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
): Flow<List<NsdServiceInfo>> = callbackFlow {
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
val resolvedServices = CopyOnWriteArrayList<NsdServiceInfo>()
val resolveChannel = Channel<NsdServiceInfo>(Channel.UNLIMITED)
val mutex = Mutex()
launch {
for (service in resolveChannel) {
mutex.withLock {
try {
val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(service) }
if (resolved != null) {
resolvedServices.removeAll { it.serviceName == resolved.serviceName }
resolvedServices.add(resolved)
trySend(resolvedServices.toList())
}
} catch (e: IllegalArgumentException) {
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
delay(RESOLVE_BACKOFF_MS)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
delay(RESOLVE_BACKOFF_MS)
}
}
}
}
val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
@ -66,14 +98,13 @@ private fun NsdManager.discoverServices(
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service found: $serviceInfo" }
serviceList += serviceInfo
trySend(serviceList)
resolveChannel.trySend(serviceInfo)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service lost: $serviceInfo" }
serviceList.removeAll { it.serviceName == serviceInfo.serviceName }
trySend(serviceList)
resolvedServices.removeAll { it.serviceName == serviceInfo.serviceName }
trySend(resolvedServices.toList())
}
}
trySend(emptyList()) // Emit an initial empty list

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
data class DiscoveredService(
val name: String,
val hostAddress: String,
val port: Int,
val txt: Map<String, ByteArray> = emptyMap(),
)

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val networkAvailable: Flow<Boolean>
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import kotlinx.coroutines.flow.Flow
interface NetworkRepository {
val networkAvailable: Flow<Boolean>
val resolvedList: Flow<List<DiscoveredService>>
companion object {
fun DiscoveredService.toAddressString() = buildString {
append(hostAddress)
if (port != NetworkConstants.SERVICE_PORT) {
append(":$port")
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,9 +16,6 @@
*/
package org.meshtastic.core.network.repository
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.Flow
@ -31,17 +28,16 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class NetworkRepository(
private val nsdManager: NsdManager,
private val connectivityManager: ConnectivityManager,
@Single(binds = [NetworkRepository::class])
class NetworkRepositoryImpl(
networkMonitor: NetworkMonitor,
serviceDiscovery: ServiceDiscovery,
private val dispatchers: CoroutineDispatchers,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
) {
) : NetworkRepository {
val networkAvailable: Flow<Boolean> by lazy {
connectivityManager
.networkAvailable()
override val networkAvailable: Flow<Boolean> by lazy {
networkMonitor.networkAvailable
.flowOn(dispatchers.io)
.conflate()
.shareIn(
@ -52,9 +48,8 @@ class NetworkRepository(
.distinctUntilChanged()
}
val resolvedList: Flow<List<NsdServiceInfo>> by lazy {
nsdManager
.serviceList(NetworkConstants.SERVICE_TYPE)
override val resolvedList: Flow<List<DiscoveredService>> by lazy {
serviceDiscovery.resolvedServices
.flowOn(dispatchers.io)
.conflate()
.shareIn(
@ -63,15 +58,4 @@ class NetworkRepository(
replay = 1,
)
}
companion object {
fun NsdServiceInfo.toAddressString() = buildString {
@Suppress("DEPRECATION")
append(host.hostAddress)
if (serviceType.trim('.') == NetworkConstants.SERVICE_TYPE && port != NetworkConstants.SERVICE_PORT) {
append(":$port")
}
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import kotlinx.coroutines.flow.Flow
interface ServiceDiscovery {
val resolvedServices: Flow<List<DiscoveredService>>
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.koin.core.annotation.Single
@Single
class JvmNetworkMonitor : NetworkMonitor {
override val networkAvailable: Flow<Boolean> = flowOf(true)
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
import java.io.IOException
import java.net.InetAddress
import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
@Single
class JvmServiceDiscovery : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow<List<DiscoveredService>> =
callbackFlow {
val jmdns =
try {
JmDNS.create(InetAddress.getLocalHost())
} catch (e: IOException) {
Logger.e(e) { "Failed to create JmDNS" }
null
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) { "Unexpected error creating JmDNS" }
null
}
val services = mutableMapOf<String, DiscoveredService>()
val listener =
object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
jmdns?.requestServiceInfo(event.type, event.name)
}
override fun serviceRemoved(event: ServiceEvent) {
services.remove(event.name)
trySend(services.values.toList())
}
override fun serviceResolved(event: ServiceEvent) {
val info = event.info
val txtMap = mutableMapOf<String, ByteArray>()
info.propertyNames.toList().forEach { key ->
info.getPropertyBytes(key)?.let { value -> txtMap[key] = value }
}
val discovered =
DiscoveredService(
name = info.name,
hostAddress = info.hostAddresses.firstOrNull() ?: "",
port = info.port,
txt = txtMap,
)
services[info.name] = discovered
trySend(services.values.toList())
}
}
val type = "${NetworkConstants.SERVICE_TYPE}.local."
jmdns?.addServiceListener(type, listener)
awaitClose {
jmdns?.removeServiceListener(type, listener)
try {
jmdns?.close()
} catch (e: IOException) {
Logger.e(e) { "Failed to close JmDNS" }
} catch (e: Exception) {
Logger.e(e) { "Unexpected error closing JmDNS" }
}
}
}
.flowOn(Dispatchers.IO)
}