Refactor command handling, enhance tests, and improve discovery logic (#4878)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-22 00:42:27 -05:00
committed by GitHub
parent d136b162a4
commit c38bfc64de
76 changed files with 2220 additions and 1277 deletions

View File

@@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
@@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ wireless-install.sh
# Git worktrees
.worktrees/
/firebase-debug.log.jdk/
firebase-debug.log

View File

@@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
@@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.

View File

@@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
@@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.

View File

@@ -20,6 +20,9 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Needed for protobufs
-keep class com.google.protobuf.** { *; }
-keep class org.meshtastic.proto.** { *; }

View File

@@ -90,11 +90,10 @@ import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.AlertHost
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@@ -122,39 +121,17 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
}
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() })
}
}
SharedDialogs(
connectionState = connectionState,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
)
VersionChecks(uIViewModel)
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()
alertDialogState?.let { state ->
val title = state.title ?: state.titleRes?.let { stringResource(it) } ?: ""
val message = state.message ?: state.messageRes?.let { stringResource(it) }
val confirmText = state.confirmText ?: state.confirmTextRes?.let { stringResource(it) }
val dismissText = state.dismissText ?: state.dismissTextRes?.let { stringResource(it) }
MeshtasticDialog(
title = title,
message = message,
html = state.html,
icon = state.icon,
text = state.composableMessage?.let { msg -> { msg.Content() } },
confirmText = confirmText,
onConfirm = state.onConfirm,
dismissText = dismissText,
onDismiss = state.onDismiss,
choices = state.choices,
dismissable = state.dismissable,
)
}
AlertHost(uIViewModel.alertManager)
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }

View File

@@ -17,9 +17,9 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.provider.Property
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
import org.koin.compiler.plugin.KoinGradleExtension
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
@@ -28,28 +28,14 @@ class KoinConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = libs.plugin("koin-compiler").get().pluginId)
// Configure Koin Compiler Plugin (0.4.0+)
extensions.configure<Any>("koinCompiler") {
val extension = this
val clazz = extension.javaClass
try {
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin 0.4.0's A1
// per-module safety checks strictly enforce that all dependencies must be explicitly
// provided or included locally. This breaks decoupled Clean Architecture designs.
// We disable A1 compile safety globally to properly rely on Koin's A3 full-graph
// validation which perfectly handles inverted dependencies at the composition root.
try {
clazz.getMethod("setCompileSafety", Boolean::class.java).invoke(extension, false)
} catch (e: Exception) {
val prop = clazz.getMethod("getCompileSafety").invoke(extension)
if (prop is Property<*>) {
@Suppress("UNCHECKED_CAST")
(prop as Property<Boolean>).set(false)
}
}
} catch (e: Exception) {
// Ignore gracefully if Koin DSL changes in the future
}
// Configure Koin K2 Compiler Plugin (0.4.0+)
extensions.configure(KoinGradleExtension::class.java) {
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
// per-module safety checks strictly enforce that all dependencies must be explicitly
// provided or included locally. This breaks decoupled Clean Architecture designs.
// We disable compile safety globally to properly rely on Koin's A3 full-graph
// validation which perfectly handles inverted dependencies at the composition root.
compileSafety.set(false)
}
val koinAnnotations = libs.findLibrary("koin-annotations").get()

View File

@@ -90,6 +90,21 @@ internal fun Project.configureKotlinMultiplatform() {
}
}
// Disable iOS native test link & run tasks.
// iOS targets exist only for compile-time validation; linking test
// executables is extremely slow and causes `./gradlew test` to hang.
tasks.configureEach {
val taskName = name.lowercase()
if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) {
if (taskName.startsWith("link") && taskName.contains("test") ||
taskName == "iosarm64test" || taskName == "iossimulatorarm64test" ||
taskName.endsWith("testbinaries")
) {
enabled = false
}
}
}
configureMokkery()
configureKotlin<KotlinMultiplatformExtension>()
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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
@@ -19,15 +19,16 @@ package org.meshtastic.core.ble
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class BleRetryTest {
@Test
fun `retryBleOperation returns immediately on success`() = runTest {
fun retryBleOperation_returns_immediately_on_success() = runTest {
var attempts = 0
val result =
retryBleOperation(count = 3, delayMs = 10L) {
@@ -39,7 +40,7 @@ class BleRetryTest {
}
@Test
fun `retryBleOperation retries on exception and succeeds`() = runTest {
fun retryBleOperation_retries_on_exception_and_succeeds() = runTest {
var attempts = 0
val result =
retryBleOperation(count = 3, delayMs = 10L) {
@@ -54,32 +55,30 @@ class BleRetryTest {
}
@Test
fun `retryBleOperation throws exception after max attempts`() = runTest {
fun retryBleOperation_throws_exception_after_max_attempts() = runTest {
var attempts = 0
var caughtException: Exception? = null
try {
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw RuntimeException("Persistent error")
val ex =
assertFailsWith<RuntimeException> {
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw RuntimeException("Persistent error")
}
}
} catch (e: Exception) {
caughtException = e
}
assertTrue(caughtException is RuntimeException)
assertEquals("Persistent error", caughtException?.message)
assertTrue(ex is RuntimeException)
assertEquals("Persistent error", ex.message)
assertEquals(3, attempts)
}
@Test(expected = CancellationException::class)
fun `retryBleOperation does not retry CancellationException`() = runTest {
@Test
fun retryBleOperation_does_not_retry_CancellationException() = runTest {
var attempts = 0
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw CancellationException("Cancelled")
assertFailsWith<CancellationException> {
retryBleOperation(count = 3, delayMs = 10L) {
attempts++
throw CancellationException("Cancelled")
}
}
// Test fails if it catches and doesn't rethrow, or if it retries.
// It shouldn't reach the assertion below because the exception should be thrown immediately.
assertEquals(1, attempts)
}
}

View File

@@ -34,9 +34,11 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -57,18 +59,16 @@ class CommandSenderImpl(
private val packetHandler: PacketHandler,
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
override var lastNeighborInfo: NeighborInfo? = null
// We'll need a way to track connection state in shared code,
// maybe via ServiceRepository or similar.
// For now I'll assume it's injected or available.
@@ -251,7 +251,7 @@ class CommandSenderImpl(
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis
tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
@@ -302,11 +302,11 @@ class CommandSenderImpl(
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
lastNeighborInfo
neighborInfoHandler.lastNeighborInfo
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }

View File

@@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
@@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl(
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L
override fun start(scope: CoroutineScope) {
@@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl(
override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@@ -253,13 +254,13 @@ class MeshConnectionManagerImpl(
}
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) }
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
startHandshakeStallGuard(1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
action()
}
@@ -340,8 +341,6 @@ class MeshConnectionManagerImpl(
}
companion object {
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 30.seconds

View File

@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
@@ -37,12 +35,10 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
@@ -59,6 +55,7 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
@@ -75,8 +72,6 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
@@ -107,17 +102,21 @@ class MeshDataHandlerImpl(
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
private val historyManager: HistoryManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
private val storeForwardHandler: StoreForwardPacketHandler,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
override fun start(scope: CoroutineScope) {
this.scope = scope
storeForwardHandler.start(scope)
}
private val rememberDataType =
@@ -191,11 +190,11 @@ class MeshDataHandlerImpl(
}
PortNum.STORE_FORWARD_APP -> {
handleStoreAndForward(packet, dataPacket, myNodeNum)
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
}
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
handleStoreForwardPlusPlus(packet)
storeForwardHandler.handleStoreForwardPlusPlus(packet)
}
PortNum.ADMIN_APP -> {
@@ -235,98 +234,6 @@ class MeshDataHandlerImpl(
rememberDataPacket(u, myNodeNum)
}
private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
// If it has a commit hash, it's already on the chain (Confirmed)
// Otherwise it's still being routed via SF++ (Routing)
val status =
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
// Prefer a full 16-byte hash calculated from the message bytes if available
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository.value.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
private fun handlePaxCounter(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
@@ -559,52 +466,6 @@ class MeshDataHandlerImpl(
}
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
// For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
// In the original, it was used for logging.
val h = s.history
val lastRequest = h?.last_request ?: 0
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
}
h != null -> {
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
// historyManager call remains same
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
else -> {}
}
}
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
@@ -807,7 +668,5 @@ class MeshDataHandlerImpl(
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
}
}

View File

@@ -17,13 +17,15 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo
class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override var lastNeighborInfo: NeighborInfo? = null
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
@@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl(
// Store the last neighbor info from our connected radio
val from = packet.from
if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni
lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}
@@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl(
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = commandSender.neighborInfoStartTimes.remove(requestId)
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val neighbors =
ni.neighbors.joinToString("\n") { n ->

View File

@@ -0,0 +1,189 @@
/*
* Copyright (c) 2025-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.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import kotlin.time.Duration.Companion.milliseconds
/** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */
@Single
class StoreForwardPacketHandlerImpl(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
) : StoreForwardPacketHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
override fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> handleLinkProvide(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository.value.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
val h = s.history
val lastRequest = h?.last_request ?: 0
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
dataHandler.value.rememberDataPacket(u, myNodeNum)
}
h != null -> {
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
dataHandler.value.rememberDataPacket(u, myNodeNum)
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
dataHandler.value.rememberDataPacket(u, myNodeNum)
}
else -> {}
}
}
}

View File

@@ -17,18 +17,20 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -42,33 +44,43 @@ class TracerouteHandlerImpl(
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
// Decode the route discovery once — avoids triple protobuf decode
val routeDiscovery = packet.fullRouteDiscovery ?: return
val forwardRoute = routeDiscovery.route
val returnRoute = routeDiscovery.route_back
// Require both directions for a "full" traceroute response
if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return
val full =
packet.getFullTracerouteResponse(
routeDiscovery.getTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
"${node.user.long_name} (${node.user.short_name})"
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
?: "Unknown" // TODO: Use core:resources once available in core:data
},
headerTowards = "Route towards destination:",
headerBack = "Route back to us:",
) ?: return
)
val requestId = packet.decoded?.request_id ?: 0
if (logUuid != null) {
scope.handledLaunch {
logInsertJob?.join()
val routeDiscovery = packet.fullRouteDiscovery
val forwardRoute = routeDiscovery?.route.orEmpty()
val returnRoute = routeDiscovery?.route_back.orEmpty()
val routeNodeNums = (forwardRoute + returnRoute).distinct()
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
val snapshotPositions =
@@ -77,28 +89,27 @@ class TracerouteHandlerImpl(
}
}
val start = commandSender.tracerouteStartTimes.remove(requestId)
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s"
"$full\n\n$durationText"
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
full
}
val routeDiscovery = packet.fullRouteDiscovery
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.route.orEmpty(),
returnRoute = routeDiscovery?.route_back.orEmpty(),
forwardRoute = forwardRoute,
returnRoute = returnRoute,
logUuid = logUuid,
),
)

View File

@@ -16,15 +16,47 @@
*/
package org.meshtastic.core.data.manager
class FromRadioPacketHandlerImplTest {
/*
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.QueueStatus
import kotlin.test.BeforeTest
import kotlin.test.Test
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
class FromRadioPacketHandlerImplTest {
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val router: MeshRouter = mock(MockMode.autofill)
private lateinit var handler: FromRadioPacketHandlerImpl
@Before
@BeforeTest
fun setup() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
handler =
FromRadioPacketHandlerImpl(
@@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleMyInfo(myInfo) }
verify { configFlowManager.handleMyInfo(myInfo) }
}
@Test
@@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleLocalMetadata(metadata) }
verify { configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
val nodeInfo = NodeInfo(num = 1234)
val nodeInfo = ProtoNodeInfo(num = 1234)
val proto = FromRadio(node_info = nodeInfo)
every { router.configFlowManager.newNodeCount } returns 1
every { configFlowManager.newNodeCount } returns 1
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
verify { configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
}
@@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleConfigComplete(nonce) }
verify { configFlowManager.handleConfigComplete(nonce) }
}
@Test
@@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configHandler.handleDeviceConfig(config) }
verify { configHandler.handleDeviceConfig(config) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val proto = FromRadio(moduleConfig = moduleConfig)
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { packetHandler.removeResponse(0, complete = false) }
verify { configHandler.handleModuleConfig(moduleConfig) }
}
*/
@Test
fun `handleFromRadio routes CHANNEL to configHandler`() {
val channel = Channel(index = 0)
val proto = FromRadio(channel = channel)
handler.handleFromRadio(proto)
verify { configHandler.handleChannel(channel) }
}
@Test
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
val proto = FromRadio(mqttClientProxyMessage = proxyMsg)
handler.handleFromRadio(proto)
verify { mqttManager.handleMqttProxyMessage(proxyMsg) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
// Note: getString() from Compose Resources requires Skiko native lib which
// is not available in headless JVM tests. We test the parts that don't trigger it.
try {
handler.handleFromRadio(proto)
} catch (_: Throwable) {
// Expected: Skiko can't load in headless JVM/native
}
verify { serviceRepository.setClientNotification(notification) }
}
}

View File

@@ -16,9 +16,9 @@
*/
package org.meshtastic.core.data.manager
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.proto.StoreAndForward
import kotlin.test.Test
import kotlin.test.assertEquals
class HistoryManagerImplTest {

View File

@@ -17,10 +17,25 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
@@ -35,12 +50,23 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class MeshDataHandlerTest {
private lateinit var handler: MeshDataHandlerImpl
@@ -56,12 +82,15 @@ class MeshDataHandlerTest {
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val historyManager: HistoryManager = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val messageFilter: MessageFilter = mock(MockMode.autofill)
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@BeforeTest
fun setUp() {
@@ -79,13 +108,21 @@ class MeshDataHandlerTest {
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
commandSender = commandSender,
historyManager = historyManager,
connectionManager = lazy { connectionManager },
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
radioConfigRepository = radioConfigRepository,
messageFilter = messageFilter,
storeForwardHandler = storeForwardHandler,
)
handler.start(testScope)
// Default: mapper returns null for empty packets, which is the safe default
every { dataMapper.toDataPacket(any()) } returns null
// Stub commonly accessed properties to avoid NPE from autofill
every { nodeManager.nodeDBbyID } returns emptyMap()
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
}
@Test
@@ -94,8 +131,582 @@ class MeshDataHandlerTest {
}
@Test
fun `handleReceivedData processes packet`() {
fun `handleReceivedData returns early when dataMapper returns null`() {
val packet = MeshPacket()
every { dataMapper.toDataPacket(packet) } returns null
handler.handleReceivedData(packet, 123)
// Should not broadcast if dataMapper returns null
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData does not broadcast for position from local node`() {
val myNodeNum = 123
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(myNodeNum),
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
// Position from local node: shouldBroadcast stays as !fromUs = false
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData broadcasts for remote packets`() {
val myNodeNum = 123
val remoteNum = 456
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(remoteNum),
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData tracks analytics`() {
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = "!other",
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { analytics.track("num_data_receive", any()) }
}
// --- Position handling ---
@Test
fun `position packet delegates to nodeManager`() {
val myNodeNum = 123
val remoteNum = 456
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
}
// --- NodeInfo handling ---
@Test
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
val myNodeNum = 123
val remoteNum = 456
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
}
@Test
fun `nodeinfo packet from local node is ignored`() {
val myNodeNum = 123
val user = User(id = "!local", long_name = "Local", short_name = "L")
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// --- Paxcounter handling ---
@Test
fun `paxcounter packet delegates to nodeManager`() {
val remoteNum = 456
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = pax.encode().toByteString(),
dataType = PortNum.PAXCOUNTER_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
}
// --- Traceroute handling ---
@Test
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = "!local",
bytes = byteArrayOf().toByteString(),
dataType = PortNum.TRACEROUTE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- NeighborInfo handling ---
@Test
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
val ni = NeighborInfo(node_id = 456)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = ni.encode().toByteString(),
dataType = PortNum.NEIGHBORINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { neighborInfoHandler.handleNeighborInfo(packet) }
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Store-and-Forward handling ---
@Test
fun `store forward packet delegates to storeForwardHandler`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf().toByteString(),
dataType = PortNum.STORE_FORWARD_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
}
// --- Routing/ACK-NAK handling ---
@Test
fun `routing packet with successful ack broadcasts and removes response`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { packetHandler.removeResponse(99, complete = true) }
}
@Test
fun `routing packet always broadcasts`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Telemetry handling ---
@Test
fun `telemetry packet updates node via nodeManager`() {
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { nodeManager.updateNode(456, any(), any(), any()) }
}
@Test
fun `telemetry from local node also updates connectionManager`() {
val myNodeNum = 123
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { connectionManager.updateTelemetry(any()) }
}
// --- Text message handling ---
@Test
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
@Test
fun `duplicate text message is not inserted again`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
// Return existing packet on duplicate check
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
packetRepository.insert(any(), any(), any(), any(), any(), any())
}
}
// --- Reaction handling ---
@Test
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
val emojiBytes = "👍".encodeToByteArray()
val packet =
MeshPacket(
id = 99,
from = 456,
to = 123,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = emojiBytes.toByteString(),
reply_id = 42,
emoji = 1,
),
)
val dataPacket =
DataPacket(
id = 99,
from = "!remote",
to = "!local",
bytes = emojiBytes.toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.nodeDBbyNodeNum } returns
mapOf(
456 to Node(num = 456, user = User(id = "!remote")),
123 to Node(num = 123, user = User(id = "!local")),
)
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
every { nodeManager.myNodeNum } returns 123
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insertReaction(any(), 123) }
}
// --- Range test / detection sensor handling ---
@Test
fun `range test packet is remembered as text message type`() = testScope.runTest {
val packet =
MeshPacket(
id = 55,
from = 456,
decoded =
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 55,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "test".encodeToByteArray().toByteString(),
dataType = PortNum.RANGE_TEST_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Range test should be remembered with TEXT_MESSAGE_APP dataType
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
// --- Admin message handling ---
@Test
fun `admin message sets session passkey`() {
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
val packet =
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = admin.encode().toByteString(),
dataType = PortNum.ADMIN_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { commandSender.setSessionPasskey(any()) }
}
// --- Message filtering ---
@Test
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
val packet =
MeshPacket(
id = 77,
from = 456,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "spam content".encodeToByteArray().toByteString(),
),
)
val dataPacket =
DataPacket(
id = 77,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "spam content".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
every { nodeManager.nodeDBbyID } returns emptyMap()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter("spam content", false) } returns true
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Verify insert was called with filtered = true (6th param)
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
@Test
fun `message from ignored node is filtered`() = testScope.runTest {
val packet =
MeshPacket(
id = 88,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 88,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
every { nodeManager.nodeDBbyID } returns
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
}

View File

@@ -16,22 +16,29 @@
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.FilterPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MessageFilterImplTest {
/*
private lateinit var filterPrefs: FilterPrefs
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean>
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>>
private val filterEnabledFlow = MutableStateFlow(true)
private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
private lateinit var filterService: MessageFilterImpl
@Before
@BeforeTest
fun setup() {
filterEnabledFlow = MutableStateFlow(true)
filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
filterPrefs = mockk {
every { filterEnabled } returns filterEnabledFlow
every { filterWords } returns filterWordsFlow
}
filterPrefs = mock(MockMode.autofill)
every { filterPrefs.filterEnabled } returns filterEnabledFlow
every { filterPrefs.filterWords } returns filterWordsFlow
filterService = MessageFilterImpl(filterPrefs)
}
@@ -92,6 +99,4 @@ class MessageFilterImplTest {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
}
*/
}

View File

@@ -16,17 +16,36 @@
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
/*
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private lateinit var nodeManager: NodeManagerImpl
@Before
@BeforeTest
fun setUp() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
}
@@ -63,8 +82,9 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234
// Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString)
val existingUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2)
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
@@ -81,29 +101,30 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000)
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.position)
assertEquals(45.0, result.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
assertNotNull(result)
assertNotNull(result.position)
assertEquals(450000000, result.position.latitude_i)
assertEquals(900000000, result.position.longitude_i)
}
@Test
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
val nodeNum = 1234
val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
// Receive "zero" position with new satellite count
val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals(45.0, result!!.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
assertEquals(450000000, result!!.position.latitude_i)
assertEquals(900000000, result.position.longitude_i)
assertEquals(5, result.position.sats_in_view)
assertEquals(1001, result.lastHeard)
}
@@ -111,13 +132,13 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedPosition for local node ignores purely empty packets`() {
val myNum = 1111
val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
val result = nodeManager.nodeDBbyNodeNum[myNum]
// Should still be a default/unset node if it didn't exist, or shouldn't have position
assertTrue(result == null || result.position.latitude_i == null)
// Should still be null since the empty position for local node is ignored
assertNull(result)
}
@Test
@@ -125,11 +146,7 @@ class NodeManagerImplTest {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
val telemetry =
org.meshtastic.proto.Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
)
val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -140,10 +157,7 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedTelemetry updates device metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
)
val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -157,10 +171,7 @@ class NodeManagerImplTest {
fun `handleReceivedTelemetry updates environment metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
environment_metrics =
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
)
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -180,5 +191,39 @@ class NodeManagerImplTest {
assertNull(nodeManager.myNodeNum)
}
*/
@Test
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
assertEquals(DataPacket.ID_BROADCAST, result)
}
@Test
fun `toNodeID returns default hex ID for unknown node`() {
val result = nodeManager.toNodeID(0x1234)
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
}
@Test
fun `toNodeID returns user ID for known node`() {
val nodeNum = 5678
val userId = "!customid"
nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) }
val result = nodeManager.toNodeID(nodeNum)
assertEquals(userId, result)
}
@Test
fun `removeByNodenum removes node from both maps`() {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) {
Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T"))
}
assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode"))
nodeManager.removeByNodenum(nodeNum)
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
}
}

View File

@@ -30,7 +30,11 @@ constructor(
private val radioController: RadioController,
) {
/** Identifies nodes that match the cleanup criteria. */
suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List<Node> {
open suspend fun getNodesToClean(
olderThanDays: Float,
onlyUnknownNodes: Boolean,
currentTimeSeconds: Long,
): List<Node> {
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
@@ -49,7 +53,7 @@ constructor(
}
/** Performs the cleanup of specified nodes. */
suspend fun cleanNodes(nodeNums: List<Int>) {
open suspend fun cleanNodes(nodeNums: List<Int>) {
if (nodeNums.isEmpty()) return
nodeRepository.deleteNodes(nodeNums)

View File

@@ -1,60 +0,0 @@
/*
* Copyright (c) 2025-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.model
class NodeInfoTest {
/*
private val model = HardwareModel.ANDROID_SIM
private val node =
listOf(
NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)),
NodeInfo(5, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)),
NodeInfo(6, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)),
NodeInfo(7, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)),
NodeInfo(8, MeshUser("+four", "User Four", "U4", model), Position(37.116, 121.1, 40)),
)
private val currentDefaultLocale = LocaleListCompat.getDefault().get(0) ?: Locale.US
@Before
fun setup() {
Locale.setDefault(Locale.US)
}
@After
fun tearDown() {
Locale.setDefault(currentDefaultLocale)
}
@Test
fun distanceGood() {
assertEquals(1111, node[1].distance(node[2]))
assertEquals(111, node[1].distance(node[3]))
assertEquals(1779, node[1].distance(node[4]))
}
@Test
fun distanceStrGood() {
assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
}
*/
}

View File

@@ -76,7 +76,7 @@ private fun formatTraceroutePath(nodesList: List<String>, snrList: List<Int>): S
.joinToString("\n")
}
private fun RouteDiscovery.getTracerouteResponse(
fun RouteDiscovery.getTracerouteResponse(
getUser: (nodeNum: Int) -> String,
headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\n\n",
@@ -98,15 +98,6 @@ fun MeshPacket.getTracerouteResponse(
headerBack: String = "Route traced back to us:\n\n",
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack)
/** Returns a traceroute response string only when the result is complete (both directions). */
fun MeshPacket.getFullTracerouteResponse(
getUser: (nodeNum: Int) -> String,
headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\n\n",
): String? = fullRouteDiscovery
?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() }
?.getTracerouteResponse(getUser, headerTowards, headerBack)
enum class TracerouteMapAvailability {
Ok,
MissingEndpoints,

View File

@@ -16,81 +16,83 @@
*/
package org.meshtastic.core.model
class CapabilitiesTest {
/*
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CapabilitiesTest {
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test
fun canMuteNodeRequiresV2718() {
fun canMuteNode_requires_V2_7_18() {
assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode)
}
@Test
fun canRequestNeighborInfoIsCurrentlyDisabled() {
fun canRequestNeighborInfo_is_currently_disabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo)
assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
fun canSendVerifiedContactsRequiresV2712() {
fun canSendVerifiedContacts_requires_V2_7_12() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts)
}
@Test
fun canToggleTelemetryEnabledRequiresV2712() {
fun canToggleTelemetryEnabled_requires_V2_7_12() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
fun canToggleUnmessageableRequiresV269() {
fun canToggleUnmessageable_requires_V2_6_9() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable)
}
@Test
fun supportsQrCodeSharingRequiresV268() {
fun supportsQrCodeSharing_requires_V2_6_8() {
assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing)
}
@Test
fun supportsSecondaryChannelLocationRequiresV2610() {
fun supportsSecondaryChannelLocation_requires_V2_6_10() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
}
@Test
fun supportsStatusMessageRequiresV2717() {
fun supportsStatusMessage_requires_V2_7_17() {
assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
fun supportsTrafficManagementConfigRequiresV300() {
fun supportsTrafficManagementConfig_requires_V3_0_0() {
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
}
@Test
fun supportsTakConfigRequiresV2719() {
fun supportsTakConfig_requires_V2_7_19() {
assertFalse(caps("2.7.18").supportsTakConfig)
assertTrue(caps("2.7.19").supportsTakConfig)
}
@Test
fun supportsEsp32OtaRequiresV2718() {
fun supportsEsp32Ota_requires_V2_7_18() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
assertTrue(caps("2.7.18").supportsEsp32Ota)
}
@Test
fun nullFirmwareReturnsAllFalse() {
fun nullFirmware_returns_all_false() {
val c = caps(null)
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
@@ -106,7 +108,7 @@ class CapabilitiesTest {
}
@Test
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
fun forceEnableAll_returns_true_regardless_of_version() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode)
assertTrue(c.canSendVerifiedContacts)
@@ -114,23 +116,4 @@ class CapabilitiesTest {
assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig)
}
@Test
fun deviceVersionParsingIsRobust() {
assertEquals(20712, DeviceVersion("2.7.12").asInt)
assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
assertEquals(30000, DeviceVersion("3.0.0").asInt)
assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
assertEquals(0, DeviceVersion("invalid").asInt)
}
@Test
fun deviceVersionComparisonIsCorrect() {
assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
*/
}

View File

@@ -16,62 +16,55 @@
*/
package org.meshtastic.core.model
class ChannelOptionTest {
/*
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class ChannelOptionTest {
/**
* This test ensures that every `ModemPreset` defined in the protobufs has a corresponding entry in our
* `ChannelOption` enum.
* Ensures that every [ModemPreset] defined in the protobufs has a corresponding entry in [ChannelOption].
*
* If this test fails, it means a `ModemPreset` was added or changed in the firmware/protobufs, and you must update
* the `ChannelOption` enum to match.
* If this test fails, a ModemPreset was added or changed in the firmware/protobufs and you must update the
* [ChannelOption] enum to match.
*/
@Test
fun `ensure every ModemPreset is mapped in ChannelOption`() {
// Get all possible ModemPreset values.
val unmappedPresets =
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
fun ensure_every_ModemPreset_is_mapped_in_ChannelOption() {
val unmappedPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
unmappedPresets.forEach { preset ->
// Attempt to find the corresponding ChannelOption
val channelOption = ChannelOption.from(preset)
// Assert that a mapping exists, with a detailed failure message.
assertNotNull(
channelOption,
"Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " +
"Please add a corresponding entry to the ChannelOption enum class.",
channelOption,
)
}
}
/**
* This test ensures that there are no extra entries in `ChannelOption` that don't correspond to a valid
* `ModemPreset`.
* Ensures that there are no extra entries in [ChannelOption] that don't correspond to a valid [ModemPreset].
*
* If this test fails, it means a `ModemPreset` was removed from the protobufs, and you must remove the
* corresponding entry from the `ChannelOption` enum.
* If this test fails, a ModemPreset was removed from the protobufs and you must remove the corresponding entry from
* the [ChannelOption] enum.
*/
@Test
fun `ensure no extra mappings exist in ChannelOption`() {
val protoPresets =
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
fun ensure_no_extra_mappings_exist_in_ChannelOption() {
val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
assertEquals(
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
protoPresets,
mappedPresets,
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
)
assertEquals(
"Each ChannelOption must map to a unique ModemPreset.",
protoPresets.size,
ChannelOption.entries.size,
"Each ChannelOption must map to a unique ModemPreset.",
)
}
*/
}

View File

@@ -16,10 +16,11 @@
*/
package org.meshtastic.core.model
class DeviceVersionTest {
/*
import kotlin.test.Test
import kotlin.test.assertEquals
class DeviceVersionTest {
/** make sure we match the python and device code behavior */
@Test
fun canParse() {
assertEquals(10000, DeviceVersion("1.0.0").asInt)
@@ -28,5 +29,21 @@ class DeviceVersionTest {
assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt)
}
*/
@Test
fun twoPartVersionAppends_zero() {
assertEquals(20700, DeviceVersion("2.7").asInt)
}
@Test
fun invalidVersionReturns_zero() {
assertEquals(0, DeviceVersion("invalid").asInt)
}
@Test
fun comparisonIsCorrect() {
kotlin.test.assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
kotlin.test.assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
assertEquals(DeviceVersion("2.7.12"), DeviceVersion("2.7.12"))
kotlin.test.assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
}

View File

@@ -69,6 +69,7 @@ kotlin {
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}

View File

@@ -20,6 +20,7 @@ import kotlinx.serialization.json.Json
import org.meshtastic.core.model.MqttJsonPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MQTTRepositoryImplTest {
@@ -67,8 +68,8 @@ class MQTTRepositoryImplTest {
val json = Json { ignoreUnknownKeys = true }
val jsonStr = json.encodeToString(MqttJsonPayload.serializer(), payload)
assert(jsonStr.contains("\"type\":\"text\""))
assert(jsonStr.contains("\"from\":12345678"))
assert(jsonStr.contains("\"payload\":\"Hello World\""))
assertTrue(jsonStr.contains("\"type\":\"text\""))
assertTrue(jsonStr.contains("\"from\":12345678"))
assertTrue(jsonStr.contains("\"payload\":\"Hello World\""))
}
}

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
@@ -34,9 +35,14 @@ class JvmServiceDiscovery : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow<List<DiscoveredService>> =
callbackFlow {
trySend(emptyList()) // Emit initial empty list so downstream combine() is not blocked
val bindAddress = findLanAddress() ?: InetAddress.getLocalHost()
Logger.i { "JmDNS binding to ${bindAddress.hostAddress}" }
val jmdns =
try {
JmDNS.create(InetAddress.getLocalHost())
JmDNS.create(bindAddress)
} catch (e: IOException) {
Logger.e(e) { "Failed to create JmDNS" }
null
@@ -93,4 +99,24 @@ class JvmServiceDiscovery : ServiceDiscovery {
}
}
.flowOn(Dispatchers.IO)
companion object {
/**
* Finds a non-loopback, up, IPv4 LAN address for JmDNS to bind to. On many systems (especially Windows),
* [InetAddress.getLocalHost] resolves to `127.0.0.1` or `::1`, which prevents JmDNS from seeing multicast
* traffic on the actual LAN interface.
*/
@Suppress("TooGenericExceptionCaught", "LoopWithTooManyJumpStatements")
internal fun findLanAddress(): InetAddress? = try {
NetworkInterface.getNetworkInterfaces()
?.toList()
.orEmpty()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
} catch (e: Exception) {
Logger.w(e) { "Failed to enumerate network interfaces, falling back to getLocalHost()" }
null
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025-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 app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class JvmServiceDiscoveryTest {
@Test
fun `resolvedServices emits initial empty list immediately`() = runTest {
val discovery = JvmServiceDiscovery()
discovery.resolvedServices.test {
val first = awaitItem()
assertNotNull(first, "First emission should not be null")
assertTrue(first.isEmpty(), "First emission should be an empty list")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `findLanAddress returns non-loopback address or null`() {
val address = JvmServiceDiscovery.findLanAddress()
// On CI machines there may be no LAN interface, so null is acceptable
if (address != null) {
assertTrue(!address.isLoopbackAddress, "Address should not be loopback")
assertTrue(address is java.net.Inet4Address, "Address should be IPv4")
}
}
@Test
fun `findLanAddress does not throw`() {
// Ensure the method handles exceptions gracefully
val result = runCatching { JvmServiceDiscovery.findLanAddress() }
assertTrue(result.isSuccess, "findLanAddress should not throw: ${result.exceptionOrNull()}")
}
}

View File

@@ -23,7 +23,6 @@ import org.meshtastic.core.model.Position
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.NeighborInfo
/** Interface for sending commands and packets to the mesh network. */
@Suppress("TooManyFunctions")
@@ -43,15 +42,6 @@ interface CommandSender {
/** Generates a new unique packet ID. */
fun generatePacketId(): Int
/** The latest neighbor info received from the connected radio. */
var lastNeighborInfo: NeighborInfo?
/** Start times of traceroute requests for duration calculation. */
val tracerouteStartTimes: MutableMap<Int, Long>
/** Start times of neighbor info requests for duration calculation. */
val neighborInfoStartTimes: MutableMap<Int, Long>
/** Sets the session passkey for admin messages. */
fun setSessionPasskey(key: ByteString)

View File

@@ -14,25 +14,20 @@
* 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.model
package org.meshtastic.core.repository
class PositionTest {
/*
/**
* Shared constants for the two-stage mesh handshake protocol.
*
* Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests
* the full node database.
*
* Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these.
*/
object HandshakeConstants {
/** Nonce sent in `want_config_id` to request config-only (Stage 1). */
const val CONFIG_NONCE = 69420
@Test
fun degGood() {
assertEquals(Position.degI(89.0), 890000000)
assertEquals(Position.degI(-89.0), -890000000)
assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
}
@Test
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
val position = Position(37.1, 121.1, 35)
assertTrue(position.time != 0)
}
*/
/** Nonce sent in `want_config_id` to request node info only (Stage 2). */
const val NODE_INFO_NONCE = 69421
}

View File

@@ -18,12 +18,19 @@ package org.meshtastic.core.repository
import kotlinx.coroutines.CoroutineScope
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
/** Interface for handling neighbor info responses from the mesh. */
interface NeighborInfoHandler {
/** Starts the neighbor info handler with the given coroutine scope. */
fun start(scope: CoroutineScope)
/** Records the start time for a neighbor info request. */
fun recordStartTime(requestId: Int)
/** The latest neighbor info received from the connected radio. */
var lastNeighborInfo: NeighborInfo?
/**
* Processes a neighbor info packet.
*

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-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.repository
import kotlinx.coroutines.CoroutineScope
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
/** Interface for handling Store & Forward (legacy) and SF++ packets. */
interface StoreForwardPacketHandler {
/** Starts the handler with the given coroutine scope. */
fun start(scope: CoroutineScope)
/**
* Handles a legacy Store & Forward packet.
*
* @param packet The received mesh packet.
* @param dataPacket The decoded data packet.
* @param myNodeNum The local node number.
*/
fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
/**
* Handles a Store Forward++ packet.
*
* @param packet The received mesh packet.
*/
fun handleStoreForwardPlusPlus(packet: MeshPacket)
}

View File

@@ -25,6 +25,9 @@ interface TracerouteHandler {
/** Starts the traceroute handler with the given coroutine scope. */
fun start(scope: CoroutineScope)
/** Records the start time for a traceroute request. */
fun recordStartTime(requestId: Int)
/**
* Processes a traceroute packet.
*

View File

@@ -1,120 +0,0 @@
/*
* Copyright (c) 2025-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.service
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
/**
* A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the
* AIDL changes, this class will fail to compile.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
open class FakeIMeshService : IMeshService.Stub() {
override fun subscribeReceiver(packageName: String?, receiverName: String?) {}
override fun setOwner(user: MeshUser?) {}
override fun setRemoteOwner(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteOwner(requestId: Int, destNum: Int) {}
override fun getMyId(): String = "fake_id"
override fun getPacketId(): Int = 1234
override fun send(packet: DataPacket?) {}
override fun getNodes(): List<NodeInfo> = emptyList()
override fun getConfig(): ByteArray = byteArrayOf()
override fun setConfig(payload: ByteArray?) {}
override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {}
override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {}
override fun setRingtone(destNum: Int, ringtone: String?) {}
override fun getRingtone(requestId: Int, destNum: Int) {}
override fun setCannedMessages(destNum: Int, messages: String?) {}
override fun getCannedMessages(requestId: Int, destNum: Int) {}
override fun setChannel(payload: ByteArray?) {}
override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {}
override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {}
override fun beginEditSettings(destNum: Int) {}
override fun commitEditSettings(destNum: Int) {}
override fun removeByNodenum(requestID: Int, nodeNum: Int) {}
override fun requestPosition(destNum: Int, position: Position?) {}
override fun setFixedPosition(destNum: Int, position: Position?) {}
override fun requestTraceroute(requestId: Int, destNum: Int) {}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override fun requestShutdown(requestId: Int, destNum: Int) {}
override fun requestReboot(requestId: Int, destNum: Int) {}
override fun requestFactoryReset(requestId: Int, destNum: Int) {}
override fun rebootToDfu(destNum: Int) {}
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {}
override fun getChannelSet(): ByteArray = byteArrayOf()
override fun connectionState(): String = "CONNECTED"
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
override fun getMyNodeInfo(): MyNodeInfo? = null
override fun startFirmwareUpdate() {}
override fun getUpdateStatus(): Int = 0
override fun startProvideLocation() {}
override fun stopProvideLocation() {}
override fun requestUserInfo(destNum: Int) {}
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {}
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-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.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.util.AlertManager
/**
* Shared composable that observes [AlertManager.currentAlert] and renders a [MeshtasticDialog] when an alert is
* present. This eliminates duplicated alert-rendering boilerplate across Android and Desktop host shells.
*
* Usage: Place `AlertHost(alertManager)` once in the top-level composable of each platform host.
*/
@Composable
fun AlertHost(alertManager: AlertManager) {
val alertDialogState by alertManager.currentAlert.collectAsStateWithLifecycle()
alertDialogState?.let { state ->
MeshtasticDialog(
title = state.title,
titleRes = state.titleRes,
message = state.message,
messageRes = state.messageRes,
html = state.html,
icon = state.icon,
text = state.composableMessage?.let { msg -> { msg.Content() } },
confirmText = state.confirmText,
confirmTextRes = state.confirmTextRes,
onConfirm = state.onConfirm,
dismissText = state.dismissText,
dismissTextRes = state.dismissTextRes,
onDismiss = state.onDismiss,
choices = state.choices,
dismissable = state.dismissable,
)
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025-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.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* Shared placeholder screen for desktop/JVM feature stubs that are not yet implemented. Displays a centered label in
* [MaterialTheme.typography.headlineMedium].
*/
@Composable
fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025-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.ui.component
import androidx.compose.runtime.Composable
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/**
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
* connected and requests are pending.
*
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
*/
@Composable
fun SharedDialogs(
connectionState: ConnectionState,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onDismissSharedContact: () -> Unit,
onDismissChannelSet: () -> Unit,
) {
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
}
}

View File

@@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
@@ -27,6 +29,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@KoinViewModel
@@ -46,6 +49,28 @@ class ConnectionsViewModel(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
/**
* Filtered [ourNodeInfo] that only emits when display-relevant fields change, preventing continuous recomposition
* from lastHeard/snr updates.
*/
val ourNodeForDisplay: StateFlow<Node?> =
nodeRepository.ourNodeInfo
.distinctUntilChanged { old, new ->
old?.num == new?.num &&
old?.user == new?.user &&
old?.batteryLevel == new?.batteryLevel &&
old?.voltage == new?.voltage &&
old?.metadata?.firmware_version == new?.metadata?.firmware_version
}
.stateInWhileSubscribed(initialValue = nodeRepository.ourNodeInfo.value)
/** Whether the LoRa region is UNSET and needs to be configured. */
val regionUnset: StateFlow<Boolean> =
radioConfigRepository.localConfigFlow
.map { it.lora?.region == Config.LoRaConfig.RegionCode.UNSET }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = false)
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

View File

@@ -79,7 +79,7 @@ class UIViewModel(
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
val alertManager: AlertManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
@@ -121,8 +121,6 @@ class UIViewModel(
_scrollToTopEventFlow.tryEmit(event)
}
val currentAlert = alertManager.currentAlert
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,

View File

@@ -16,17 +16,17 @@
*/
package org.meshtastic.core.ui.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class AlertManagerTest {
private val alertManager = AlertManager()
@Test
fun `showAlert updates currentAlert flow`() {
fun showAlert_updates_currentAlert_flow() {
val title = "Test Title"
val message = "Test Message"
@@ -34,12 +34,12 @@ class AlertManagerTest {
val alertData = alertManager.currentAlert.value
assertNotNull(alertData)
assertEquals(title, alertData?.title)
assertEquals(message, alertData?.message)
assertEquals(title, alertData.title)
assertEquals(message, alertData.message)
}
@Test
fun `dismissAlert clears currentAlert flow`() {
fun dismissAlert_clears_currentAlert_flow() {
alertManager.showAlert(title = "Title")
assertNotNull(alertManager.currentAlert.value)
@@ -48,7 +48,7 @@ class AlertManagerTest {
}
@Test
fun `onConfirm triggers and dismisses alert`() {
fun onConfirm_triggers_and_dismisses_alert() {
var confirmClicked = false
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
@@ -59,7 +59,7 @@ class AlertManagerTest {
}
@Test
fun `onDismiss triggers and dismisses alert`() {
fun onDismiss_triggers_and_dismisses_alert() {
var dismissClicked = false
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })

View File

@@ -72,6 +72,7 @@ compose.desktop {
// App Icon & OS Specific Configurations
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
minimumSystemVersion = "12.0"
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
// You can inject these from CI environment variables.
// bundleID = "org.meshtastic.desktop"

View File

@@ -3,6 +3,9 @@
-dontwarn com.squareup.wire.AndroidMessage**
-dontwarn io.ktor.**
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Suppress ProGuard notes about duplicate resource files (common in Compose Desktop)
-dontnote **

View File

@@ -16,13 +16,6 @@
*/
package org.meshtastic.desktop.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -68,14 +61,3 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>)
// Connections — shared screen
connectionsGraph(backStack)
}
@Composable
internal fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -35,13 +35,12 @@ import androidx.navigation3.ui.NavDisplay
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.component.AlertHost
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
@@ -67,15 +66,15 @@ fun DesktopMainScreen(
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
}
SharedDialogs(
connectionState = connectionState,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
)
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
}
}
AlertHost(uiViewModel.alertManager)
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Row(modifier = Modifier.fillMaxSize()) {

View File

@@ -14,9 +14,9 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`.
- Don't assume feature/core `@Module` classes are active automatically.
- Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`.
- **Don't use Koin 0.4.0's A1 Module Compile Safety checks for inverted dependencies.**
- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another.
- **Don't** expect Koin to inject default parameters automatically. Koin 0.4.0's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values.
- **Don't use Koin K2 Compiler Plugin's A1 Module Compile Safety checks for inverted dependencies.**
- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt` (uses typed `KoinGradleExtension`). We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another.
- **Don't** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values.
### Current code anchors (DI)

View File

@@ -120,6 +120,22 @@ Formerly found in 3 prefs files:
Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains.
### B5. Cross-platform code deduplication *(resolved 2026-03-21)*
Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components:
| Component | Module | Eliminated from |
|---|---|---|
| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` |
| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` |
| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) |
| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` |
| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals |
| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` |
| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher |
Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`.
---
## C. DI Improvements
@@ -203,12 +219,12 @@ Ordered by impact × effort:
| Area | Previous | Current | Notes |
|---|---:|---:|---|
| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared |
| Shared feature/UI logic | 9.5/10 | **8.5/10** | All 7 KMP features; connections unified; Vico charts in commonMain |
| Shared feature/UI logic | 9.5/10 | **9/10** | All 7 KMP features; connections unified; cross-platform deduplication complete |
| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files |
| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
| CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers |
| DI portability | 7/10 | **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 |
---

View File

@@ -1,6 +1,6 @@
# KMP Migration Status
> Last updated: 2026-03-16
> Last updated: 2026-03-21
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
@@ -72,7 +72,7 @@ Working Compose Desktop application with:
| Area | Score | Notes |
|---|---|---|
| Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified |
| Shared feature/UI logic | **8.5/10** | All 7 KMP; feature:connections unified with dynamic transport detection |
| Shared feature/UI logic | **9/10** | All 7 KMP; feature:connections unified; cross-platform deduplication complete |
| Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) |
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated |
@@ -87,7 +87,7 @@ Working Compose Desktop application with:
|---|---:|
| Android-first structural KMP | ~100% |
| Shared business logic | ~98% |
| Shared feature/UI | ~95% |
| Shared feature/UI | ~97% |
| True multi-target readiness | ~85% |
| "Add iOS without surprises" | ~100% |
@@ -114,6 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~200 lines of duplicated code across Android/desktop |
## Navigation Parity Note

View File

@@ -29,7 +29,6 @@ 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
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
@@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
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 { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
val defaultName = getString(Res.string.meshtastic)
processTcpServices(tcpServices, recentList, defaultName)
}
val usbDevicesFlow =
@@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
val bleForUi =
bondedBle
.map { entry ->
@@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
}
.sortedBy { it.name }
// Android-specific: USB node matching via shared helper
val usbForUi =
(
usbDevices +
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
)
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
}
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 { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
// Shared TCP logic via helpers
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
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 =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
bleDevices = bleForUi,

View File

@@ -23,7 +23,6 @@ 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
@@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
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())
@@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
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 }
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
processTcpServices(tcpServices, recentList, defaultName)
}
return combine(
@@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
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 discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
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 =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase()
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase().endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025-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.feature.connections.domain.usecase
import org.meshtastic.core.common.database.DatabaseManager
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.Companion.toAddressString
import org.meshtastic.feature.connections.model.DeviceListEntry
private const val SUFFIX_LENGTH = 4
/**
* Shared helpers for TCP device discovery logic used by both [CommonGetDiscoveredDevicesUseCase] and the
* Android-specific variant.
*/
/** Converts a list of [DiscoveredService] into [DeviceListEntry.Tcp] with display names derived from TXT records. */
internal fun processTcpServices(
tcpServices: List<DiscoveredService>,
recentAddresses: List<RecentAddress>,
defaultShortName: String = "Meshtastic",
): List<DeviceListEntry.Tcp> {
val recentMap = recentAddresses.associateBy({ it.address }) { it.name }
return tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName = shortNameBytes?.decodeToString() ?: defaultShortName
val deviceId = idBytes?.decodeToString()?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && displayName.split("_").none { it == deviceId }) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
/** Matches each discovered TCP entry to a [Node] from the database using its mDNS device ID. */
internal fun matchDiscoveredTcpNodes(
entries: List<DeviceListEntry.Tcp>,
nodeDb: Map<Int, Node>,
resolvedServices: List<DiscoveredService>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = entries.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolvedServices.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.decodeToString()
nodeDb.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
/**
* Builds the "recent TCP devices" list by filtering out currently discovered addresses and matching each entry to a
* [Node] by name suffix.
*/
internal fun buildRecentTcpEntries(
recentAddresses: List<RecentAddress>,
discoveredAddresses: Set<String>,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = recentAddresses
.filterNot { discoveredAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, nodeDb, databaseManager))
}
.sortedBy { it.name }
/**
* Finds a [Node] matching the last `_`-delimited segment of [displayName], if a local database exists for the given
* [fullAddress]. Used by both TCP recent-device matching and Android USB device matching to avoid duplicated
* suffix-lookup logic.
*/
internal fun findNodeByNameSuffix(
displayName: String,
fullAddress: String,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): Node? {
val suffix = displayName.split("_").lastOrNull()?.lowercase()
return if (!databaseManager.hasDatabaseFor(fullAddress) || suffix == null || suffix.length < SUFFIX_LENGTH) {
null
} else {
nodeDb.values.find { it.user.id.lowercase().endsWith(suffix) }
}
}

View File

@@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
@@ -102,25 +100,12 @@ fun ConnectionsScreen(
onConfigNavigate: (Route) -> Unit,
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
// Prevent continuous recomposition from lastHeard and snr updates on the node
val ourNode by
remember(connectionsViewModel.ourNodeInfo) {
connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new ->
old?.num == new?.num &&
old?.user == new?.user &&
old?.batteryLevel == new?.batteryLevel &&
old?.voltage == new?.voltage &&
old?.metadata?.firmware_version == new?.metadata?.firmware_version
}
}
.collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value)
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@@ -192,63 +177,31 @@ fun ConnectionsScreen(
Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.find { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
2 ->
ConnectedDeviceContent(
ourNode = ourNode,
regionUnset = regionUnset,
selectedDevice = selectedDevice,
bleDevices = bleDevices,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
onSetRegion = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
}
}
}
}
1 ->
ConnectingDeviceContent(
selectedDevice = selectedDevice,
bleDevices = bleDevices,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
usbDevices = usbDevices,
onClickDisconnect = { scanModel.disconnect() },
)
1 -> {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(
deviceName = name,
deviceAddress = address,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
else -> {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}
else -> NoDeviceContent()
}
}
@@ -334,3 +287,74 @@ fun ConnectionsScreen(
}
}
}
/** Content shown when connected to a device with node info available. */
@Composable
private fun ConnectedDeviceContent(
ourNode: org.meshtastic.core.model.Node?,
regionUnset: Boolean,
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
onNavigateToNodeDetails: (Int) -> Unit,
onClickDisconnect: () -> Unit,
onSetRegion: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = onClickDisconnect,
)
}
}
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
onClick = onSetRegion,
)
}
}
}
}
/** Content shown when connecting or a device is selected but node info is not yet available. */
@Composable
private fun ConnectingDeviceContent(
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
usbDevices: List<DeviceListEntry>,
onClickDisconnect: () -> Unit,
) {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
}
}
/** Content shown when no device is selected. */
@Composable
private fun NoDeviceContent() {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}

View File

@@ -0,0 +1,232 @@
/*
* 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.feature.connections.domain.usecase
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.connections.model.DeviceListEntry
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/** Unit tests for the shared TCP discovery helper functions. */
class TcpDiscoveryHelpersTest {
@Test
fun `processTcpServices maps services to DeviceListEntry with shortname and id`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_abcd",
hostAddress = "192.168.1.10",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
),
)
val result = processTcpServices(services, emptyList())
result.size shouldBe 1
result[0].name shouldBe "Mesh_abcd"
result[0].fullAddress shouldBe "t192.168.1.10"
}
@Test
fun `processTcpServices uses default shortname when missing`() {
val services =
listOf(DiscoveredService(name = "TestDevice", hostAddress = "10.0.0.1", port = 4403, txt = emptyMap()))
val result = processTcpServices(services, emptyList(), defaultShortName = "Meshtastic")
result.size shouldBe 1
result[0].name shouldBe "Meshtastic"
}
@Test
fun `processTcpServices uses recent name over shortname`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "MyNode"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
result[0].name shouldBe "MyNode"
}
@Test
fun `processTcpServices does not duplicate id in display name`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!1234".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "Mesh_1234"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
// Should NOT become "Mesh_1234_1234"
result[0].name shouldBe "Mesh_1234"
}
@Test
fun `processTcpServices results are sorted by name`() {
val services =
listOf(
DiscoveredService("Z", "10.0.0.2", 4403, mapOf("shortname" to "Zulu".encodeToByteArray())),
DiscoveredService("A", "10.0.0.1", 4403, mapOf("shortname" to "Alpha".encodeToByteArray())),
)
val result = processTcpServices(services, emptyList())
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zulu"
}
@Test
fun `matchDiscoveredTcpNodes matches node by device id`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns true }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!1234"
}
@Test
fun `matchDiscoveredTcpNodes returns null node when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns false }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNull(result[0].node)
}
@Test
fun `buildRecentTcpEntries filters out discovered addresses`() {
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "NodeA"), RecentAddress("t192.168.1.51", "NodeB"))
val discoveredAddresses = setOf("t192.168.1.50")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, discoveredAddresses, emptyMap(), databaseManager)
result.size shouldBe 1
result[0].name shouldBe "NodeB"
result[0].fullAddress shouldBe "t192.168.1.51"
}
@Test
fun `buildRecentTcpEntries matches node by suffix`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
val recentAddresses = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), mapOf(1 to node), databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!test1234"
}
@Test
fun `buildRecentTcpEntries results are sorted by name`() {
val recentAddresses = listOf(RecentAddress("t10.0.0.2", "Zebra"), RecentAddress("t10.0.0.1", "Alpha"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), emptyMap(), databaseManager)
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zebra"
}
@Test
fun `findNodeByNameSuffix returns null when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNull(result)
}
@Test
fun `findNodeByNameSuffix matches by last underscore segment`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNotNull(result)
result.user.id shouldBe "!abcd1234"
}
@Test
fun `findNodeByNameSuffix returns null when suffix is too short`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_ab", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
// "ab" is only 2 chars, below the minimum SUFFIX_LENGTH of 4
assertNull(result)
}
}

View File

@@ -45,16 +45,7 @@ kotlin {
implementation(projects.core.di)
}
androidMain.dependencies {
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.common)
implementation(libs.androidx.savedstate.compose)
implementation(libs.androidx.savedstate.ktx)
implementation(libs.material)
}
androidMain.dependencies { implementation(libs.material) }
androidUnitTest.dependencies {
implementation(libs.junit)

View File

@@ -16,25 +16,11 @@
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// Desktop placeholder for now
org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.material3.Text(
text = name,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
PlaceholderScreen(name = "Map")
}

View File

@@ -229,34 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}
private fun handleNodeAction(
action: NodeDetailAction,
uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025-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.feature.node.detail
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
/**
* Shared handler for [NodeDetailAction]s that are common across all platforms.
*
* Platform-specific actions (e.g. [NodeDetailAction.ShareContact], [NodeDetailAction.OpenCompass]) are ignored by this
* handler and should be handled by the platform-specific caller.
*/
internal fun handleNodeAction(
action: NodeDetailAction,
uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
}

View File

@@ -23,14 +23,13 @@ import dev.mokkery.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
@@ -51,7 +50,7 @@ class NodeManagementActionsTest {
)
@Test
fun `requestRemoveNode shows confirmation alert`() {
fun requestRemoveNode_shows_confirmation_alert() {
val node = Node(num = 123, user = User(long_name = "Test Node"))
actions.requestRemoveNode(testScope, node)
@@ -70,11 +69,4 @@ class NodeManagementActionsTest {
)
}
}
@Test
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
// This test might fail due to getString() not being mocked easily
// but let's see if we can at least get requestRemoveNode passing.
// Actually, if getString() fails, the coroutine will fail.
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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,17 +16,17 @@
*/
package org.meshtastic.feature.node.metrics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsForGraphing correctly calculates times`() {
fun environmentMetricsForGraphing_correctly_calculates_times() {
val now = nowSeconds.toInt()
val metrics =
listOf(
@@ -42,7 +42,7 @@ class EnvironmentMetricsStateTest {
}
@Test
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
fun environmentMetricsForGraphing_handles_valid_zero_temperatures() {
val now = nowSeconds.toInt()
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
val state = EnvironmentMetricsState(metrics)

View File

@@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
actual fun NodeDetailScreen(
@@ -45,24 +43,14 @@ actual fun NodeDetailScreen(
uiState = uiState,
modifier = modifier,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
},
onFirmwareSelect = { /* No-op on desktop for now */ },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },

View File

@@ -16,21 +16,10 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Position Log",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
PlaceholderScreen(name = "Position Log")
}

View File

@@ -16,27 +16,11 @@
*/
package org.meshtastic.feature.node.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -17,6 +17,7 @@
package org.meshtastic.feature.settings.radio
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
@@ -58,7 +59,7 @@ class CleanNodeDatabaseViewModelTest {
}
@Test
fun `getNodesToDelete updates state`() = runTest {
fun getNodesToDelete_updates_state() = runTest {
val nodes = listOf(Node(num = 1), Node(num = 2))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
@@ -69,7 +70,7 @@ class CleanNodeDatabaseViewModelTest {
}
@Test
fun `cleanNodes calls useCase and clears state`() = runTest {
fun cleanNodes_calls_useCase_and_clears_state() = runTest {
val nodes = listOf(Node(num = 1))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()

View File

@@ -20,7 +20,6 @@ import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
@@ -46,23 +44,18 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
@@ -269,28 +262,3 @@ private fun LanguagePickerDialog(onDismiss: () -> Unit) {
},
)
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = AppCompatDelegate.MODE_NIGHT_NO),
DARK(label = Res.string.theme_dark, mode = AppCompatDelegate.MODE_NIGHT_YES),
SYSTEM(label = Res.string.theme_system, mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}

View File

@@ -57,32 +57,7 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
}
context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
}
Logger.i { "MeshLog exported successfully to $targetUri" }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
@@ -91,5 +66,3 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025-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/>.
*/
@file:Suppress("MatchingDeclarationName")
package org.meshtastic.feature.settings.component
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
/** Theme modes that match AppCompatDelegate constants for cross-platform use. */
enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // AppCompatDelegate.MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // AppCompatDelegate.MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
/** Shared dialog for picking a theme option. Used by both Android and Desktop settings screens. */
@Composable
fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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

View File

@@ -0,0 +1,49 @@
/*
* 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.feature.settings.debugging
internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
/**
* Formats a list of [DebugViewModel.UiMeshLog] entries into the given [Appendable], redacting sensitive keys in decoded
* payloads.
*/
internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>) {
logs.forEach { log ->
out.append("${log.formattedReceivedDate} [${log.messageType}]\n")
out.append(log.logMessage)
val decodedPayload = log.decodedPayload
if (!decodedPayload.isNullOrBlank()) {
appendRedactedPayload(out, decodedPayload)
}
}
}
private fun appendRedactedPayload(out: Appendable, payload: String) {
out.append("\n\nDecoded Payload:\n{\n")
payload.lineSequence().forEach { line ->
out.append(redactLine(line))
out.append("\n")
}
out.append("}\n\n")
}
private fun redactLine(line: String): String {
if (redactedKeys.none { line.contains(it) }) return line
val idx = line.indexOf(':')
return if (idx != -1) line.take(idx + 1) + "<redacted>" else line
}

View File

@@ -17,15 +17,17 @@
package org.meshtastic.feature.settings.filter
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FilterSettingsViewModelTest {
@@ -34,23 +36,23 @@ class FilterSettingsViewModelTest {
private lateinit var viewModel: FilterSettingsViewModel
@Before
@BeforeTest
fun setUp() {
every { filterPrefs.filterEnabled.value } returns true
every { filterPrefs.filterWords.value } returns setOf("apple", "banana")
every { filterPrefs.filterEnabled } returns MutableStateFlow(true)
every { filterPrefs.filterWords } returns MutableStateFlow(setOf("apple", "banana"))
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
}
@Test
fun `setFilterEnabled updates prefs and state`() {
fun setFilterEnabled_updates_prefs_and_state() {
viewModel.setFilterEnabled(false)
verify { filterPrefs.setFilterEnabled(false) }
assertEquals(false, viewModel.filterEnabled.value)
}
@Test
fun `addFilterWord updates prefs and rebuilds patterns`() {
fun addFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.addFilterWord("cherry")
verify { filterPrefs.setFilterWords(any()) }
@@ -59,7 +61,7 @@ class FilterSettingsViewModelTest {
}
@Test
fun `removeFilterWord updates prefs and rebuilds patterns`() {
fun removeFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.removeFilterWord("apple")
verify { filterPrefs.setFilterWords(any()) }

View File

@@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
@@ -52,28 +51,23 @@ import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
@@ -291,31 +285,6 @@ private fun DesktopAppVersionButton(
}
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}
/**
* Supported languages — tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
* Display names are written in the native language for clarity.

View File

@@ -54,32 +54,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
val exportFile = File(directory, selectedFile)
try {
FileOutputStream(exportFile).use { fos ->
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
}
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
} catch (e: java.io.IOException) {
@@ -92,5 +67,3 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
}
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")

View File

@@ -1,140 +0,0 @@
/*
* Copyright (c) 2025-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.feature.settings
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@Config(sdk = [34])
class LegacySettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val radioController: RadioController = mock(MockMode.autofill)
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val meshLogPrefs: MeshLogPrefs = mock(MockMode.autofill)
private lateinit var setThemeUseCase: SetThemeUseCase
private lateinit var setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase
private lateinit var setProvideLocationUseCase: SetProvideLocationUseCase
private lateinit var setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase
private lateinit var setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase
private lateinit var meshLocationUseCase: MeshLocationUseCase
private lateinit var exportDataUseCase: ExportDataUseCase
private lateinit var isOtaCapableUseCase: IsOtaCapableUseCase
private lateinit var viewModel: SettingsViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
setThemeUseCase = mock(MockMode.autofill)
setAppIntroCompletedUseCase = mock(MockMode.autofill)
setProvideLocationUseCase = mock(MockMode.autofill)
setDatabaseCacheLimitUseCase = mock(MockMode.autofill)
setMeshLogSettingsUseCase = mock(MockMode.autofill)
meshLocationUseCase = mock(MockMode.autofill)
exportDataUseCase = mock(MockMode.autofill)
isOtaCapableUseCase = mock(MockMode.autofill)
// Return real StateFlows to avoid ClassCastException
every { databaseManager.cacheLimit } returns MutableStateFlow(100)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
every { radioController.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
every { isOtaCapableUseCase() } returns flowOf(false)
viewModel =
SettingsViewModel(
app = mock(),
radioConfigRepository = radioConfigRepository,
radioController = radioController,
nodeRepository = nodeRepository,
uiPrefs = uiPrefs,
buildConfigProvider = buildConfigProvider,
databaseManager = databaseManager,
meshLogPrefs = meshLogPrefs,
setThemeUseCase = setThemeUseCase,
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
setProvideLocationUseCase = setProvideLocationUseCase,
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
meshLocationUseCase = meshLocationUseCase,
exportDataUseCase = exportDataUseCase,
isOtaCapableUseCase = isOtaCapableUseCase,
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `setTheme calls useCase`() {
viewModel.setTheme(1)
verify { setThemeUseCase(1) }
}
@Test
fun `setDbCacheLimit calls useCase`() {
viewModel.setDbCacheLimit(50)
verify { setDatabaseCacheLimitUseCase(50) }
}
@Test
fun `startProvidingLocation calls useCase`() {
viewModel.startProvidingLocation()
verify { meshLocationUseCase.startProvidingLocation() }
}
}

View File

@@ -9,15 +9,12 @@ androidxTracing = "1.10.5"
datastore = "1.2.1"
glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.10.0"
navigation = "2.9.7"
jetbrains-lifecycle = "2.11.0-alpha01"
navigation3 = "1.1.0-alpha04"
navigationevent = "1.0.1"
navigationevent = "1.1.0-alpha01"
paging = "3.4.2"
room = "3.0.0-alpha01"
savedstate = "1.4.0"
koin = "4.2.0"
koin-annotations = "2.1.0"
koin-plugin = "0.4.1"
# Kotlin
@@ -96,14 +93,11 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
# JetBrains KMP lifecycle (use in commonMain and androidMain)
jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
# AndroidX Navigation (legacy nav-compose; Android-only nav utilities)
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" }
# JetBrains Navigation 3 currently publishes `navigation3-ui` (no separate `navigation3-runtime` artifact).
# Both `jetbrains-navigation3-runtime` and `jetbrains-navigation3-ui` resolve to the same coordinate.
jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
@@ -116,8 +110,6 @@ androidx-room-paging = { module = "androidx.room3:room3-paging", version.ref = "
androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" }
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }