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
17 changed files with 523 additions and 77 deletions

View File

@@ -48,12 +48,14 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kermit)
implementation(libs.jetbrains.lifecycle.runtime)
}
val jvmMain by getting {
dependencies {
implementation(libs.ktor.client.java)
implementation(libs.jserialcomm)
implementation(libs.jmdns)
}
}

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)
}

View File

@@ -39,7 +39,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
**18/20** core modules are KMP with JVM targets. The 2 Android-only modules are intentionally platform-specific, with shared contracts already abstracted into `core:ui/commonMain`.
### Feature Modules (7 total — all KMP with JVM)
### Feature Modules (8 total — all KMP with JVM)
| Module | UI in commonMain? | Desktop wired? |
|---|:---:|:---:|
@@ -50,6 +50,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `feature:intro` | ✅ | — |
| `feature:map` | ✅ | Placeholder; shared `NodeMapViewModel` |
| `feature:firmware` | — | Placeholder; DFU is Android-only |
| `feature:widget` | ❌ | — | Android-only (App Widgets). Intentional. |
### Desktop Module
@@ -76,7 +77,7 @@ Working Compose Desktop application with:
| Multi-target readiness | **8/10** | Full JVM; release-ready desktop; iOS not declared |
| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated |
| DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
| Test maturity | **8/10** | 131 commonTest + 89 platform-specific = 219 tests across all 7 features; core:testing established |
| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features |
> See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis.
@@ -126,6 +127,8 @@ Based on the latest codebase investigation, the following steps are proposed to
All major ViewModels have now been extracted to `commonMain` and no longer rely on Android-specific subclasses. Platform-specific dependencies (like `android.net.Uri` or Location permissions) have been successfully isolated behind injected `core:repository` interfaces (e.g., `FileService`, `LocationService`).
**The extraction of all feature-specific navigation graphs, background services, and widgets out of `:app` is complete.** The `:app` module now only serves as the root DI assembler and NavHost container.
Extracted to shared `commonMain` (no longer app-only):
- `SettingsViewModel``feature:settings/commonMain`
- `RadioConfigViewModel``feature:settings/commonMain`
@@ -136,9 +139,10 @@ Extracted to shared `commonMain` (no longer app-only):
- `NodeMapViewModel``feature:map/commonMain` (Shared logic for node-specific maps)
- `BaseMapViewModel``feature:map/commonMain` (Core contract for all maps)
Extracted to core KMP modules (Android-specific implementations):
Extracted to core KMP modules:
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
- BLE, USB/Serial, TCP radio connections, and NsdManager`core:network/androidMain`
- BLE and USB/Serial radio connections → `core:network/androidMain`
- TCP radio connections and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
Remaining to be extracted from `:app` or unified in `commonMain`:
- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface)

View File

@@ -78,7 +78,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
## Near-Term Priorities (30 days)
1. **Evaluate KMP-native testing tools**Evaluate `Mokkery` or `Mockative` to replace `mockk` in `commonMain` of `core:testing` for iOS readiness. Integrate `Turbine` for shared `Flow` testing.
1. **Evaluate KMP-native testing tools****Done:** Fully evaluated and integrated `Mokkery`, `Turbine`, and `Kotest` across the KMP modules. `mockk` has been successfully replaced, enabling property-based and Flow testing in `commonTest` for iOS readiness.
2. **Desktop Map Integration** — Address the major Desktop feature gap by implementing a raster map view using [**MapComposeMP**](https://github.com/p-lr/MapComposeMP).
- Implement a `MapComposeProvider` for Desktop.
- Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane.

View File

@@ -17,7 +17,6 @@
package org.meshtastic.feature.connections.domain.usecase
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
@@ -28,6 +27,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.network.repository.UsbRepository
@@ -56,6 +56,7 @@ class AndroidGetDiscoveredDevicesUseCase(
private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
@@ -72,7 +73,7 @@ class AndroidGetDiscoveredDevicesUseCase(
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.attributes
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
@@ -125,7 +126,7 @@ class AndroidGetDiscoveredDevicesUseCase(
val usbDevices = args[3] as List<DeviceListEntry.Usb>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val resolved = args[4] as List<NsdServiceInfo>
val resolved = args[4] as List<DiscoveredService>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
@@ -136,8 +137,14 @@ class AndroidGetDiscoveredDevicesUseCase(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
val macSuffix =
entry.device.address
.replace(":", "")
.takeLast(macSuffixLength)
.lowercase(Locale.ROOT)
val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) ||
(nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix))
}
} else {
null
@@ -171,7 +178,7 @@ class AndroidGetDiscoveredDevicesUseCase(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}

View File

@@ -22,9 +22,12 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode
import org.meshtastic.core.resources.meshtastic
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@@ -34,17 +37,69 @@ class CommonGetDiscoveredDevicesUseCase(
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
return combine(nodeDb, recentAddressesDataSource.recentAddresses, usbFlow) { db, recentList, usbList ->
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { it.decodeToString() }
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
return combine(
nodeDb,
processedTcpFlow,
networkRepository.resolvedList,
recentAddressesDataSource.recentAddresses,
usbFlow,
) { db, processedTcp, resolved, recentList, usbList ->
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
@@ -63,6 +118,7 @@ class CommonGetDiscoveredDevicesUseCase(
.sortedBy { it.name }
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,
recentTcpDevices = recentTcpForUi,
usbDevices =
usbList +

View File

@@ -16,25 +16,52 @@
*/
package org.meshtastic.feature.connections.domain.usecase
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Tests for [CommonGetDiscoveredDevicesUseCase] covering TCP device discovery and node matching. */
class CommonGetDiscoveredDevicesUseCaseTest {
/*
private lateinit var useCase: CommonGetDiscoveredDevicesUseCase
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var recentAddressesDataSource: RecentAddressesDataSource
private lateinit var databaseManager: DatabaseManager
private lateinit var networkRepository: NetworkRepository
private val recentAddressesFlow = MutableStateFlow<List<RecentAddress>>(emptyList())
private val resolvedServicesFlow = MutableStateFlow<List<DiscoveredService>>(emptyList())
private fun setUp() {
nodeRepository = FakeNodeRepository()
recentAddressesDataSource = mock { every { recentAddresses } returns recentAddressesFlow }
databaseManager = mock { every { hasDatabaseFor(any()) } returns false }
networkRepository = mock {
every { resolvedList } returns resolvedServicesFlow
every { networkAvailable } returns flowOf(true)
}
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
}
@@ -45,7 +72,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val result = awaitItem()
assertTrue(result.recentTcpDevices.isEmpty(), "No recent TCP devices when empty")
assertTrue(result.usbDevices.isEmpty(), "No USB devices when showMock=false")
assertTrue(result.bleDevices.isEmpty(), "No BLE devices in common use case")
assertTrue(result.discoveredTcpDevices.isEmpty(), "No discovered TCP in common use case")
cancelAndIgnoreRemainingEvents()
}
@@ -71,7 +97,7 @@ class CommonGetDiscoveredDevicesUseCaseTest {
setUp()
useCase.invoke(showMock = true).test {
val result = awaitItem()
"Mock device should appear in usbDevices" shouldBe 1, result.usbDevices.size
result.usbDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
}
}
@@ -92,7 +118,14 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234", longName = "Test Node")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor("tMeshtastic_1234") } returns true
databaseManager = mock { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
@@ -111,8 +144,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor(any()) } returns false
recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
useCase.invoke(showMock = false).test {
@@ -123,24 +154,6 @@ class CommonGetDiscoveredDevicesUseCaseTest {
}
}
@Test
fun testSuffixTooShortForMatch() = runTest {
setUp()
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
nodeRepository.setNodes(listOf(testNode))
every { databaseManager.hasDatabaseFor("tShort_ab") } returns true
recentAddressesFlow.value = listOf(RecentAddress("tShort_ab", "Short_ab"))
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.recentTcpDevices.size shouldBe 1
assertNull(result.recentTcpDevices[0].node, "Suffix 'ab' is too short (< 4) to match")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testReactiveNodeUpdates() = runTest {
setUp()
@@ -153,10 +166,66 @@ class CommonGetDiscoveredDevicesUseCaseTest {
// Add a node to the repository — flow should re-emit
nodeRepository.setNodes(TestDataFactory.createTestNodes(2))
val secondResult = awaitItem()
"Recent TCP devices count unchanged" shouldBe 1, secondResult.recentTcpDevices.size
secondResult.recentTcpDevices.size shouldBe 1
cancelAndIgnoreRemainingEvents()
}
}
*/
@Test
fun testDiscoveredTcpDevices() = runTest {
setUp()
resolvedServicesFlow.value =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray(), "shortname" to "Mesh".encodeToByteArray()),
),
)
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
result.discoveredTcpDevices[0].name shouldBe "Mesh_1234"
result.discoveredTcpDevices[0].fullAddress shouldBe "t192.168.1.50"
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testDiscoveredTcpDeviceMatchesNode() = runTest {
setUp()
val testNode = TestDataFactory.createTestNode(num = 1, userId = "!1234", longName = "Mesh")
nodeRepository.setNodes(listOf(testNode))
databaseManager = mock { every { hasDatabaseFor("t192.168.1.50") } returns true }
useCase =
CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource = recentAddressesDataSource,
nodeRepository = nodeRepository,
databaseManager = databaseManager,
networkRepository = networkRepository,
)
resolvedServicesFlow.value =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray(), "shortname" to "Mesh".encodeToByteArray()),
),
)
useCase.invoke(showMock = false).test {
val result = awaitItem()
result.discoveredTcpDevices.size shouldBe 1
assertNotNull(result.discoveredTcpDevices[0].node)
result.discoveredTcpDevices[0].node?.user?.id shouldBe "!1234"
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -68,7 +68,7 @@ dependency-guard = "0.5.0"
kable = "0.42.0"
nordic-dfu = "2.11.0"
kmqtt = "1.0.0"
jmdns = "3.5.9"
[libraries]
# AndroidX
@@ -260,6 +260,8 @@ serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serializati
spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" }
jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" }
[plugins]
# Android
android-application = { id = "com.android.application", version.ref = "agp" }