diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index c70edc7c4..1e086af6a 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -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) } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidNetworkMonitor.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidNetworkMonitor.kt new file mode 100644 index 000000000..e11c4fba5 --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidNetworkMonitor.kt @@ -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 . + */ +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 = connectivityManager.networkAvailable() +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidServiceDiscovery.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidServiceDiscovery.kt new file mode 100644 index 000000000..e00ab8c60 --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/AndroidServiceDiscovery.kt @@ -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 . + */ +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> = + nsdManager.serviceList(NetworkConstants.SERVICE_TYPE).map { list -> + list.map { info -> + val txtMap = mutableMapOf() + info.attributes.forEach { (key, value) -> txtMap[key] = value } + @Suppress("DEPRECATION") + DiscoveredService( + name = info.serviceName, + hostAddress = info.host?.hostAddress ?: "", + port = info.port, + txt = txtMap, + ) + } + } +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NsdManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NsdManager.kt index ce272bf59..45180d432 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NsdManager.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NsdManager.kt @@ -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> = - 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> = callbackFlow { - val serviceList = CopyOnWriteArrayList() + val resolvedServices = CopyOnWriteArrayList() + val resolveChannel = Channel(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 diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/DiscoveredService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/DiscoveredService.kt new file mode 100644 index 000000000..3c2a3c623 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/DiscoveredService.kt @@ -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 . + */ +package org.meshtastic.core.network.repository + +data class DiscoveredService( + val name: String, + val hostAddress: String, + val port: Int, + val txt: Map = emptyMap(), +) diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkMonitor.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkMonitor.kt new file mode 100644 index 000000000..28aa67e4b --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkMonitor.kt @@ -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 . + */ +package org.meshtastic.core.network.repository + +import kotlinx.coroutines.flow.Flow + +interface NetworkMonitor { + val networkAvailable: Flow +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt new file mode 100644 index 000000000..19863dcb8 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt @@ -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 . + */ +package org.meshtastic.core.network.repository + +import kotlinx.coroutines.flow.Flow + +interface NetworkRepository { + val networkAvailable: Flow + val resolvedList: Flow> + + companion object { + fun DiscoveredService.toAddressString() = buildString { + append(hostAddress) + if (port != NetworkConstants.SERVICE_PORT) { + append(":$port") + } + } + } +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepositoryImpl.kt similarity index 67% rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepositoryImpl.kt index 2e0f797ef..5990152f8 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/NetworkRepository.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/NetworkRepositoryImpl.kt @@ -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 by lazy { - connectivityManager - .networkAvailable() + override val networkAvailable: Flow by lazy { + networkMonitor.networkAvailable .flowOn(dispatchers.io) .conflate() .shareIn( @@ -52,9 +48,8 @@ class NetworkRepository( .distinctUntilChanged() } - val resolvedList: Flow> by lazy { - nsdManager - .serviceList(NetworkConstants.SERVICE_TYPE) + override val resolvedList: Flow> 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") - } - } - } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/ServiceDiscovery.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/ServiceDiscovery.kt new file mode 100644 index 000000000..4a4dc594c --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/ServiceDiscovery.kt @@ -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 . + */ +package org.meshtastic.core.network.repository + +import kotlinx.coroutines.flow.Flow + +interface ServiceDiscovery { + val resolvedServices: Flow> +} diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmNetworkMonitor.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmNetworkMonitor.kt new file mode 100644 index 000000000..e464979f2 --- /dev/null +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmNetworkMonitor.kt @@ -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 . + */ +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 = flowOf(true) +} diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt new file mode 100644 index 000000000..952d70c67 --- /dev/null +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt @@ -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 . + */ +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> = + 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() + + 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() + 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) +} diff --git a/docs/kmp-status.md b/docs/kmp-status.md index cd681398c..e1f077221 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -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) diff --git a/docs/roadmap.md b/docs/roadmap.md index e21880d2b..dc785bc50 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index d620a4933..91c37fec1 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -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, ) : GetDiscoveredDevicesUseCase { private val suffixLength = 4 + private val macSuffixLength = 8 @Suppress("LongMethod", "CyclomaticComplexMethod") override fun invoke(showMock: Boolean): Flow { @@ -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 @Suppress("UNCHECKED_CAST", "MagicNumber") - val resolved = args[4] as List + val resolved = args[4] as List @Suppress("UNCHECKED_CAST", "MagicNumber") val recentList = args[5] as List @@ -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") } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index 7b073d09a..e53653f9b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -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 { 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 + diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt index 6fc7bde7b..c1ac1e70c 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt @@ -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>(emptyList()) + private val resolvedServicesFlow = MutableStateFlow>(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() + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e42032c38..b2107efe6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }