mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Implement KMP ServiceDiscovery for TCP devices (#4854)
This commit is contained in:
parent
a5d3914149
commit
b982b145e6
17 changed files with 523 additions and 77 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue