mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 22:23:47 -04:00
feat: Implement KMP ServiceDiscovery for TCP devices (#4854)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class AndroidNetworkMonitor(private val connectivityManager: ConnectivityManager) : NetworkMonitor {
|
||||
override val networkAvailable: Flow<Boolean> = connectivityManager.networkAvailable()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import android.net.nsd.NsdManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class AndroidServiceDiscovery(private val nsdManager: NsdManager) : ServiceDiscovery {
|
||||
override val resolvedServices: Flow<List<DiscoveredService>> =
|
||||
nsdManager.serviceList(NetworkConstants.SERVICE_TYPE).map { list ->
|
||||
list.map { info ->
|
||||
val txtMap = mutableMapOf<String, ByteArray>()
|
||||
info.attributes.forEach { (key, value) -> txtMap[key] = value }
|
||||
@Suppress("DEPRECATION")
|
||||
DiscoveredService(
|
||||
name = info.serviceName,
|
||||
hostAddress = info.host?.hostAddress ?: "",
|
||||
port = info.port,
|
||||
txt = txtMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,24 +27,56 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
|
||||
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
|
||||
private const val RESOLVE_TIMEOUT_MS = 10000L
|
||||
private const val RESOLVE_BACKOFF_MS = 1000L
|
||||
|
||||
private fun NsdManager.discoverServices(
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal fun NsdManager.serviceList(
|
||||
serviceType: String,
|
||||
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
|
||||
): Flow<List<NsdServiceInfo>> = callbackFlow {
|
||||
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
|
||||
val resolvedServices = CopyOnWriteArrayList<NsdServiceInfo>()
|
||||
val resolveChannel = Channel<NsdServiceInfo>(Channel.UNLIMITED)
|
||||
val mutex = Mutex()
|
||||
|
||||
launch {
|
||||
for (service in resolveChannel) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(service) }
|
||||
if (resolved != null) {
|
||||
resolvedServices.removeAll { it.serviceName == resolved.serviceName }
|
||||
resolvedServices.add(resolved)
|
||||
trySend(resolvedServices.toList())
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
|
||||
delay(RESOLVE_BACKOFF_MS)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "NSD resolution failed for ${service.serviceName}" }
|
||||
delay(RESOLVE_BACKOFF_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
@@ -66,14 +98,13 @@ private fun NsdManager.discoverServices(
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
Logger.d { "NSD Service found: $serviceInfo" }
|
||||
serviceList += serviceInfo
|
||||
trySend(serviceList)
|
||||
resolveChannel.trySend(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
Logger.d { "NSD Service lost: $serviceInfo" }
|
||||
serviceList.removeAll { it.serviceName == serviceInfo.serviceName }
|
||||
trySend(serviceList)
|
||||
resolvedServices.removeAll { it.serviceName == serviceInfo.serviceName }
|
||||
trySend(resolvedServices.toList())
|
||||
}
|
||||
}
|
||||
trySend(emptyList()) // Emit an initial empty list
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
data class DiscoveredService(
|
||||
val name: String,
|
||||
val hostAddress: String,
|
||||
val port: Int,
|
||||
val txt: Map<String, ByteArray> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkMonitor {
|
||||
val networkAvailable: Flow<Boolean>
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkRepository {
|
||||
val networkAvailable: Flow<Boolean>
|
||||
val resolvedList: Flow<List<DiscoveredService>>
|
||||
|
||||
companion object {
|
||||
fun DiscoveredService.toAddressString() = buildString {
|
||||
append(hostAddress)
|
||||
if (port != NetworkConstants.SERVICE_PORT) {
|
||||
append(":$port")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -16,9 +16,6 @@
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -31,17 +28,16 @@ import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@Single
|
||||
class NetworkRepository(
|
||||
private val nsdManager: NsdManager,
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
@Single(binds = [NetworkRepository::class])
|
||||
class NetworkRepositoryImpl(
|
||||
networkMonitor: NetworkMonitor,
|
||||
serviceDiscovery: ServiceDiscovery,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
) {
|
||||
) : NetworkRepository {
|
||||
|
||||
val networkAvailable: Flow<Boolean> by lazy {
|
||||
connectivityManager
|
||||
.networkAvailable()
|
||||
override val networkAvailable: Flow<Boolean> by lazy {
|
||||
networkMonitor.networkAvailable
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.shareIn(
|
||||
@@ -52,9 +48,8 @@ class NetworkRepository(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
val resolvedList: Flow<List<NsdServiceInfo>> by lazy {
|
||||
nsdManager
|
||||
.serviceList(NetworkConstants.SERVICE_TYPE)
|
||||
override val resolvedList: Flow<List<DiscoveredService>> by lazy {
|
||||
serviceDiscovery.resolvedServices
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.shareIn(
|
||||
@@ -63,15 +58,4 @@ class NetworkRepository(
|
||||
replay = 1,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun NsdServiceInfo.toAddressString() = buildString {
|
||||
@Suppress("DEPRECATION")
|
||||
append(host.hostAddress)
|
||||
if (serviceType.trim('.') == NetworkConstants.SERVICE_TYPE && port != NetworkConstants.SERVICE_PORT) {
|
||||
append(":$port")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ServiceDiscovery {
|
||||
val resolvedServices: Flow<List<DiscoveredService>>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class JvmNetworkMonitor : NetworkMonitor {
|
||||
override val networkAvailable: Flow<Boolean> = flowOf(true)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koin.core.annotation.Single
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import javax.jmdns.JmDNS
|
||||
import javax.jmdns.ServiceEvent
|
||||
import javax.jmdns.ServiceListener
|
||||
|
||||
@Single
|
||||
class JvmServiceDiscovery : ServiceDiscovery {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override val resolvedServices: Flow<List<DiscoveredService>> =
|
||||
callbackFlow {
|
||||
val jmdns =
|
||||
try {
|
||||
JmDNS.create(InetAddress.getLocalHost())
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to create JmDNS" }
|
||||
null
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Unexpected error creating JmDNS" }
|
||||
null
|
||||
}
|
||||
|
||||
val services = mutableMapOf<String, DiscoveredService>()
|
||||
|
||||
val listener =
|
||||
object : ServiceListener {
|
||||
override fun serviceAdded(event: ServiceEvent) {
|
||||
jmdns?.requestServiceInfo(event.type, event.name)
|
||||
}
|
||||
|
||||
override fun serviceRemoved(event: ServiceEvent) {
|
||||
services.remove(event.name)
|
||||
trySend(services.values.toList())
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
val info = event.info
|
||||
val txtMap = mutableMapOf<String, ByteArray>()
|
||||
info.propertyNames.toList().forEach { key ->
|
||||
info.getPropertyBytes(key)?.let { value -> txtMap[key] = value }
|
||||
}
|
||||
val discovered =
|
||||
DiscoveredService(
|
||||
name = info.name,
|
||||
hostAddress = info.hostAddresses.firstOrNull() ?: "",
|
||||
port = info.port,
|
||||
txt = txtMap,
|
||||
)
|
||||
services[info.name] = discovered
|
||||
trySend(services.values.toList())
|
||||
}
|
||||
}
|
||||
|
||||
val type = "${NetworkConstants.SERVICE_TYPE}.local."
|
||||
jmdns?.addServiceListener(type, listener)
|
||||
|
||||
awaitClose {
|
||||
jmdns?.removeServiceListener(type, listener)
|
||||
try {
|
||||
jmdns?.close()
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to close JmDNS" }
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Unexpected error closing JmDNS" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user