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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `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:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `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. - **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. - **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`). - **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`. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer ### 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.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`. - `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). - `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. - **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`. - **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. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **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 # Git worktrees
.worktrees/ .worktrees/
/firebase-debug.log.jdk/ /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:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `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:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `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. - **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. - **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`). - **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`. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer ### 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.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`. - `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). - `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. - **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`. - **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. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **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:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. | | `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. | | `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:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. | | `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. | | `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. - **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. - **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`). - **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`. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer ### 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.ConcurrentHashMap``atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`. - `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). - `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. - **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`. - **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. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.

View file

@ -20,6 +20,9 @@
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Needed for protobufs # Needed for protobufs
-keep class com.google.protobuf.** { *; } -keep class com.google.protobuf.** { *; }
-keep class org.meshtastic.proto.** { *; } -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.traceroute
import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.MeshService 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.ScrollToTopEvent
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon 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.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow 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 sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle() val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
if (connectionState == ConnectionState.Connected) { SharedDialogs(
sharedContactRequested?.let { connectionState = connectionState,
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() }) sharedContactRequested = sharedContactRequested,
} requestChannelSet = requestChannelSet,
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
requestChannelSet?.let { newChannelSet -> onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() }) )
}
}
VersionChecks(uIViewModel) VersionChecks(uIViewModel)
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle() AlertHost(uIViewModel.alertManager)
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,
)
}
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null) val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) } var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }

View file

