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