@ -17,9 +17,9 @@
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.provider.Property
import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.koin.compiler.plugin.KoinGradleExtension
import org.meshtastic.buildlogic.libs import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin import org.meshtastic.buildlogic.plugin
@ -28,28 +28,14 @@ class KoinConventionPlugin : Plugin<Project> {
with(target) { with(target) {
apply(plugin = libs.plugin("koin-compiler").get().pluginId) apply(plugin = libs.plugin("koin-compiler").get().pluginId)
// Configure Koin Compiler Plugin (0.4.0+) // Configure Koin K2 Compiler Plugin (0.4.0+)
extensions.configure<Any>("koinCompiler") { extensions.configure(KoinGradleExtension::class.java) {
val extension = this // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
val clazz = extension.javaClass // per-module safety checks strictly enforce that all dependencies must be explicitly
try { // provided or included locally. This breaks decoupled Clean Architecture designs.
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin 0.4.0's A1 // We disable compile safety globally to properly rely on Koin's A3 full-graph
// per-module safety checks strictly enforce that all dependencies must be explicitly // validation which perfectly handles inverted dependencies at the composition root.
// provided or included locally. This breaks decoupled Clean Architecture designs. compileSafety.set(false)
// 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
}
} }
val koinAnnotations = libs.findLibrary("koin-annotations").get() 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() configureMokkery()
configureKotlin<KotlinMultiplatformExtension>() 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 * 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 * 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.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertTrue import kotlin.test.assertEquals
import org.junit.Test import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class BleRetryTest { class BleRetryTest {
@Test @Test
fun `retryBleOperation returns immediately on success`() = runTest { fun retryBleOperation_returns_immediately_on_success() = runTest {
var attempts = 0 var attempts = 0
val result = val result =
retryBleOperation(count = 3, delayMs = 10L) { retryBleOperation(count = 3, delayMs = 10L) {
@ -39,7 +40,7 @@ class BleRetryTest {
} }
@Test @Test
fun `retryBleOperation retries on exception and succeeds`() = runTest { fun retryBleOperation_retries_on_exception_and_succeeds() = runTest {
var attempts = 0 var attempts = 0
val result = val result =
retryBleOperation(count = 3, delayMs = 10L) { retryBleOperation(count = 3, delayMs = 10L) {
@ -54,32 +55,30 @@ class BleRetryTest {
} }
@Test @Test
fun `retryBleOperation throws exception after max attempts`() = runTest { fun retryBleOperation_throws_exception_after_max_attempts() = runTest {
var attempts = 0 var attempts = 0
var caughtException: Exception? = null val ex =
try { assertFailsWith<RuntimeException> {
retryBleOperation(count = 3, delayMs = 10L) { retryBleOperation(count = 3, delayMs = 10L) {
attempts++ attempts++
throw RuntimeException("Persistent error") throw RuntimeException("Persistent error")
}
} }
} catch (e: Exception) {
caughtException = e
}
assertTrue(caughtException is RuntimeException) assertTrue(ex is RuntimeException)
assertEquals("Persistent error", caughtException?.message) assertEquals("Persistent error", ex.message)
assertEquals(3, attempts) assertEquals(3, attempts)
} }
@Test(expected = CancellationException::class) @Test
fun `retryBleOperation does not retry CancellationException`() = runTest { fun retryBleOperation_does_not_retry_CancellationException() = runTest {
var attempts = 0 var attempts = 0
retryBleOperation(count = 3, delayMs = 10L) { assertFailsWith<CancellationException> {
attempts++ retryBleOperation(count = 3, delayMs = 10L) {
throw CancellationException("Cancelled") 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) 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.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants import org.meshtastic.proto.Constants
@ -57,18 +59,16 @@ class CommandSenderImpl(
private val packetHandler: PacketHandler, private val packetHandler: PacketHandler,
private val nodeManager: NodeManager, private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository, private val radioConfigRepository: RadioConfigRepository,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
) : CommandSender { ) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY) 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 localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet()) private val channelSet = MutableStateFlow(ChannelSet())
override var lastNeighborInfo: NeighborInfo? = null
// We'll need a way to track connection state in shared code, // We'll need a way to track connection state in shared code,
// maybe via ServiceRepository or similar. // maybe via ServiceRepository or similar.
// For now I'll assume it's injected or available. // For now I'll assume it's injected or available.
@ -251,7 +251,7 @@ class CommandSenderImpl(
} }
override fun requestTraceroute(requestId: Int, destNum: Int) { override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio( packetHandler.sendToRadio(
buildMeshPacket( buildMeshPacket(
to = destNum, to = destNum,
@ -302,11 +302,11 @@ class CommandSenderImpl(
} }
override fun requestNeighborInfo(requestId: Int, destNum: Int) { override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum ?: 0 val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) { if (destNum == myNum) {
val neighborInfoToSend = val neighborInfoToSend =
lastNeighborInfo neighborInfoHandler.lastNeighborInfo
?: run { ?: run {
val oneHour = 1.hours.inWholeMinutes.toInt() val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" } 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.common.util.ioDispatcher
import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeManager
@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl(
private val packetHandler: PacketHandler, private val packetHandler: PacketHandler,
) : MeshConfigFlowManager { ) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L private val wantConfigDelay = 100L
override fun start(scope: CoroutineScope) { override fun start(scope: CoroutineScope) {
@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl(
override fun handleConfigComplete(configCompleteId: Int) { override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) { when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete() HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete() HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
else -> Logger.w { "Config complete id mismatch: $configCompleteId" } 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.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshLocationManager
@ -253,13 +254,13 @@ class MeshConnectionManagerImpl(
} }
override fun startConfigOnly() { 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) startHandshakeStallGuard(1, action)
action() action()
} }
override fun startNodeInfoOnly() { 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) startHandshakeStallGuard(2, action)
action() action()
} }
@ -340,8 +341,6 @@ class MeshConnectionManagerImpl(
} }
companion object { 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 const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 30.seconds private val HANDSHAKE_TIMEOUT = 30.seconds

View file

@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher 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.Node
import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.MeshDataMapper 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.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager 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.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert 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.Position
import org.meshtastic.proto.Routing import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint import org.meshtastic.proto.Waypoint
@ -107,17 +102,21 @@ class MeshDataHandlerImpl(
private val configHandler: Lazy<MeshConfigHandler>, private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>, private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender, private val commandSender: CommandSender,
private val historyManager: HistoryManager,
private val connectionManager: Lazy<MeshConnectionManager>, private val connectionManager: Lazy<MeshConnectionManager>,
private val tracerouteHandler: TracerouteHandler, private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler, private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository, private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter, private val messageFilter: MessageFilter,
private val storeForwardHandler: StoreForwardPacketHandler,
) : MeshDataHandler { ) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
override fun start(scope: CoroutineScope) { override fun start(scope: CoroutineScope) {
this.scope = scope this.scope = scope
storeForwardHandler.start(scope)
} }
private val rememberDataType = private val rememberDataType =
@ -191,11 +190,11 @@ class MeshDataHandlerImpl(
} }
PortNum.STORE_FORWARD_APP -> { PortNum.STORE_FORWARD_APP -> {
handleStoreAndForward(packet, dataPacket, myNodeNum) storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
} }
PortNum.STORE_FORWARD_PLUSPLUS_APP -> { PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
handleStoreForwardPlusPlus(packet) storeForwardHandler.handleStoreForwardPlusPlus(packet)
} }
PortNum.ADMIN_APP -> { PortNum.ADMIN_APP -> {
@ -235,98 +234,6 @@ class MeshDataHandlerImpl(
rememberDataPacket(u, myNodeNum) 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) { private fun handlePaxCounter(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: 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) { override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return if (dataPacket.dataType !in rememberDataType) return
val fromLocal = val fromLocal =
@ -807,7 +668,5 @@ class MeshDataHandlerImpl(
private const val BATTERY_PERCENT_LOW_DIVISOR = 5 private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5 private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500 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 package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger 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.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceBroadcasts
@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo
class NeighborInfoHandlerImpl( class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager, private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository, private val serviceRepository: ServiceRepository,
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts, private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler { ) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override var lastNeighborInfo: NeighborInfo? = null
override fun start(scope: CoroutineScope) { override fun start(scope: CoroutineScope) {
this.scope = scope this.scope = scope
} }
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun handleNeighborInfo(packet: MeshPacket) { override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload) val ni = NeighborInfo.ADAPTER.decode(payload)
@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl(
// Store the last neighbor info from our connected radio // Store the last neighbor info from our connected radio
val from = packet.from val from = packet.from
if (from == nodeManager.myNodeNum) { if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" } Logger.d { "Stored last neighbor info from connected radio" }
} }
@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl(
// Format for UI response // Format for UI response
val requestId = packet.decoded?.request_id ?: 0 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 = val neighbors =
ni.neighbors.joinToString("\n") { n -> 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 package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger 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.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery 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.model.service.TracerouteResponse
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
@ -42,33 +44,43 @@ class TracerouteHandlerImpl(
private val serviceRepository: ServiceRepository, private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository, private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler { ) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override fun start(scope: CoroutineScope) { override fun start(scope: CoroutineScope) {
this.scope = scope 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 = val full =
packet.getFullTracerouteResponse( routeDiscovery.getTracerouteResponse(
getUser = { num -> getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node -> nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
"${node.user.long_name} (${node.user.short_name})" ?: "Unknown" // TODO: Use core:resources once available in core:data
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
}, },
headerTowards = "Route towards destination:", headerTowards = "Route towards destination:",
headerBack = "Route back to us:", headerBack = "Route back to us:",
) ?: return )
val requestId = packet.decoded?.request_id ?: 0 val requestId = packet.decoded?.request_id ?: 0
if (logUuid != null) { if (logUuid != null) {
scope.handledLaunch { scope.handledLaunch {
logInsertJob?.join() logInsertJob?.join()
val routeDiscovery = packet.fullRouteDiscovery
val forwardRoute = routeDiscovery?.route.orEmpty()
val returnRoute = routeDiscovery?.route_back.orEmpty()
val routeNodeNums = (forwardRoute + returnRoute).distinct() val routeNodeNums = (forwardRoute + returnRoute).distinct()
val nodeDbByNum = nodeRepository.nodeDBbyNum.value val nodeDbByNum = nodeRepository.nodeDBbyNum.value
val snapshotPositions = 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 = val responseText =
if (start != null) { if (start != null) {
val elapsedMs = nowMillis - start val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" } Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s" "$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
"$full\n\n$durationText"
} else { } else {
full full
} }
val routeDiscovery = packet.fullRouteDiscovery val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse( serviceRepository.setTracerouteResponse(
TracerouteResponse( TracerouteResponse(
message = responseText, message = responseText,
destinationNodeNum = destination, destinationNodeNum = destination,
requestId = requestId, requestId = requestId,
forwardRoute = routeDiscovery?.route.orEmpty(), forwardRoute = forwardRoute,
returnRoute = routeDiscovery?.route_back.orEmpty(), returnRoute = returnRoute,
logUuid = logUuid, logUuid = logUuid,
), ),
) )

View file

@ -16,15 +16,47 @@
*/ */
package org.meshtastic.core.data.manager 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 private lateinit var handler: FromRadioPacketHandlerImpl
@Before @BeforeTest
fun setup() { fun setup() {
mockkStatic("org.meshtastic.core.resources.GetStringKt") every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
handler = handler =
FromRadioPacketHandlerImpl( FromRadioPacketHandlerImpl(
@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { router.configFlowManager.handleMyInfo(myInfo) } verify { configFlowManager.handleMyInfo(myInfo) }
} }
@Test @Test
@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { router.configFlowManager.handleLocalMetadata(metadata) } verify { configFlowManager.handleLocalMetadata(metadata) }
} }
@Test @Test
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() { 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) val proto = FromRadio(node_info = nodeInfo)
every { router.configFlowManager.newNodeCount } returns 1 every { configFlowManager.newNodeCount } returns 1
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { router.configFlowManager.handleNodeInfo(nodeInfo) } verify { configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setConnectionProgress("Nodes (1)") } verify { serviceRepository.setConnectionProgress("Nodes (1)") }
} }
@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { router.configFlowManager.handleConfigComplete(nonce) } verify { configFlowManager.handleConfigComplete(nonce) }
} }
@Test @Test
@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { router.configHandler.handleDeviceConfig(config) } verify { configHandler.handleDeviceConfig(config) }
} }
@Test @Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() { fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
val notification = ClientNotification(message = "test") val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val proto = FromRadio(clientNotification = notification) val proto = FromRadio(moduleConfig = moduleConfig)
handler.handleFromRadio(proto) handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) } verify { configHandler.handleModuleConfig(moduleConfig) }
verify { packetHandler.removeResponse(0, complete = false) }
} }
*/ @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 package org.meshtastic.core.data.manager
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.proto.StoreAndForward import org.meshtastic.proto.StoreAndForward
import kotlin.test.Test
import kotlin.test.assertEquals
class HistoryManagerImplTest { class HistoryManagerImplTest {

View file

@ -17,10 +17,25 @@
package org.meshtastic.core.data.manager package org.meshtastic.core.data.manager
import dev.mokkery.MockMode 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.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.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager 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.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler 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.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.BeforeTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class MeshDataHandlerTest { class MeshDataHandlerTest {
private lateinit var handler: MeshDataHandlerImpl private lateinit var handler: MeshDataHandlerImpl
@ -56,12 +82,15 @@ class MeshDataHandlerTest {
private val configHandler: MeshConfigHandler = mock(MockMode.autofill) private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill) private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val commandSender: CommandSender = 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 connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill) private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill) private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val messageFilter: MessageFilter = 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 @BeforeTest
fun setUp() { fun setUp() {
@ -79,13 +108,21 @@ class MeshDataHandlerTest {
configHandler = lazy { configHandler }, configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager }, configFlowManager = lazy { configFlowManager },
commandSender = commandSender, commandSender = commandSender,
historyManager = historyManager,
connectionManager = lazy { connectionManager }, connectionManager = lazy { connectionManager },
tracerouteHandler = tracerouteHandler, tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler, neighborInfoHandler = neighborInfoHandler,
radioConfigRepository = radioConfigRepository, radioConfigRepository = radioConfigRepository,
messageFilter = messageFilter, 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 @Test
@ -94,8 +131,582 @@ class MeshDataHandlerTest {
} }
@Test @Test
fun `handleReceivedData processes packet`() { fun `handleReceivedData returns early when dataMapper returns null`() {
val packet = MeshPacket() val packet = MeshPacket()
every { dataMapper.toDataPacket(packet) } returns null
handler.handleReceivedData(packet, 123) 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 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 { class MessageFilterImplTest {
/*
private lateinit var filterPrefs: FilterPrefs private lateinit var filterPrefs: FilterPrefs
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean> private val filterEnabledFlow = MutableStateFlow(true)
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>> private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
private lateinit var filterService: MessageFilterImpl private lateinit var filterService: MessageFilterImpl
@Before @BeforeTest
fun setup() { fun setup() {
filterEnabledFlow = MutableStateFlow(true) filterPrefs = mock(MockMode.autofill)
filterWordsFlow = MutableStateFlow(setOf("spam", "bad")) every { filterPrefs.filterEnabled } returns filterEnabledFlow
filterPrefs = mockk { every { filterPrefs.filterWords } returns filterWordsFlow
every { filterEnabled } returns filterEnabledFlow
every { filterWords } returns filterWordsFlow
}
filterService = MessageFilterImpl(filterPrefs) filterService = MessageFilterImpl(filterPrefs)
} }
@ -92,6 +99,4 @@ class MessageFilterImplTest {
filterService.rebuildPatterns() filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false)) assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
} }
*/
} }

View file

@ -16,17 +16,36 @@
*/ */
package org.meshtastic.core.data.manager 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 { 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 private lateinit var nodeManager: NodeManagerImpl
@Before @BeforeTest
fun setUp() { fun setUp() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager) nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
} }
@ -63,8 +82,9 @@ class NodeManagerImplTest {
@Test @Test
fun `handleReceivedUser updates user if incoming is higher detail`() { fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234 val nodeNum = 1234
// Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString)
val existingUser = 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) } nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
@ -81,29 +101,30 @@ class NodeManagerImplTest {
@Test @Test
fun `handleReceivedPosition updates node position`() { fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234 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) nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
val result = nodeManager.nodeDBbyNodeNum[nodeNum] val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.position) assertNotNull(result)
assertEquals(45.0, result.latitude, 0.0001) assertNotNull(result.position)
assertEquals(90.0, result.longitude, 0.0001) assertEquals(450000000, result.position.latitude_i)
assertEquals(900000000, result.position.longitude_i)
} }
@Test @Test
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() { fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
val nodeNum = 1234 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) nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
// Receive "zero" position with new satellite count // 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) nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
val result = nodeManager.nodeDBbyNodeNum[nodeNum] val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals(45.0, result!!.latitude, 0.0001) assertEquals(450000000, result!!.position.latitude_i)
assertEquals(90.0, result.longitude, 0.0001) assertEquals(900000000, result.position.longitude_i)
assertEquals(5, result.position.sats_in_view) assertEquals(5, result.position.sats_in_view)
assertEquals(1001, result.lastHeard) assertEquals(1001, result.lastHeard)
} }
@ -111,13 +132,13 @@ class NodeManagerImplTest {
@Test @Test
fun `handleReceivedPosition for local node ignores purely empty packets`() { fun `handleReceivedPosition for local node ignores purely empty packets`() {
val myNum = 1111 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) nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
val result = nodeManager.nodeDBbyNodeNum[myNum] val result = nodeManager.nodeDBbyNodeNum[myNum]
// Should still be a default/unset node if it didn't exist, or shouldn't have position // Should still be null since the empty position for local node is ignored
assertTrue(result == null || result.position.latitude_i == null) assertNull(result)
} }
@Test @Test
@ -125,11 +146,7 @@ class NodeManagerImplTest {
val nodeNum = 1234 val nodeNum = 1234
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) } nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
val telemetry = val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50))
org.meshtastic.proto.Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
)
nodeManager.handleReceivedTelemetry(nodeNum, telemetry) nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@ -140,10 +157,7 @@ class NodeManagerImplTest {
@Test @Test
fun `handleReceivedTelemetry updates device metrics`() { fun `handleReceivedTelemetry updates device metrics`() {
val nodeNum = 1234 val nodeNum = 1234
val telemetry = val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f))
org.meshtastic.proto.Telemetry(
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
)
nodeManager.handleReceivedTelemetry(nodeNum, telemetry) nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@ -157,10 +171,7 @@ class NodeManagerImplTest {
fun `handleReceivedTelemetry updates environment metrics`() { fun `handleReceivedTelemetry updates environment metrics`() {
val nodeNum = 1234 val nodeNum = 1234
val telemetry = val telemetry =
org.meshtastic.proto.Telemetry( Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f))
environment_metrics =
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
)
nodeManager.handleReceivedTelemetry(nodeNum, telemetry) nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@ -180,5 +191,39 @@ class NodeManagerImplTest {
assertNull(nodeManager.myNodeNum) 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, private val radioController: RadioController,
) { ) {
/** Identifies nodes that match the cleanup criteria. */ /** 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 sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
@ -49,7 +53,7 @@ constructor(
} }
/** Performs the cleanup of specified nodes. */ /** Performs the cleanup of specified nodes. */
suspend fun cleanNodes(nodeNums: List<Int>) { open suspend fun cleanNodes(nodeNums: List<Int>) {
if (nodeNums.isEmpty()) return if (nodeNums.isEmpty()) return
nodeRepository.deleteNodes(nodeNums) 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") .joinToString("\n")
} }
private fun RouteDiscovery.getTracerouteResponse( fun RouteDiscovery.getTracerouteResponse(
getUser: (nodeNum: Int) -> String, getUser: (nodeNum: Int) -> String,
headerTowards: String = "Route traced toward destination:\n\n", headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\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", headerBack: String = "Route traced back to us:\n\n",
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack) ): 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 { enum class TracerouteMapAvailability {
Ok, Ok,
MissingEndpoints, MissingEndpoints,

View file

@ -16,81 +16,83 @@
*/ */
package org.meshtastic.core.model 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) private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test @Test
fun canMuteNodeRequiresV2718() { fun canMuteNode_requires_V2_7_18() {
assertFalse(caps("2.7.15").canMuteNode) assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode) assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode) assertTrue(caps("2.8.0").canMuteNode)
} }
@Test @Test
fun canRequestNeighborInfoIsCurrentlyDisabled() { fun canRequestNeighborInfo_is_currently_disabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo) assertFalse(caps("2.7.14").canRequestNeighborInfo)
assertFalse(caps("3.0.0").canRequestNeighborInfo) assertFalse(caps("3.0.0").canRequestNeighborInfo)
} }
@Test @Test
fun canSendVerifiedContactsRequiresV2712() { fun canSendVerifiedContacts_requires_V2_7_12() {
assertFalse(caps("2.7.11").canSendVerifiedContacts) assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts) assertTrue(caps("2.7.12").canSendVerifiedContacts)
} }
@Test @Test
fun canToggleTelemetryEnabledRequiresV2712() { fun canToggleTelemetryEnabled_requires_V2_7_12() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled) assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled) assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
} }
@Test @Test
fun canToggleUnmessageableRequiresV269() { fun canToggleUnmessageable_requires_V2_6_9() {
assertFalse(caps("2.6.8").canToggleUnmessageable) assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable) assertTrue(caps("2.6.9").canToggleUnmessageable)
} }
@Test @Test
fun supportsQrCodeSharingRequiresV268() { fun supportsQrCodeSharing_requires_V2_6_8() {
assertFalse(caps("2.6.7").supportsQrCodeSharing) assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing) assertTrue(caps("2.6.8").supportsQrCodeSharing)
} }
@Test @Test
fun supportsSecondaryChannelLocationRequiresV2610() { fun supportsSecondaryChannelLocation_requires_V2_6_10() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation) assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation) assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
} }
@Test @Test
fun supportsStatusMessageRequiresV2717() { fun supportsStatusMessage_requires_V2_7_17() {
assertFalse(caps("2.7.16").supportsStatusMessage) assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage) assertTrue(caps("2.7.17").supportsStatusMessage)
} }
@Test @Test
fun supportsTrafficManagementConfigRequiresV300() { fun supportsTrafficManagementConfig_requires_V3_0_0() {
assertFalse(caps("2.7.18").supportsTrafficManagementConfig) assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
assertTrue(caps("3.0.0").supportsTrafficManagementConfig) assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
} }
@Test @Test
fun supportsTakConfigRequiresV2719() { fun supportsTakConfig_requires_V2_7_19() {
assertFalse(caps("2.7.18").supportsTakConfig) assertFalse(caps("2.7.18").supportsTakConfig)
assertTrue(caps("2.7.19").supportsTakConfig) assertTrue(caps("2.7.19").supportsTakConfig)
} }
@Test @Test
fun supportsEsp32OtaRequiresV2718() { fun supportsEsp32Ota_requires_V2_7_18() {
assertFalse(caps("2.7.17").supportsEsp32Ota) assertFalse(caps("2.7.17").supportsEsp32Ota)
assertTrue(caps("2.7.18").supportsEsp32Ota) assertTrue(caps("2.7.18").supportsEsp32Ota)
} }
@Test @Test
fun nullFirmwareReturnsAllFalse() { fun nullFirmware_returns_all_false() {
val c = caps(null) val c = caps(null)
assertFalse(c.canMuteNode) assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo) assertFalse(c.canRequestNeighborInfo)
@ -106,7 +108,7 @@ class CapabilitiesTest {
} }
@Test @Test
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() { fun forceEnableAll_returns_true_regardless_of_version() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true) val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode) assertTrue(c.canMuteNode)
assertTrue(c.canSendVerifiedContacts) assertTrue(c.canSendVerifiedContacts)
@ -114,23 +116,4 @@ class CapabilitiesTest {
assertTrue(c.supportsTrafficManagementConfig) assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig) 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 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 * Ensures that every [ModemPreset] defined in the protobufs has a corresponding entry in [ChannelOption].
* `ChannelOption` enum.
* *
* If this test fails, it means a `ModemPreset` was added or changed in the firmware/protobufs, and you must update * If this test fails, a ModemPreset was added or changed in the firmware/protobufs and you must update the
* the `ChannelOption` enum to match. * [ChannelOption] enum to match.
*/ */
@Test @Test
fun `ensure every ModemPreset is mapped in ChannelOption`() { fun ensure_every_ModemPreset_is_mapped_in_ChannelOption() {
// Get all possible ModemPreset values. val unmappedPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
val unmappedPresets =
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
unmappedPresets.forEach { preset -> unmappedPresets.forEach { preset ->
// Attempt to find the corresponding ChannelOption
val channelOption = ChannelOption.from(preset) val channelOption = ChannelOption.from(preset)
// Assert that a mapping exists, with a detailed failure message.
assertNotNull( assertNotNull(
channelOption,
"Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " + "Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " +
"Please add a corresponding entry to the ChannelOption enum class.", "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 * Ensures that there are no extra entries in [ChannelOption] that don't correspond to a valid [ModemPreset].
* `ModemPreset`.
* *
* If this test fails, it means a `ModemPreset` was removed from the protobufs, and you must remove the * If this test fails, a ModemPreset was removed from the protobufs and you must remove the corresponding entry from
* corresponding entry from the `ChannelOption` enum. * the [ChannelOption] enum.
*/ */
@Test @Test
fun `ensure no extra mappings exist in ChannelOption`() { fun ensure_no_extra_mappings_exist_in_ChannelOption() {
val protoPresets = val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet() val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
assertEquals( 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, protoPresets,
mappedPresets, 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( assertEquals(
"Each ChannelOption must map to a unique ModemPreset.",
protoPresets.size, protoPresets.size,
ChannelOption.entries.size, ChannelOption.entries.size,
"Each ChannelOption must map to a unique ModemPreset.",
) )
} }
*/
} }

View file

@ -16,10 +16,11 @@
*/ */
package org.meshtastic.core.model 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 @Test
fun canParse() { fun canParse() {
assertEquals(10000, DeviceVersion("1.0.0").asInt) assertEquals(10000, DeviceVersion("1.0.0").asInt)
@ -28,5 +29,21 @@ class DeviceVersionTest {
assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt) 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 { commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions) implementation(libs.kotest.assertions)
implementation(libs.kotest.property) implementation(libs.kotest.property)
} }

View file

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

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import java.io.IOException import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
import java.net.NetworkInterface
import javax.jmdns.JmDNS import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener import javax.jmdns.ServiceListener
@ -34,9 +35,14 @@ class JvmServiceDiscovery : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught") @Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow<List<DiscoveredService>> = override val resolvedServices: Flow<List<DiscoveredService>> =
callbackFlow { 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 = val jmdns =
try { try {
JmDNS.create(InetAddress.getLocalHost()) JmDNS.create(bindAddress)
} catch (e: IOException) { } catch (e: IOException) {
Logger.e(e) { "Failed to create JmDNS" } Logger.e(e) { "Failed to create JmDNS" }
null null
@ -93,4 +99,24 @@ class JvmServiceDiscovery : ServiceDiscovery {
} }
} }
.flowOn(Dispatchers.IO) .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.AdminMessage
import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.NeighborInfo
/** Interface for sending commands and packets to the mesh network. */ /** Interface for sending commands and packets to the mesh network. */
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@ -43,15 +42,6 @@ interface CommandSender {
/** Generates a new unique packet ID. */ /** Generates a new unique packet ID. */
fun generatePacketId(): Int 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. */ /** Sets the session passkey for admin messages. */
fun setSessionPasskey(key: ByteString) fun setSessionPasskey(key: ByteString)

View file

@ -14,25 +14,20 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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 /** Nonce sent in `want_config_id` to request node info only (Stage 2). */
fun degGood() { const val NODE_INFO_NONCE = 69421
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)
}
*/
} }

View file

@ -18,12 +18,19 @@ package org.meshtastic.core.repository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
/** Interface for handling neighbor info responses from the mesh. */ /** Interface for handling neighbor info responses from the mesh. */
interface NeighborInfoHandler { interface NeighborInfoHandler {
/** Starts the neighbor info handler with the given coroutine scope. */ /** Starts the neighbor info handler with the given coroutine scope. */
fun start(scope: CoroutineScope) 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. * 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. */ /** Starts the traceroute handler with the given coroutine scope. */
fun start(scope: CoroutineScope) fun start(scope: CoroutineScope)
/** Records the start time for a traceroute request. */
fun recordStartTime(requestId: Int)
/** /**
* Processes a traceroute packet. * 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.KoinViewModel import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node 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.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalConfig
@KoinViewModel @KoinViewModel
@ -46,6 +49,28 @@ class ConnectionsViewModel(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo 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) private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow() val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

View file

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

View file

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

View file

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

View file

@ -3,6 +3,9 @@
-dontwarn com.squareup.wire.AndroidMessage** -dontwarn com.squareup.wire.AndroidMessage**
-dontwarn io.ktor.** -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) # Suppress ProGuard notes about duplicate resource files (common in Compose Desktop)
-dontnote ** -dontnote **

View file

@ -16,13 +16,6 @@
*/ */
package org.meshtastic.desktop.navigation 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.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@ -68,14 +61,3 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>)
// Connections — shared screen // Connections — shared screen
connectionsGraph(backStack) 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.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService 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.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.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph import org.meshtastic.desktop.navigation.desktopNavGraph
@ -67,15 +66,15 @@ fun DesktopMainScreen(
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
if (connectionState == ConnectionState.Connected) { SharedDialogs(
sharedContactRequested?.let { connectionState = connectionState,
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() }) sharedContactRequested = sharedContactRequested,
} requestChannelSet = requestChannelSet,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
)
requestChannelSet?.let { newChannelSet -> AlertHost(uiViewModel.alertManager)
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Row(modifier = Modifier.fillMaxSize()) { 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`. - 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. - 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`. - 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.** - **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`. 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. - **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. Koin 0.4.0's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values. - **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) ### 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. 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 ## C. DI Improvements
@ -203,12 +219,12 @@ Ordered by impact × effort:
| Area | Previous | Current | Notes | | Area | Previous | Current | Notes |
|---|---:|---:|---| |---|---:|---:|---|
| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | | 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 | | 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 | | 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 | | 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 | | 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 # 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/). 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 | | Area | Score | Notes |
|---|---|---| |---|---|---|
| Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified | | 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) | | 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 | | 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 | | 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% | | Android-first structural KMP | ~100% |
| Shared business logic | ~98% | | Shared business logic | ~98% |
| Shared feature/UI | ~95% | | Shared feature/UI | ~97% |
| True multi-target readiness | ~85% | | True multi-target readiness | ~85% |
| "Add iOS without surprises" | ~100% | | "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. | | **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. | | **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 | | 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 ## 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.model.Node
import org.meshtastic.core.network.repository.DiscoveredService import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository 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.network.repository.UsbRepository
import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioInterfaceService
@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
private val radioInterfaceService: RadioInterfaceService, private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: Lazy<UsbManager>, private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase { ) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8 private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
tcpServices, tcpServices,
recentList, recentList,
-> ->
val recentMap = recentList.associateBy({ it.address }) { it.name } val defaultName = getString(Res.string.meshtastic)
tcpServices processTcpServices(tcpServices, recentList, defaultName)
.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 usbDevicesFlow = val usbDevicesFlow =
@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
@Suppress("UNCHECKED_CAST", "MagicNumber") @Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress> val recentList = args[5] as List<RecentAddress>
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
val bleForUi = val bleForUi =
bondedBle bondedBle
.map { entry -> .map { entry ->
@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
} }
.sortedBy { it.name } .sortedBy { it.name }
// Android-specific: USB node matching via shared helper
val usbForUi = val usbForUi =
( (
usbDevices + usbDevices +
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList() if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
) )
.map { entry -> .map { entry ->
val matchingNode = entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
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)
} }
val discoveredTcpForUi = // Shared TCP logic via helpers
processedTcp.map { entry -> val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
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)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi = val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
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 }
DiscoveredDevices( DiscoveredDevices(
bleDevices = bleForUi, 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.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository 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.repository.NodeRepository
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode import org.meshtastic.core.resources.demo_mode
@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
private val networkRepository: NetworkRepository, private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null, private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase { ) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> { override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList()) val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
tcpServices, tcpServices,
recentList, recentList,
-> ->
val recentMap = recentList.associateBy({ it.address }) { it.name } val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
tcpServices processTcpServices(tcpServices, recentList, defaultName)
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { it.decodeToString() }
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
} }
return combine( return combine(
@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource.recentAddresses, recentAddressesDataSource.recentAddresses,
usbFlow, usbFlow,
) { db, processedTcp, resolved, recentList, usbList -> ) { db, processedTcp, resolved, recentList, usbList ->
val discoveredTcpForUi = val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
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 }
DiscoveredDevices( DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi, 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel 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.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */ /** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
@ -102,25 +100,12 @@ fun ConnectionsScreen(
onConfigNavigate: (Route) -> Unit, onConfigNavigate: (Route) -> Unit,
) { ) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle() val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
// Prevent continuous recomposition from lastHeard and snr updates on the node val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
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 selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@ -192,63 +177,31 @@ fun ConnectionsScreen(
Crossfade(targetState = uiState, label = "connection_state") { state -> Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) { when (state) {
2 -> { 2 ->
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ConnectedDeviceContent(
ourNode?.let { node -> ourNode = ourNode,
TitledCard(title = stringResource(Res.string.connected_device)) { regionUnset = regionUnset,
CurrentlyConnectedInfo( selectedDevice = selectedDevice,
node = node, bleDevices = bleDevices,
bleDevice = onNavigateToNodeDetails = onNavigateToNodeDetails,
bleDevices.find { it.fullAddress == selectedDevice } onClickDisconnect = { scanModel.disconnect() },
as DeviceListEntry.Ble?, onSetRegion = {
onNavigateToNodeDetails = onNavigateToNodeDetails, isWaiting = true
onClickDisconnect = { scanModel.disconnect() }, radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
) },
} )
}
if (regionUnset && selectedDevice != "m") { 1 ->
TitledCard(title = null) { ConnectingDeviceContent(
ListItem( selectedDevice = selectedDevice,
leadingIcon = Icons.Rounded.Language, bleDevices = bleDevices,
text = stringResource(Res.string.set_your_region), discoveredTcpDevices = discoveredTcpDevices,
) { recentTcpDevices = recentTcpDevices,
isWaiting = true usbDevices = usbDevices,
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) onClickDisconnect = { scanModel.disconnect() },
} )
}
}
}
}
1 -> { else -> NoDeviceContent()
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),
)
}
}
} }
} }
@ -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) implementation(projects.core.di)
} }
androidMain.dependencies { androidMain.dependencies { implementation(libs.material) }
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)
}
androidUnitTest.dependencies { androidUnitTest.dependencies {
implementation(libs.junit) implementation(libs.junit)

View file

@ -16,25 +16,11 @@
*/ */
package org.meshtastic.feature.map.navigation package org.meshtastic.feature.map.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable @Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// Desktop placeholder for now // Desktop placeholder for now
org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map") 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,
)
}
} }

View file

@ -229,34 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() } 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) @Preview(showBackground = true)
@Composable @Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) { 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope 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.Node
import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User import org.meshtastic.proto.User
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest { class NodeManagementActionsTest {
@ -51,7 +50,7 @@ class NodeManagementActionsTest {
) )
@Test @Test
fun `requestRemoveNode shows confirmation alert`() { fun requestRemoveNode_shows_confirmation_alert() {
val node = Node(num = 123, user = User(long_name = "Test Node")) val node = Node(num = 123, user = User(long_name = "Test Node"))
actions.requestRemoveNode(testScope, 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 * 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 * it under the terms of the GNU General Public License as published by
@ -16,17 +16,17 @@
*/ */
package org.meshtastic.feature.node.metrics 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.core.common.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EnvironmentMetricsStateTest { class EnvironmentMetricsStateTest {
@Test @Test
fun `environmentMetricsForGraphing correctly calculates times`() { fun environmentMetricsForGraphing_correctly_calculates_times() {
val now = nowSeconds.toInt() val now = nowSeconds.toInt()
val metrics = val metrics =
listOf( listOf(
@ -42,7 +42,7 @@ class EnvironmentMetricsStateTest {
} }
@Test @Test
fun `environmentMetricsForGraphing handles valid zero temperatures`() { fun environmentMetricsForGraphing_handles_valid_zero_temperatures() {
val now = nowSeconds.toInt() val now = nowSeconds.toInt()
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f))) val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
val state = EnvironmentMetricsState(metrics) val state = EnvironmentMetricsState(metrics)

View file

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

View file

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

View file

@ -16,27 +16,11 @@
*/ */
package org.meshtastic.feature.node.navigation 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.runtime.Composable
import androidx.compose.ui.Alignment import org.meshtastic.core.ui.component.PlaceholderScreen
import androidx.compose.ui.Modifier
@Composable @Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) { actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now // Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map") 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 package org.meshtastic.feature.settings.radio
import dev.mokkery.MockMode import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend import dev.mokkery.everySuspend
import dev.mokkery.matcher.any import dev.mokkery.matcher.any
import dev.mokkery.mock import dev.mokkery.mock
@ -58,7 +59,7 @@ class CleanNodeDatabaseViewModelTest {
} }
@Test @Test
fun `getNodesToDelete updates state`() = runTest { fun getNodesToDelete_updates_state() = runTest {
val nodes = listOf(Node(num = 1), Node(num = 2)) val nodes = listOf(Node(num = 1), Node(num = 2))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
@ -69,7 +70,7 @@ class CleanNodeDatabaseViewModelTest {
} }
@Test @Test
fun `cleanNodes calls useCase and clears state`() = runTest { fun cleanNodes_calls_useCase_and_clears_state() = runTest {
val nodes = listOf(Node(num = 1)) val nodes = listOf(Node(num = 1))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete() viewModel.getNodesToDelete()

View file

@ -20,7 +20,6 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.StringResource
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.nowMillis
import org.meshtastic.core.common.util.toDate 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.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bottom_nav_settings 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.export_configuration
import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating 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.ListItem
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog 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.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection 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.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList 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 -> context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
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")
}
}
}
}
} }
Logger.i { "MeshLog exported successfully to $targetUri" } Logger.i { "MeshLog exported successfully to $targetUri" }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) } 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 ?: "") } 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 * 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 * 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 package org.meshtastic.feature.settings.filter
import dev.mokkery.MockMode import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every import dev.mokkery.every
import dev.mokkery.matcher.any import dev.mokkery.matcher.any
import dev.mokkery.mock import dev.mokkery.mock
import dev.mokkery.verify import dev.mokkery.verify
import org.junit.Assert.assertEquals import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.FilterPrefs import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter import org.meshtastic.core.repository.MessageFilter
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FilterSettingsViewModelTest { class FilterSettingsViewModelTest {
@ -34,23 +36,23 @@ class FilterSettingsViewModelTest {
private lateinit var viewModel: FilterSettingsViewModel private lateinit var viewModel: FilterSettingsViewModel
@Before @BeforeTest
fun setUp() { fun setUp() {
every { filterPrefs.filterEnabled.value } returns true every { filterPrefs.filterEnabled } returns MutableStateFlow(true)
every { filterPrefs.filterWords.value } returns setOf("apple", "banana") every { filterPrefs.filterWords } returns MutableStateFlow(setOf("apple", "banana"))
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter) viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
} }
@Test @Test
fun `setFilterEnabled updates prefs and state`() { fun setFilterEnabled_updates_prefs_and_state() {
viewModel.setFilterEnabled(false) viewModel.setFilterEnabled(false)
verify { filterPrefs.setFilterEnabled(false) } verify { filterPrefs.setFilterEnabled(false) }
assertEquals(false, viewModel.filterEnabled.value) assertEquals(false, viewModel.filterEnabled.value)
} }
@Test @Test
fun `addFilterWord updates prefs and rebuilds patterns`() { fun addFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.addFilterWord("cherry") viewModel.addFilterWord("cherry")
verify { filterPrefs.setFilterWords(any()) } verify { filterPrefs.setFilterWords(any()) }
@ -59,7 +61,7 @@ class FilterSettingsViewModelTest {
} }
@Test @Test
fun `removeFilterWord updates prefs and rebuilds patterns`() { fun removeFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.removeFilterWord("apple") viewModel.removeFilterWord("apple")
verify { filterPrefs.setFilterWords(any()) } verify { filterPrefs.setFilterWords(any()) }

View file

@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route 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_settings
import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings 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
import org.meshtastic.core.resources.device_db_cache_limit_summary 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.info
import org.meshtastic.core.resources.modules_already_unlocked import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme 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.DropDownPreference
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog 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.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection 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.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList 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. * 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. * 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) val exportFile = File(directory, selectedFile)
try { try {
FileOutputStream(exportFile).use { fos -> FileOutputStream(exportFile).use { fos ->
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
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")
}
}
}
}
} }
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" } Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
} catch (e: java.io.IOException) { } 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" datastore = "1.2.1"
glance = "1.2.0-rc01" glance = "1.2.0-rc01"
lifecycle = "2.10.0" lifecycle = "2.10.0"
jetbrains-lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha01"
navigation = "2.9.7"
navigation3 = "1.1.0-alpha04" navigation3 = "1.1.0-alpha04"
navigationevent = "1.0.1" navigationevent = "1.1.0-alpha01"
paging = "3.4.2" paging = "3.4.2"
room = "3.0.0-alpha01" room = "3.0.0-alpha01"
savedstate = "1.4.0"
koin = "4.2.0" koin = "4.2.0"
koin-annotations = "2.1.0"
koin-plugin = "0.4.1" koin-plugin = "0.4.1"
# Kotlin # 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-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", 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-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 KMP lifecycle (use in commonMain and androidMain)
jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } 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-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-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" } 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). # 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. # 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" } 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-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
androidx-room-testing = { module = "androidx.room3:room3-testing", 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-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-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }