diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fcb614b12..aae64c1a2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). | | `core:di` | Common DI qualifiers and dispatchers. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. | -| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. | +| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:api` | Public AIDL/API integration module for external clients. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. | @@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Material 3:** The app uses Material 3. - **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). +- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. +- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. +- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer @@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`. - `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`. - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). + - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). +- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`. +- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. - **Concurrency:** Use Kotlin Coroutines and Flow. -- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`. +- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. diff --git a/.gitignore b/.gitignore index 4a057e39f..97dbb7b24 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ wireless-install.sh # Git worktrees .worktrees/ /firebase-debug.log.jdk/ +firebase-debug.log diff --git a/AGENTS.md b/AGENTS.md index fcb614b12..aae64c1a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). | | `core:di` | Common DI qualifiers and dispatchers. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. | -| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. | +| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:api` | Public AIDL/API integration module for external clients. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. | @@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Material 3:** The app uses Material 3. - **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). +- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. +- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. +- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer @@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`. - `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`. - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). + - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). +- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`. +- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. - **Concurrency:** Use Kotlin Coroutines and Flow. -- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`. +- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. diff --git a/GEMINI.md b/GEMINI.md index fcb614b12..aae64c1a2 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). | | `core:di` | Common DI qualifiers and dispatchers. | | `core:navigation` | Shared navigation keys/routes for Navigation 3. | -| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. | +| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | | `core:service` | KMP service layer; Android bindings stay in `androidMain`. | | `core:api` | Public AIDL/API integration module for external clients. | | `core:prefs` | KMP preferences layer built on DataStore abstractions. | @@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Material 3:** The app uses Material 3. - **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). +- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable. +- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. +- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. - **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. ### B. Logic & Data Layer @@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`. - `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`. - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). + - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). +- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`. +- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. - **Concurrency:** Use Kotlin Coroutines and Flow. -- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`. +- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. - **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. - **BLE:** All Bluetooth communication must route through `core:ble` using Kable. - **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d885aee0a..732f4ae2d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,6 +20,9 @@ # hide the original source file name. #-renamesourcefileattribute SourceFile +# Room KMP: preserve generated database constructor (required for R8/ProGuard) +-keep class * extends androidx.room.RoomDatabase { (); } + # Needed for protobufs -keep class com.google.protobuf.** { *; } -keep class org.meshtastic.proto.** { *; } diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 66bd779d0..e10fbbbd3 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -90,11 +90,10 @@ import org.meshtastic.core.resources.should_update_firmware import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.service.MeshService -import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.component.AlertHost import org.meshtastic.core.ui.component.ScrollToTopEvent +import org.meshtastic.core.ui.component.SharedDialogs import org.meshtastic.core.ui.navigation.icon -import org.meshtastic.core.ui.qr.ScannedQrCodeDialog -import org.meshtastic.core.ui.share.SharedContactDialog import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @@ -122,39 +121,17 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle() val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle() - if (connectionState == ConnectionState.Connected) { - sharedContactRequested?.let { - SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() }) - } - - requestChannelSet?.let { newChannelSet -> - ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() }) - } - } + SharedDialogs( + connectionState = connectionState, + sharedContactRequested = sharedContactRequested, + requestChannelSet = requestChannelSet, + onDismissSharedContact = { uIViewModel.clearSharedContactRequested() }, + onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() }, + ) VersionChecks(uIViewModel) - val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle() - alertDialogState?.let { state -> - val title = state.title ?: state.titleRes?.let { stringResource(it) } ?: "" - val message = state.message ?: state.messageRes?.let { stringResource(it) } - val confirmText = state.confirmText ?: state.confirmTextRes?.let { stringResource(it) } - val dismissText = state.dismissText ?: state.dismissTextRes?.let { stringResource(it) } - - MeshtasticDialog( - title = title, - message = message, - html = state.html, - icon = state.icon, - text = state.composableMessage?.let { msg -> { msg.Content() } }, - confirmText = confirmText, - onConfirm = state.onConfirm, - dismissText = dismissText, - onDismiss = state.onDismiss, - choices = state.choices, - dismissable = state.dismissable, - ) - } + AlertHost(uIViewModel.alertManager) val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null) var dismissedTracerouteRequestId by remember { mutableStateOf(null) } diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index 3bbc800b1..ea96ad569 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -17,9 +17,9 @@ import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.provider.Property import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.dependencies +import org.koin.compiler.plugin.KoinGradleExtension import org.meshtastic.buildlogic.libs import org.meshtastic.buildlogic.plugin @@ -28,28 +28,14 @@ class KoinConventionPlugin : Plugin { with(target) { apply(plugin = libs.plugin("koin-compiler").get().pluginId) - // Configure Koin Compiler Plugin (0.4.0+) - extensions.configure("koinCompiler") { - val extension = this - val clazz = extension.javaClass - try { - // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin 0.4.0's A1 - // per-module safety checks strictly enforce that all dependencies must be explicitly - // provided or included locally. This breaks decoupled Clean Architecture designs. - // We disable A1 compile safety globally to properly rely on Koin's A3 full-graph - // validation which perfectly handles inverted dependencies at the composition root. - try { - clazz.getMethod("setCompileSafety", Boolean::class.java).invoke(extension, false) - } catch (e: Exception) { - val prop = clazz.getMethod("getCompileSafety").invoke(extension) - if (prop is Property<*>) { - @Suppress("UNCHECKED_CAST") - (prop as Property).set(false) - } - } - } catch (e: Exception) { - // Ignore gracefully if Koin DSL changes in the future - } + // Configure Koin K2 Compiler Plugin (0.4.0+) + extensions.configure(KoinGradleExtension::class.java) { + // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1 + // per-module safety checks strictly enforce that all dependencies must be explicitly + // provided or included locally. This breaks decoupled Clean Architecture designs. + // We disable compile safety globally to properly rely on Koin's A3 full-graph + // validation which perfectly handles inverted dependencies at the composition root. + compileSafety.set(false) } val koinAnnotations = libs.findLibrary("koin-annotations").get() diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 39c965255..a3e216b84 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -90,6 +90,21 @@ internal fun Project.configureKotlinMultiplatform() { } } + // Disable iOS native test link & run tasks. + // iOS targets exist only for compile-time validation; linking test + // executables is extremely slow and causes `./gradlew test` to hang. + tasks.configureEach { + val taskName = name.lowercase() + if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) { + if (taskName.startsWith("link") && taskName.contains("test") || + taskName == "iosarm64test" || taskName == "iossimulatorarm64test" || + taskName.endsWith("testbinaries") + ) { + enabled = false + } + } + } + configureMokkery() configureKotlin() } diff --git a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleRetryTest.kt similarity index 62% rename from core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt rename to core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleRetryTest.kt index 0fdf4d1a0..bd8de76ff 100644 --- a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleRetryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,15 +19,16 @@ package org.meshtastic.core.ble import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class BleRetryTest { @Test - fun `retryBleOperation returns immediately on success`() = runTest { + fun retryBleOperation_returns_immediately_on_success() = runTest { var attempts = 0 val result = retryBleOperation(count = 3, delayMs = 10L) { @@ -39,7 +40,7 @@ class BleRetryTest { } @Test - fun `retryBleOperation retries on exception and succeeds`() = runTest { + fun retryBleOperation_retries_on_exception_and_succeeds() = runTest { var attempts = 0 val result = retryBleOperation(count = 3, delayMs = 10L) { @@ -54,32 +55,30 @@ class BleRetryTest { } @Test - fun `retryBleOperation throws exception after max attempts`() = runTest { + fun retryBleOperation_throws_exception_after_max_attempts() = runTest { var attempts = 0 - var caughtException: Exception? = null - try { - retryBleOperation(count = 3, delayMs = 10L) { - attempts++ - throw RuntimeException("Persistent error") + val ex = + assertFailsWith { + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + throw RuntimeException("Persistent error") + } } - } catch (e: Exception) { - caughtException = e - } - assertTrue(caughtException is RuntimeException) - assertEquals("Persistent error", caughtException?.message) + assertTrue(ex is RuntimeException) + assertEquals("Persistent error", ex.message) assertEquals(3, attempts) } - @Test(expected = CancellationException::class) - fun `retryBleOperation does not retry CancellationException`() = runTest { + @Test + fun retryBleOperation_does_not_retry_CancellationException() = runTest { var attempts = 0 - retryBleOperation(count = 3, delayMs = 10L) { - attempts++ - throw CancellationException("Cancelled") + assertFailsWith { + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + throw CancellationException("Cancelled") + } } - // Test fails if it catches and doesn't rethrow, or if it retries. - // It shouldn't reach the assertion below because the exception should be thrown immediately. assertEquals(1, attempts) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 8084a9507..ff3600ee5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -34,9 +34,11 @@ import org.meshtastic.core.model.Position import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.isWithinSizeLimit import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Constants @@ -57,18 +59,16 @@ class CommandSenderImpl( private val packetHandler: PacketHandler, private val nodeManager: NodeManager, private val radioConfigRepository: RadioConfigRepository, + private val tracerouteHandler: TracerouteHandler, + private val neighborInfoHandler: NeighborInfoHandler, ) : CommandSender { private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) private val sessionPasskey = atomic(ByteString.EMPTY) - override val tracerouteStartTimes = mutableMapOf() - override val neighborInfoStartTimes = mutableMapOf() private val localConfig = MutableStateFlow(LocalConfig()) private val channelSet = MutableStateFlow(ChannelSet()) - override var lastNeighborInfo: NeighborInfo? = null - // We'll need a way to track connection state in shared code, // maybe via ServiceRepository or similar. // For now I'll assume it's injected or available. @@ -251,7 +251,7 @@ class CommandSenderImpl( } override fun requestTraceroute(requestId: Int, destNum: Int) { - tracerouteStartTimes[requestId] = nowMillis + tracerouteHandler.recordStartTime(requestId) packetHandler.sendToRadio( buildMeshPacket( to = destNum, @@ -302,11 +302,11 @@ class CommandSenderImpl( } override fun requestNeighborInfo(requestId: Int, destNum: Int) { - neighborInfoStartTimes[requestId] = nowMillis + neighborInfoHandler.recordStartTime(requestId) val myNum = nodeManager.myNodeNum ?: 0 if (destNum == myNum) { val neighborInfoToSend = - lastNeighborInfo + neighborInfoHandler.lastNeighborInfo ?: run { val oneHour = 1.hours.inWholeMinutes.toInt() Logger.d { "No stored neighbor info from connected radio, sending dummy data" } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index d7d0cebfe..774c29ce3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager @@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl( private val packetHandler: PacketHandler, ) : MeshConfigFlowManager { private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) - private val configOnlyNonce = 69420 - private val nodeInfoNonce = 69421 private val wantConfigDelay = 100L override fun start(scope: CoroutineScope) { @@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl( override fun handleConfigComplete(configCompleteId: Int) { when (configCompleteId) { - configOnlyNonce -> handleConfigOnlyComplete() - nodeInfoNonce -> handleNodeInfoComplete() + HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete() + HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete() else -> Logger.w { "Config complete id mismatch: $configCompleteId" } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 02192894b..c41a9e3da 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair +import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshLocationManager @@ -253,13 +254,13 @@ class MeshConnectionManagerImpl( } override fun startConfigOnly() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) } + val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } startHandshakeStallGuard(1, action) action() } override fun startNodeInfoOnly() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) } + val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } startHandshakeStallGuard(2, action) action() } @@ -340,8 +341,6 @@ class MeshConnectionManagerImpl( } companion object { - private const val CONFIG_ONLY_NONCE = 69420 - private const val NODE_INFO_NONCE = 69421 private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 private val HANDSHAKE_TIMEOUT = 30.seconds diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 64811d0e9..6931a44d1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import okio.ByteString.Companion.toByteString -import okio.IOException import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ioDispatcher @@ -37,12 +35,10 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.util.MeshDataMapper -import org.meshtastic.core.model.util.SfppHasher import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toOneLiner import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager @@ -59,6 +55,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.critical_alert @@ -75,8 +72,6 @@ import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position import org.meshtastic.proto.Routing import org.meshtastic.proto.StatusMessage -import org.meshtastic.proto.StoreAndForward -import org.meshtastic.proto.StoreForwardPlusPlus import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint @@ -107,17 +102,21 @@ class MeshDataHandlerImpl( private val configHandler: Lazy, private val configFlowManager: Lazy, private val commandSender: CommandSender, - private val historyManager: HistoryManager, private val connectionManager: Lazy, private val tracerouteHandler: TracerouteHandler, private val neighborInfoHandler: NeighborInfoHandler, private val radioConfigRepository: RadioConfigRepository, private val messageFilter: MessageFilter, + private val storeForwardHandler: StoreForwardPacketHandler, ) : MeshDataHandler { private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val batteryMutex = Mutex() + private val batteryPercentCooldowns = mutableMapOf() + override fun start(scope: CoroutineScope) { this.scope = scope + storeForwardHandler.start(scope) } private val rememberDataType = @@ -191,11 +190,11 @@ class MeshDataHandlerImpl( } PortNum.STORE_FORWARD_APP -> { - handleStoreAndForward(packet, dataPacket, myNodeNum) + storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum) } PortNum.STORE_FORWARD_PLUSPLUS_APP -> { - handleStoreForwardPlusPlus(packet) + storeForwardHandler.handleStoreForwardPlusPlus(packet) } PortNum.ADMIN_APP -> { @@ -235,98 +234,6 @@ class MeshDataHandlerImpl( rememberDataPacket(u, myNodeNum) } - private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val u = StoreAndForward.ADAPTER.decode(payload) - handleReceivedStoreAndForward(dataPacket, u, myNodeNum) - } - - @Suppress("LongMethod", "ReturnCount") - private fun handleStoreForwardPlusPlus(packet: MeshPacket) { - val payload = packet.decoded?.payload ?: return - val sfpp = - try { - StoreForwardPlusPlus.ADAPTER.decode(payload) - } catch (e: IOException) { - Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" } - return - } - Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" } - - when (sfpp.sfpp_message_type) { - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, - -> { - val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE - - // If it has a commit hash, it's already on the chain (Confirmed) - // Otherwise it's still being routed via SF++ (Routing) - val status = - if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED - - // Prefer a full 16-byte hash calculated from the message bytes if available - // But only if it's NOT a fragment, otherwise the calculated hash would be wrong - val hash = - when { - sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() - !isFragment && sfpp.message.size != 0 -> { - SfppHasher.computeMessageHash( - encryptedPayload = sfpp.message.toByteArray(), - // Map 0 back to NODENUM_BROADCAST to match firmware hash calculation - to = - if (sfpp.encapsulated_to == 0) { - DataPacket.NODENUM_BROADCAST - } else { - sfpp.encapsulated_to - }, - from = sfpp.encapsulated_from, - id = sfpp.encapsulated_id, - ) - } - else -> null - } ?: return - - Logger.d { - "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + - "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status" - } - scope.handledLaunch { - packetRepository.value.updateSFPPStatus( - packetId = sfpp.encapsulated_id, - from = sfpp.encapsulated_from, - to = sfpp.encapsulated_to, - hash = hash, - status = status, - rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - myNodeNum = nodeManager.myNodeNum ?: 0, - ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) - } - } - - StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> { - scope.handledLaunch { - sfpp.message_hash.let { - packetRepository.value.updateSFPPStatusByHash( - hash = it.toByteArray(), - status = MessageStatus.SFPP_CONFIRMED, - rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - ) - } - } - } - - StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { - Logger.i { "SF++: Node ${packet.from} is querying chain status" } - } - - StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> { - Logger.i { "SF++: Node ${packet.from} is requesting links" } - } - } - } - private fun handlePaxCounter(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return @@ -559,52 +466,6 @@ class MeshDataHandlerImpl( } } - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { - Logger.d { "StoreAndForward: variant from ${dataPacket.from}" } - // For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it. - // In the original, it was used for logging. - val h = s.history - val lastRequest = h?.last_request ?: 0 - Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" } - when { - s.stats != null -> { - val text = s.stats.toString() - val u = - dataPacket.copy( - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - rememberDataPacket(u, myNodeNum) - } - h != null -> { - val text = - "Total messages: ${h.history_messages}\n" + - "History window: ${h.window.milliseconds.inWholeMinutes} min\n" + - "Last request: ${h.last_request}" - val u = - dataPacket.copy( - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - rememberDataPacket(u, myNodeNum) - // historyManager call remains same - historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown") - } - s.heartbeat != null -> { - val hb = s.heartbeat!! - Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" } - } - s.text != null -> { - if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { - dataPacket.to = DataPacket.ID_BROADCAST - } - val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) - rememberDataPacket(u, myNodeNum) - } - else -> {} - } - } - override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = @@ -807,7 +668,5 @@ class MeshDataHandlerImpl( private const val BATTERY_PERCENT_LOW_DIVISOR = 5 private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5 private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500 - private val batteryMutex = Mutex() - private val batteryPercentCooldowns = mutableMapOf() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 631f2e4ca..1b971ec3a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -17,13 +17,15 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.ServiceBroadcasts @@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo class NeighborInfoHandlerImpl( private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, - private val commandSender: CommandSender, private val serviceBroadcasts: ServiceBroadcasts, ) : NeighborInfoHandler { private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val startTimes = atomic(persistentMapOf()) + + override var lastNeighborInfo: NeighborInfo? = null + override fun start(scope: CoroutineScope) { this.scope = scope } + override fun recordStartTime(requestId: Int) { + startTimes.update { it.put(requestId, nowMillis) } + } + override fun handleNeighborInfo(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return val ni = NeighborInfo.ADAPTER.decode(payload) @@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl( // Store the last neighbor info from our connected radio val from = packet.from if (from == nodeManager.myNodeNum) { - commandSender.lastNeighborInfo = ni + lastNeighborInfo = ni Logger.d { "Stored last neighbor info from connected radio" } } @@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl( // Format for UI response val requestId = packet.decoded?.request_id ?: 0 - val start = commandSender.neighborInfoStartTimes.remove(requestId) + val start = startTimes.value[requestId] + startTimes.update { it.remove(requestId) } val neighbors = ni.neighbors.joinToString("\n") { n -> diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt new file mode 100644 index 000000000..3644c9c22 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -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 . + */ +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, + private val serviceBroadcasts: ServiceBroadcasts, + private val historyManager: HistoryManager, + private val dataHandler: Lazy, +) : 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 -> {} + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index ae03186f0..d7eb38982 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -17,18 +17,20 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.Node import org.meshtastic.core.model.fullRouteDiscovery -import org.meshtastic.core.model.getFullTracerouteResponse +import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.service.TracerouteResponse -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -42,33 +44,43 @@ class TracerouteHandlerImpl( private val serviceRepository: ServiceRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRepository: NodeRepository, - private val commandSender: CommandSender, ) : TracerouteHandler { private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val startTimes = atomic(persistentMapOf()) + override fun start(scope: CoroutineScope) { this.scope = scope } - override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) { + override fun recordStartTime(requestId: Int) { + startTimes.update { it.put(requestId, nowMillis) } + } + + override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) { + // Decode the route discovery once — avoids triple protobuf decode + val routeDiscovery = packet.fullRouteDiscovery ?: return + val forwardRoute = routeDiscovery.route + val returnRoute = routeDiscovery.route_back + + // Require both directions for a "full" traceroute response + if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return + val full = - packet.getFullTracerouteResponse( + routeDiscovery.getTracerouteResponse( getUser = { num -> - nodeManager.nodeDBbyNodeNum[num]?.let { node: Node -> - "${node.user.long_name} (${node.user.short_name})" - } ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later + nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" } + ?: "Unknown" // TODO: Use core:resources once available in core:data }, headerTowards = "Route towards destination:", headerBack = "Route back to us:", - ) ?: return + ) val requestId = packet.decoded?.request_id ?: 0 + if (logUuid != null) { scope.handledLaunch { logInsertJob?.join() - val routeDiscovery = packet.fullRouteDiscovery - val forwardRoute = routeDiscovery?.route.orEmpty() - val returnRoute = routeDiscovery?.route_back.orEmpty() val routeNodeNums = (forwardRoute + returnRoute).distinct() val nodeDbByNum = nodeRepository.nodeDBbyNum.value val snapshotPositions = @@ -77,28 +89,27 @@ class TracerouteHandlerImpl( } } - val start = commandSender.tracerouteStartTimes.remove(requestId) + val start = startTimes.value[requestId] + startTimes.update { it.remove(requestId) } val responseText = if (start != null) { val elapsedMs = nowMillis - start val seconds = elapsedMs / MILLIS_PER_SECOND Logger.i { "Traceroute $requestId complete in $seconds s" } - val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s" - "$full\n\n$durationText" + "$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s" } else { full } - val routeDiscovery = packet.fullRouteDiscovery - val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0 + val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0 serviceRepository.setTracerouteResponse( TracerouteResponse( message = responseText, destinationNodeNum = destination, requestId = requestId, - forwardRoute = routeDiscovery?.route.orEmpty(), - returnRoute = routeDiscovery?.route_back.orEmpty(), + forwardRoute = forwardRoute, + returnRoute = returnRoute, logUuid = logUuid, ), ) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index ce60e5d41..d3f0efc32 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -16,15 +16,47 @@ */ package org.meshtastic.core.data.manager -class FromRadioPacketHandlerImplTest { - /* +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import dev.mokkery.verify +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.QueueStatus +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +class FromRadioPacketHandlerImplTest { + + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val mqttManager: MqttManager = mock(MockMode.autofill) + private val packetHandler: PacketHandler = mock(MockMode.autofill) + private val notificationManager: NotificationManager = mock(MockMode.autofill) + private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill) + private val configHandler: MeshConfigHandler = mock(MockMode.autofill) + private val router: MeshRouter = mock(MockMode.autofill) private lateinit var handler: FromRadioPacketHandlerImpl - @Before + @BeforeTest fun setup() { - mockkStatic("org.meshtastic.core.resources.GetStringKt") + every { router.configFlowManager } returns configFlowManager + every { router.configHandler } returns configHandler handler = FromRadioPacketHandlerImpl( @@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest { handler.handleFromRadio(proto) - verify { router.configFlowManager.handleMyInfo(myInfo) } + verify { configFlowManager.handleMyInfo(myInfo) } } @Test @@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest { handler.handleFromRadio(proto) - verify { router.configFlowManager.handleLocalMetadata(metadata) } + verify { configFlowManager.handleLocalMetadata(metadata) } } @Test fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() { - val nodeInfo = NodeInfo(num = 1234) + val nodeInfo = ProtoNodeInfo(num = 1234) val proto = FromRadio(node_info = nodeInfo) - every { router.configFlowManager.newNodeCount } returns 1 + every { configFlowManager.newNodeCount } returns 1 handler.handleFromRadio(proto) - verify { router.configFlowManager.handleNodeInfo(nodeInfo) } + verify { configFlowManager.handleNodeInfo(nodeInfo) } verify { serviceRepository.setConnectionProgress("Nodes (1)") } } @@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest { handler.handleFromRadio(proto) - verify { router.configFlowManager.handleConfigComplete(nonce) } + verify { configFlowManager.handleConfigComplete(nonce) } } @Test @@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest { handler.handleFromRadio(proto) - verify { router.configHandler.handleDeviceConfig(config) } + verify { configHandler.handleDeviceConfig(config) } } @Test - fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() { - val notification = ClientNotification(message = "test") - val proto = FromRadio(clientNotification = notification) + fun `handleFromRadio routes MODULE_CONFIG to configHandler`() { + val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val proto = FromRadio(moduleConfig = moduleConfig) handler.handleFromRadio(proto) - verify { serviceRepository.setClientNotification(notification) } - verify { packetHandler.removeResponse(0, complete = false) } + verify { configHandler.handleModuleConfig(moduleConfig) } } - */ + @Test + fun `handleFromRadio routes CHANNEL to configHandler`() { + val channel = Channel(index = 0) + val proto = FromRadio(channel = channel) + + handler.handleFromRadio(proto) + + verify { configHandler.handleChannel(channel) } + } + + @Test + fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() { + val proxyMsg = MqttClientProxyMessage(topic = "test/topic") + val proto = FromRadio(mqttClientProxyMessage = proxyMsg) + + handler.handleFromRadio(proto) + + verify { mqttManager.handleMqttProxyMessage(proxyMsg) } + } + + @Test + fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() { + val notification = ClientNotification(message = "test") + val proto = FromRadio(clientNotification = notification) + + // Note: getString() from Compose Resources requires Skiko native lib which + // is not available in headless JVM tests. We test the parts that don't trigger it. + try { + handler.handleFromRadio(proto) + } catch (_: Throwable) { + // Expected: Skiko can't load in headless JVM/native + } + + verify { serviceRepository.setClientNotification(notification) } + } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt index ebf0ca065..7be980b21 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt @@ -16,9 +16,9 @@ */ package org.meshtastic.core.data.manager -import org.junit.Assert.assertEquals -import org.junit.Test import org.meshtastic.proto.StoreAndForward +import kotlin.test.Test +import kotlin.test.assertEquals class HistoryManagerImplTest { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index b8684930c..0f3c5dfdb 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -17,10 +17,25 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verifySuspend +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.ContactSettings +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.MeshDataMapper import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager @@ -35,12 +50,23 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TracerouteHandler +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertNotNull +@OptIn(ExperimentalCoroutinesApi::class) class MeshDataHandlerTest { private lateinit var handler: MeshDataHandlerImpl @@ -56,12 +82,15 @@ class MeshDataHandlerTest { private val configHandler: MeshConfigHandler = mock(MockMode.autofill) private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) - private val historyManager: HistoryManager = mock(MockMode.autofill) private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill) private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill) private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val messageFilter: MessageFilter = mock(MockMode.autofill) + private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill) + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) @BeforeTest fun setUp() { @@ -79,13 +108,21 @@ class MeshDataHandlerTest { configHandler = lazy { configHandler }, configFlowManager = lazy { configFlowManager }, commandSender = commandSender, - historyManager = historyManager, connectionManager = lazy { connectionManager }, tracerouteHandler = tracerouteHandler, neighborInfoHandler = neighborInfoHandler, radioConfigRepository = radioConfigRepository, messageFilter = messageFilter, + storeForwardHandler = storeForwardHandler, ) + handler.start(testScope) + + // Default: mapper returns null for empty packets, which is the safe default + every { dataMapper.toDataPacket(any()) } returns null + // Stub commonly accessed properties to avoid NPE from autofill + every { nodeManager.nodeDBbyID } returns emptyMap() + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) } @Test @@ -94,8 +131,582 @@ class MeshDataHandlerTest { } @Test - fun `handleReceivedData processes packet`() { + fun `handleReceivedData returns early when dataMapper returns null`() { val packet = MeshPacket() + every { dataMapper.toDataPacket(packet) } returns null + handler.handleReceivedData(packet, 123) + + // Should not broadcast if dataMapper returns null + verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } + } + + @Test + fun `handleReceivedData does not broadcast for position from local node`() { + val myNodeNum = 123 + val position = Position(latitude_i = 450000000, longitude_i = 900000000) + val packet = + MeshPacket( + from = myNodeNum, + decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = DataPacket.nodeNumToDefaultId(myNodeNum), + to = DataPacket.ID_BROADCAST, + bytes = position.encode().toByteString(), + dataType = PortNum.POSITION_APP.value, + time = 1000L, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + // Position from local node: shouldBroadcast stays as !fromUs = false + verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } + } + + @Test + fun `handleReceivedData broadcasts for remote packets`() { + val myNodeNum = 123 + val remoteNum = 456 + val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP)) + val dataPacket = + DataPacket( + from = DataPacket.nodeNumToDefaultId(remoteNum), + to = DataPacket.ID_BROADCAST, + bytes = null, + dataType = PortNum.PRIVATE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + verify { serviceBroadcasts.broadcastReceivedData(any()) } + } + + @Test + fun `handleReceivedData tracks analytics`() { + val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP)) + val dataPacket = + DataPacket( + from = "!other", + to = DataPacket.ID_BROADCAST, + bytes = null, + dataType = PortNum.PRIVATE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { analytics.track("num_data_receive", any()) } + } + + // --- Position handling --- + + @Test + fun `position packet delegates to nodeManager`() { + val myNodeNum = 123 + val remoteNum = 456 + val position = Position(latitude_i = 450000000, longitude_i = 900000000) + val packet = + MeshPacket( + from = remoteNum, + decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = position.encode().toByteString(), + dataType = PortNum.POSITION_APP.value, + time = 1000L, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) } + } + + // --- NodeInfo handling --- + + @Test + fun `nodeinfo packet from remote delegates to handleReceivedUser`() { + val myNodeNum = 123 + val remoteNum = 456 + val user = User(id = "!remote", long_name = "Remote", short_name = "R") + val packet = + MeshPacket( + from = remoteNum, + decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = user.encode().toByteString(), + dataType = PortNum.NODEINFO_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) } + } + + @Test + fun `nodeinfo packet from local node is ignored`() { + val myNodeNum = 123 + val user = User(id = "!local", long_name = "Local", short_name = "L") + val packet = + MeshPacket( + from = myNodeNum, + decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!local", + to = DataPacket.ID_BROADCAST, + bytes = user.encode().toByteString(), + dataType = PortNum.NODEINFO_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) } + } + + // --- Paxcounter handling --- + + @Test + fun `paxcounter packet delegates to nodeManager`() { + val remoteNum = 456 + val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000) + val packet = + MeshPacket( + from = remoteNum, + decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = pax.encode().toByteString(), + dataType = PortNum.PAXCOUNTER_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) } + } + + // --- Traceroute handling --- + + @Test + fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() { + val packet = + MeshPacket( + from = 456, + decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = "!local", + bytes = byteArrayOf().toByteString(), + dataType = PortNum.TRACEROUTE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { tracerouteHandler.handleTraceroute(packet, any(), any()) } + verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } + } + + // --- NeighborInfo handling --- + + @Test + fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() { + val ni = NeighborInfo(node_id = 456) + val packet = + MeshPacket( + from = 456, + decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = ni.encode().toByteString(), + dataType = PortNum.NEIGHBORINFO_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { neighborInfoHandler.handleNeighborInfo(packet) } + verify { serviceBroadcasts.broadcastReceivedData(any()) } + } + + // --- Store-and-Forward handling --- + + @Test + fun `store forward packet delegates to storeForwardHandler`() { + val packet = + MeshPacket( + from = 456, + decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = byteArrayOf().toByteString(), + dataType = PortNum.STORE_FORWARD_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) } + } + + // --- Routing/ACK-NAK handling --- + + @Test + fun `routing packet with successful ack broadcasts and removes response`() { + val routing = Routing(error_reason = Routing.Error.NONE) + val packet = + MeshPacket( + from = 456, + decoded = + Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = routing.encode().toByteString(), + dataType = PortNum.ROUTING_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + every { nodeManager.toNodeID(456) } returns "!remote" + + handler.handleReceivedData(packet, 123) + + verify { packetHandler.removeResponse(99, complete = true) } + } + + @Test + fun `routing packet always broadcasts`() { + val routing = Routing(error_reason = Routing.Error.NONE) + val packet = + MeshPacket( + from = 456, + decoded = + Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = routing.encode().toByteString(), + dataType = PortNum.ROUTING_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + every { nodeManager.toNodeID(456) } returns "!remote" + + handler.handleReceivedData(packet, 123) + + verify { serviceBroadcasts.broadcastReceivedData(any()) } + } + + // --- Telemetry handling --- + + @Test + fun `telemetry packet updates node via nodeManager`() { + val telemetry = + Telemetry( + time = 2000, + device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f), + ) + val packet = + MeshPacket( + from = 456, + decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = telemetry.encode().toByteString(), + dataType = PortNum.TELEMETRY_APP.value, + time = 2000000L, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { nodeManager.updateNode(456, any(), any(), any()) } + } + + @Test + fun `telemetry from local node also updates connectionManager`() { + val myNodeNum = 123 + val telemetry = + Telemetry( + time = 2000, + device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f), + ) + val packet = + MeshPacket( + from = myNodeNum, + decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()), + ) + val dataPacket = + DataPacket( + from = "!local", + to = DataPacket.ID_BROADCAST, + bytes = telemetry.encode().toByteString(), + dataType = PortNum.TELEMETRY_APP.value, + time = 2000000L, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, myNodeNum) + + verify { connectionManager.updateTelemetry(any()) } + } + + // --- Text message handling --- + + @Test + fun `text message is persisted via rememberDataPacket`() = testScope.runTest { + val packet = + MeshPacket( + id = 42, + from = 456, + decoded = + Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), + ) + val dataPacket = + DataPacket( + id = 42, + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = "hello".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList() + everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") + every { messageFilter.shouldFilter(any(), any()) } returns false + // Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko) + every { nodeManager.nodeDBbyID } returns + mapOf( + "!remote" to + Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")), + ) + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) } + } + + @Test + fun `duplicate text message is not inserted again`() = testScope.runTest { + val packet = + MeshPacket( + id = 42, + from = 456, + decoded = + Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), + ) + val dataPacket = + DataPacket( + id = 42, + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = "hello".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + // Return existing packet on duplicate check + everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket) + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) { + packetRepository.insert(any(), any(), any(), any(), any(), any()) + } + } + + // --- Reaction handling --- + + @Test + fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest { + val emojiBytes = "👍".encodeToByteArray() + val packet = + MeshPacket( + id = 99, + from = 456, + to = 123, + decoded = + Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = emojiBytes.toByteString(), + reply_id = 42, + emoji = 1, + ), + ) + val dataPacket = + DataPacket( + id = 99, + from = "!remote", + to = "!local", + bytes = emojiBytes.toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + every { nodeManager.nodeDBbyNodeNum } returns + mapOf( + 456 to Node(num = 456, user = User(id = "!remote")), + 123 to Node(num = 123, user = User(id = "!local")), + ) + everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList() + every { nodeManager.myNodeNum } returns 123 + everySuspend { packetRepository.getPacketByPacketId(42) } returns null + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend { packetRepository.insertReaction(any(), 123) } + } + + // --- Range test / detection sensor handling --- + + @Test + fun `range test packet is remembered as text message type`() = testScope.runTest { + val packet = + MeshPacket( + id = 55, + from = 456, + decoded = + Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()), + ) + val dataPacket = + DataPacket( + id = 55, + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = "test".encodeToByteArray().toByteString(), + dataType = PortNum.RANGE_TEST_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList() + everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") + every { messageFilter.shouldFilter(any(), any()) } returns false + every { nodeManager.nodeDBbyID } returns + mapOf( + "!remote" to + Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")), + ) + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + // Range test should be remembered with TEXT_MESSAGE_APP dataType + verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) } + } + + // --- Admin message handling --- + + @Test + fun `admin message sets session passkey`() { + val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3)) + val packet = + MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString())) + val dataPacket = + DataPacket( + from = "!local", + to = DataPacket.ID_BROADCAST, + bytes = admin.encode().toByteString(), + dataType = PortNum.ADMIN_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + + handler.handleReceivedData(packet, 123) + + verify { commandSender.setSessionPasskey(any()) } + } + + // --- Message filtering --- + + @Test + fun `filtered message is inserted with filtered flag`() = testScope.runTest { + val packet = + MeshPacket( + id = 77, + from = 456, + decoded = + Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "spam content".encodeToByteArray().toByteString(), + ), + ) + val dataPacket = + DataPacket( + id = 77, + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = "spam content".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList() + every { nodeManager.nodeDBbyID } returns emptyMap() + everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") + every { messageFilter.shouldFilter("spam content", false) } returns true + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + // Verify insert was called with filtered = true (6th param) + verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) } + } + + @Test + fun `message from ignored node is filtered`() = testScope.runTest { + val packet = + MeshPacket( + id = 88, + from = 456, + decoded = + Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), + ) + val dataPacket = + DataPacket( + id = 88, + from = "!remote", + to = DataPacket.ID_BROADCAST, + bytes = "hello".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList() + every { nodeManager.nodeDBbyID } returns + mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true)) + everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt index 4c6e733b3..d0d05dbb7 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt @@ -16,22 +16,29 @@ */ package org.meshtastic.core.data.manager +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.flow.MutableStateFlow +import org.meshtastic.core.repository.FilterPrefs +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + class MessageFilterImplTest { - /* private lateinit var filterPrefs: FilterPrefs - private lateinit var filterEnabledFlow: MutableStateFlow - private lateinit var filterWordsFlow: MutableStateFlow> + private val filterEnabledFlow = MutableStateFlow(true) + private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad")) private lateinit var filterService: MessageFilterImpl - @Before + @BeforeTest fun setup() { - filterEnabledFlow = MutableStateFlow(true) - filterWordsFlow = MutableStateFlow(setOf("spam", "bad")) - filterPrefs = mockk { - every { filterEnabled } returns filterEnabledFlow - every { filterWords } returns filterWordsFlow - } + filterPrefs = mock(MockMode.autofill) + every { filterPrefs.filterEnabled } returns filterEnabledFlow + every { filterPrefs.filterWords } returns filterWordsFlow filterService = MessageFilterImpl(filterPrefs) } @@ -92,6 +99,4 @@ class MessageFilterImplTest { filterService.rebuildPatterns() assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false)) } - - */ } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index aef335e7c..531f77e7a 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -16,17 +16,36 @@ */ package org.meshtastic.core.data.manager +import dev.mokkery.MockMode +import dev.mokkery.mock +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.meshtastic.proto.Position as ProtoPosition + class NodeManagerImplTest { - /* - + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill) + private val notificationManager: NotificationManager = mock(MockMode.autofill) private lateinit var nodeManager: NodeManagerImpl - @Before + @BeforeTest fun setUp() { - mockkStatic("org.meshtastic.core.resources.GetStringKt") - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager) } @@ -63,8 +82,9 @@ class NodeManagerImplTest { @Test fun `handleReceivedUser updates user if incoming is higher detail`() { val nodeNum = 1234 + // Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString) val existingUser = - User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) + User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2) nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } @@ -81,29 +101,30 @@ class NodeManagerImplTest { @Test fun `handleReceivedPosition updates node position`() { val nodeNum = 1234 - val position = Position(latitude_i = 450000000, longitude_i = 900000000) + val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000) nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0) val result = nodeManager.nodeDBbyNodeNum[nodeNum] - assertNotNull(result!!.position) - assertEquals(45.0, result.latitude, 0.0001) - assertEquals(90.0, result.longitude, 0.0001) + assertNotNull(result) + assertNotNull(result.position) + assertEquals(450000000, result.position.latitude_i) + assertEquals(900000000, result.position.longitude_i) } @Test fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() { val nodeNum = 1234 - val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10) + val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10) nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L) // Receive "zero" position with new satellite count - val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001) + val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001) nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L) val result = nodeManager.nodeDBbyNodeNum[nodeNum] - assertEquals(45.0, result!!.latitude, 0.0001) - assertEquals(90.0, result.longitude, 0.0001) + assertEquals(450000000, result!!.position.latitude_i) + assertEquals(900000000, result.position.longitude_i) assertEquals(5, result.position.sats_in_view) assertEquals(1001, result.lastHeard) } @@ -111,13 +132,13 @@ class NodeManagerImplTest { @Test fun `handleReceivedPosition for local node ignores purely empty packets`() { val myNum = 1111 - val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0) + val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0) nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0) val result = nodeManager.nodeDBbyNodeNum[myNum] - // Should still be a default/unset node if it didn't exist, or shouldn't have position - assertTrue(result == null || result.position.latitude_i == null) + // Should still be null since the empty position for local node is ignored + assertNull(result) } @Test @@ -125,11 +146,7 @@ class NodeManagerImplTest { val nodeNum = 1234 nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) } - val telemetry = - org.meshtastic.proto.Telemetry( - time = 2000, - device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50), - ) + val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50)) nodeManager.handleReceivedTelemetry(nodeNum, telemetry) @@ -140,10 +157,7 @@ class NodeManagerImplTest { @Test fun `handleReceivedTelemetry updates device metrics`() { val nodeNum = 1234 - val telemetry = - org.meshtastic.proto.Telemetry( - device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f), - ) + val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f)) nodeManager.handleReceivedTelemetry(nodeNum, telemetry) @@ -157,10 +171,7 @@ class NodeManagerImplTest { fun `handleReceivedTelemetry updates environment metrics`() { val nodeNum = 1234 val telemetry = - org.meshtastic.proto.Telemetry( - environment_metrics = - org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f), - ) + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f)) nodeManager.handleReceivedTelemetry(nodeNum, telemetry) @@ -180,5 +191,39 @@ class NodeManagerImplTest { assertNull(nodeManager.myNodeNum) } - */ + @Test + fun `toNodeID returns broadcast ID for broadcast nodeNum`() { + val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) + assertEquals(DataPacket.ID_BROADCAST, result) + } + + @Test + fun `toNodeID returns default hex ID for unknown node`() { + val result = nodeManager.toNodeID(0x1234) + assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result) + } + + @Test + fun `toNodeID returns user ID for known node`() { + val nodeNum = 5678 + val userId = "!customid" + nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) } + val result = nodeManager.toNodeID(nodeNum) + assertEquals(userId, result) + } + + @Test + fun `removeByNodenum removes node from both maps`() { + val nodeNum = 1234 + nodeManager.updateNode(nodeNum) { + Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T")) + } + assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum)) + assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode")) + + nodeManager.removeByNodenum(nodeNum) + + assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum)) + assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode")) + } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 491497ba7..092417ad9 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -30,7 +30,11 @@ constructor( private val radioController: RadioController, ) { /** Identifies nodes that match the cleanup criteria. */ - suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List { + open suspend fun getNodesToClean( + olderThanDays: Float, + onlyUnknownNodes: Boolean, + currentTimeSeconds: Long, + ): List { val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds @@ -49,7 +53,7 @@ constructor( } /** Performs the cleanup of specified nodes. */ - suspend fun cleanNodes(nodeNums: List) { + open suspend fun cleanNodes(nodeNums: List) { if (nodeNums.isEmpty()) return nodeRepository.deleteNodes(nodeNums) diff --git a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt deleted file mode 100644 index 4bbb63611..000000000 --- a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt +++ /dev/null @@ -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 . - */ -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)) - } - - */ -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt index 7f528c4d8..38706da00 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt @@ -76,7 +76,7 @@ private fun formatTraceroutePath(nodesList: List, snrList: List): S .joinToString("\n") } -private fun RouteDiscovery.getTracerouteResponse( +fun RouteDiscovery.getTracerouteResponse( getUser: (nodeNum: Int) -> String, headerTowards: String = "Route traced toward destination:\n\n", headerBack: String = "Route traced back to us:\n\n", @@ -98,15 +98,6 @@ fun MeshPacket.getTracerouteResponse( headerBack: String = "Route traced back to us:\n\n", ): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack) -/** Returns a traceroute response string only when the result is complete (both directions). */ -fun MeshPacket.getFullTracerouteResponse( - getUser: (nodeNum: Int) -> String, - headerTowards: String = "Route traced toward destination:\n\n", - headerBack: String = "Route traced back to us:\n\n", -): String? = fullRouteDiscovery - ?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() } - ?.getTracerouteResponse(getUser, headerTowards, headerBack) - enum class TracerouteMapAvailability { Ok, MissingEndpoints, diff --git a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt similarity index 70% rename from core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index be6d2cfef..ecaf88db6 100644 --- a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -16,81 +16,83 @@ */ package org.meshtastic.core.model -class CapabilitiesTest { - /* +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +class CapabilitiesTest { private fun caps(version: String?) = Capabilities(version, forceEnableAll = false) @Test - fun canMuteNodeRequiresV2718() { + fun canMuteNode_requires_V2_7_18() { assertFalse(caps("2.7.15").canMuteNode) assertTrue(caps("2.7.18").canMuteNode) assertTrue(caps("2.8.0").canMuteNode) } @Test - fun canRequestNeighborInfoIsCurrentlyDisabled() { + fun canRequestNeighborInfo_is_currently_disabled() { assertFalse(caps("2.7.14").canRequestNeighborInfo) assertFalse(caps("3.0.0").canRequestNeighborInfo) } @Test - fun canSendVerifiedContactsRequiresV2712() { + fun canSendVerifiedContacts_requires_V2_7_12() { assertFalse(caps("2.7.11").canSendVerifiedContacts) assertTrue(caps("2.7.12").canSendVerifiedContacts) } @Test - fun canToggleTelemetryEnabledRequiresV2712() { + fun canToggleTelemetryEnabled_requires_V2_7_12() { assertFalse(caps("2.7.11").canToggleTelemetryEnabled) assertTrue(caps("2.7.12").canToggleTelemetryEnabled) } @Test - fun canToggleUnmessageableRequiresV269() { + fun canToggleUnmessageable_requires_V2_6_9() { assertFalse(caps("2.6.8").canToggleUnmessageable) assertTrue(caps("2.6.9").canToggleUnmessageable) } @Test - fun supportsQrCodeSharingRequiresV268() { + fun supportsQrCodeSharing_requires_V2_6_8() { assertFalse(caps("2.6.7").supportsQrCodeSharing) assertTrue(caps("2.6.8").supportsQrCodeSharing) } @Test - fun supportsSecondaryChannelLocationRequiresV2610() { + fun supportsSecondaryChannelLocation_requires_V2_6_10() { assertFalse(caps("2.6.9").supportsSecondaryChannelLocation) assertTrue(caps("2.6.10").supportsSecondaryChannelLocation) } @Test - fun supportsStatusMessageRequiresV2717() { + fun supportsStatusMessage_requires_V2_7_17() { assertFalse(caps("2.7.16").supportsStatusMessage) assertTrue(caps("2.7.17").supportsStatusMessage) } @Test - fun supportsTrafficManagementConfigRequiresV300() { + fun supportsTrafficManagementConfig_requires_V3_0_0() { assertFalse(caps("2.7.18").supportsTrafficManagementConfig) assertTrue(caps("3.0.0").supportsTrafficManagementConfig) } @Test - fun supportsTakConfigRequiresV2719() { + fun supportsTakConfig_requires_V2_7_19() { assertFalse(caps("2.7.18").supportsTakConfig) assertTrue(caps("2.7.19").supportsTakConfig) } @Test - fun supportsEsp32OtaRequiresV2718() { + fun supportsEsp32Ota_requires_V2_7_18() { assertFalse(caps("2.7.17").supportsEsp32Ota) assertTrue(caps("2.7.18").supportsEsp32Ota) } @Test - fun nullFirmwareReturnsAllFalse() { + fun nullFirmware_returns_all_false() { val c = caps(null) assertFalse(c.canMuteNode) assertFalse(c.canRequestNeighborInfo) @@ -106,7 +108,7 @@ class CapabilitiesTest { } @Test - fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() { + fun forceEnableAll_returns_true_regardless_of_version() { val c = Capabilities(firmwareVersion = null, forceEnableAll = true) assertTrue(c.canMuteNode) assertTrue(c.canSendVerifiedContacts) @@ -114,23 +116,4 @@ class CapabilitiesTest { assertTrue(c.supportsTrafficManagementConfig) assertTrue(c.supportsTakConfig) } - - @Test - fun deviceVersionParsingIsRobust() { - assertEquals(20712, DeviceVersion("2.7.12").asInt) - assertEquals(20712, DeviceVersion("2.7.12-beta").asInt) - assertEquals(30000, DeviceVersion("3.0.0").asInt) - assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions - assertEquals(0, DeviceVersion("invalid").asInt) - } - - @Test - fun deviceVersionComparisonIsCorrect() { - assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11")) - assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1")) - assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12")) - assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0")) - } - - */ } diff --git a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt similarity index 60% rename from core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt index 2f53cfa84..317c38aa8 100644 --- a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt @@ -16,62 +16,55 @@ */ package org.meshtastic.core.model -class ChannelOptionTest { - /* +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +class ChannelOptionTest { /** - * This test ensures that every `ModemPreset` defined in the protobufs has a corresponding entry in our - * `ChannelOption` enum. + * Ensures that every [ModemPreset] defined in the protobufs has a corresponding entry in [ChannelOption]. * - * If this test fails, it means a `ModemPreset` was added or changed in the firmware/protobufs, and you must update - * the `ChannelOption` enum to match. + * If this test fails, a ModemPreset was added or changed in the firmware/protobufs and you must update the + * [ChannelOption] enum to match. */ @Test - fun `ensure every ModemPreset is mapped in ChannelOption`() { - // Get all possible ModemPreset values. - val unmappedPresets = - Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" } + fun ensure_every_ModemPreset_is_mapped_in_ChannelOption() { + val unmappedPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" } unmappedPresets.forEach { preset -> - // Attempt to find the corresponding ChannelOption val channelOption = ChannelOption.from(preset) - - // Assert that a mapping exists, with a detailed failure message. assertNotNull( + channelOption, "Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " + "Please add a corresponding entry to the ChannelOption enum class.", - channelOption, ) } } /** - * This test ensures that there are no extra entries in `ChannelOption` that don't correspond to a valid - * `ModemPreset`. + * Ensures that there are no extra entries in [ChannelOption] that don't correspond to a valid [ModemPreset]. * - * If this test fails, it means a `ModemPreset` was removed from the protobufs, and you must remove the - * corresponding entry from the `ChannelOption` enum. + * If this test fails, a ModemPreset was removed from the protobufs and you must remove the corresponding entry from + * the [ChannelOption] enum. */ @Test - fun `ensure no extra mappings exist in ChannelOption`() { - val protoPresets = - Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet() + fun ensure_no_extra_mappings_exist_in_ChannelOption() { + val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet() val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet() assertEquals( - "The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " + - "Check for removed presets in protobufs or duplicate mappings in ChannelOption.", protoPresets, mappedPresets, + "The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " + + "Check for removed presets in protobufs or duplicate mappings in ChannelOption.", ) assertEquals( - "Each ChannelOption must map to a unique ModemPreset.", protoPresets.size, ChannelOption.entries.size, + "Each ChannelOption must map to a unique ModemPreset.", ) } - - */ } diff --git a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt similarity index 61% rename from core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt index 90efb65b5..9d0eb75a0 100644 --- a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt @@ -16,10 +16,11 @@ */ package org.meshtastic.core.model -class DeviceVersionTest { - /* +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeviceVersionTest { - /** make sure we match the python and device code behavior */ @Test fun canParse() { assertEquals(10000, DeviceVersion("1.0.0").asInt) @@ -28,5 +29,21 @@ class DeviceVersionTest { assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt) } - */ + @Test + fun twoPartVersionAppends_zero() { + assertEquals(20700, DeviceVersion("2.7").asInt) + } + + @Test + fun invalidVersionReturns_zero() { + assertEquals(0, DeviceVersion("invalid").asInt) + } + + @Test + fun comparisonIsCorrect() { + kotlin.test.assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11")) + kotlin.test.assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1")) + assertEquals(DeviceVersion("2.7.12"), DeviceVersion("2.7.12")) + kotlin.test.assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0")) + } } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 9e7e4f8df..adc6c1153 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -69,6 +69,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) implementation(libs.kotest.assertions) implementation(libs.kotest.property) } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt index 446b1a8b3..73e096da9 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.json.Json import org.meshtastic.core.model.MqttJsonPayload import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class MQTTRepositoryImplTest { @@ -67,8 +68,8 @@ class MQTTRepositoryImplTest { val json = Json { ignoreUnknownKeys = true } val jsonStr = json.encodeToString(MqttJsonPayload.serializer(), payload) - assert(jsonStr.contains("\"type\":\"text\"")) - assert(jsonStr.contains("\"from\":12345678")) - assert(jsonStr.contains("\"payload\":\"Hello World\"")) + assertTrue(jsonStr.contains("\"type\":\"text\"")) + assertTrue(jsonStr.contains("\"from\":12345678")) + assertTrue(jsonStr.contains("\"payload\":\"Hello World\"")) } } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt index 952d70c67..1b46232bf 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flowOn import org.koin.core.annotation.Single import java.io.IOException import java.net.InetAddress +import java.net.NetworkInterface import javax.jmdns.JmDNS import javax.jmdns.ServiceEvent import javax.jmdns.ServiceListener @@ -34,9 +35,14 @@ class JvmServiceDiscovery : ServiceDiscovery { @Suppress("TooGenericExceptionCaught") override val resolvedServices: Flow> = callbackFlow { + trySend(emptyList()) // Emit initial empty list so downstream combine() is not blocked + + val bindAddress = findLanAddress() ?: InetAddress.getLocalHost() + Logger.i { "JmDNS binding to ${bindAddress.hostAddress}" } + val jmdns = try { - JmDNS.create(InetAddress.getLocalHost()) + JmDNS.create(bindAddress) } catch (e: IOException) { Logger.e(e) { "Failed to create JmDNS" } null @@ -93,4 +99,24 @@ class JvmServiceDiscovery : ServiceDiscovery { } } .flowOn(Dispatchers.IO) + + companion object { + /** + * Finds a non-loopback, up, IPv4 LAN address for JmDNS to bind to. On many systems (especially Windows), + * [InetAddress.getLocalHost] resolves to `127.0.0.1` or `::1`, which prevents JmDNS from seeing multicast + * traffic on the actual LAN interface. + */ + @Suppress("TooGenericExceptionCaught", "LoopWithTooManyJumpStatements") + internal fun findLanAddress(): InetAddress? = try { + NetworkInterface.getNetworkInterfaces() + ?.toList() + .orEmpty() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.toList() } + .firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address } + } catch (e: Exception) { + Logger.w(e) { "Failed to enumerate network interfaces, falling back to getLocalHost()" } + null + } + } } diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt new file mode 100644 index 000000000..869628b1d --- /dev/null +++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt @@ -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 . + */ +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()}") + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index e69310d68..cd0641abb 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -23,7 +23,6 @@ import org.meshtastic.core.model.Position import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.NeighborInfo /** Interface for sending commands and packets to the mesh network. */ @Suppress("TooManyFunctions") @@ -43,15 +42,6 @@ interface CommandSender { /** Generates a new unique packet ID. */ fun generatePacketId(): Int - /** The latest neighbor info received from the connected radio. */ - var lastNeighborInfo: NeighborInfo? - - /** Start times of traceroute requests for duration calculation. */ - val tracerouteStartTimes: MutableMap - - /** Start times of neighbor info requests for duration calculation. */ - val neighborInfoStartTimes: MutableMap - /** Sets the session passkey for admin messages. */ fun setSessionPasskey(key: ByteString) diff --git a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt similarity index 53% rename from core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt index 1bac3fdb7..7b403aa36 100644 --- a/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt @@ -14,25 +14,20 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository -class PositionTest { - /* +/** + * Shared constants for the two-stage mesh handshake protocol. + * + * Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests + * the full node database. + * + * Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these. + */ +object HandshakeConstants { + /** Nonce sent in `want_config_id` to request config-only (Stage 1). */ + const val CONFIG_NONCE = 69420 - @Test - fun degGood() { - assertEquals(Position.degI(89.0), 890000000) - assertEquals(Position.degI(-89.0), -890000000) - - assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01) - assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01) - } - - @Test - fun givenPositionCreatedWithoutTime_thenTimeIsSet() { - val position = Position(37.1, 121.1, 35) - assertTrue(position.time != 0) - } - - */ + /** Nonce sent in `want_config_id` to request node info only (Stage 2). */ + const val NODE_INFO_NONCE = 69421 } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt index 1dd95b5d9..b9759ff59 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt @@ -18,12 +18,19 @@ package org.meshtastic.core.repository import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo /** Interface for handling neighbor info responses from the mesh. */ interface NeighborInfoHandler { /** Starts the neighbor info handler with the given coroutine scope. */ fun start(scope: CoroutineScope) + /** Records the start time for a neighbor info request. */ + fun recordStartTime(requestId: Int) + + /** The latest neighbor info received from the connected radio. */ + var lastNeighborInfo: NeighborInfo? + /** * Processes a neighbor info packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt new file mode 100644 index 000000000..51006763d --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt @@ -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 . + */ +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) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt index bff5f03a0..aa2e6318a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt @@ -25,6 +25,9 @@ interface TracerouteHandler { /** Starts the traceroute handler with the given coroutine scope. */ fun start(scope: CoroutineScope) + /** Records the start time for a traceroute request. */ + fun recordStartTime(requestId: Int) + /** * Processes a traceroute packet. * diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt deleted file mode 100644 index 1a22b8919..000000000 --- a/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt +++ /dev/null @@ -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 . - */ -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 = 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?) {} -} diff --git a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt b/core/ui/src/androidHostTest/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt similarity index 100% rename from core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt rename to core/ui/src/androidHostTest/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertHost.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertHost.kt new file mode 100644 index 000000000..205737657 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertHost.kt @@ -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 . + */ +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, + ) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PlaceholderScreen.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PlaceholderScreen.kt new file mode 100644 index 000000000..693405c57 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PlaceholderScreen.kt @@ -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 . + */ +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, + ) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt new file mode 100644 index 000000000..6a3e16dfe --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt @@ -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 . + */ +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) } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index a838b6a9f..75016084f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node @@ -27,6 +29,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig @KoinViewModel @@ -46,6 +49,28 @@ class ConnectionsViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo + /** + * Filtered [ourNodeInfo] that only emits when display-relevant fields change, preventing continuous recomposition + * from lastHeard/snr updates. + */ + val ourNodeForDisplay: StateFlow = + 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 = + radioConfigRepository.localConfigFlow + .map { it.lora?.region == Config.LoRaConfig.RegionCode.UNSET } + .distinctUntilChanged() + .stateInWhileSubscribed(initialValue = false) + private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value) val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow() diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 04abdf415..16ee48573 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -79,7 +79,7 @@ class UIViewModel( private val uiPreferencesDataSource: UiPreferencesDataSource, private val notificationManager: NotificationManager, packetRepository: PacketRepository, - private val alertManager: AlertManager, + val alertManager: AlertManager, ) : ViewModel() { private val _navigationDeepLink = MutableSharedFlow(replay = 1) @@ -121,8 +121,6 @@ class UIViewModel( _scrollToTopEventFlow.tryEmit(event) } - val currentAlert = alertManager.currentAlert - fun tracerouteMapAvailability(forwardRoute: List, returnRoute: List): TracerouteMapAvailability = evaluateTracerouteMapAvailability( forwardRoute = forwardRoute, diff --git a/core/ui/src/test/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt similarity index 81% rename from core/ui/src/test/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt index 02b3399e8..d221aeb39 100644 --- a/core/ui/src/test/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt @@ -16,17 +16,17 @@ */ package org.meshtastic.core.ui.util -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull class AlertManagerTest { private val alertManager = AlertManager() @Test - fun `showAlert updates currentAlert flow`() { + fun showAlert_updates_currentAlert_flow() { val title = "Test Title" val message = "Test Message" @@ -34,12 +34,12 @@ class AlertManagerTest { val alertData = alertManager.currentAlert.value assertNotNull(alertData) - assertEquals(title, alertData?.title) - assertEquals(message, alertData?.message) + assertEquals(title, alertData.title) + assertEquals(message, alertData.message) } @Test - fun `dismissAlert clears currentAlert flow`() { + fun dismissAlert_clears_currentAlert_flow() { alertManager.showAlert(title = "Title") assertNotNull(alertManager.currentAlert.value) @@ -48,7 +48,7 @@ class AlertManagerTest { } @Test - fun `onConfirm triggers and dismisses alert`() { + fun onConfirm_triggers_and_dismisses_alert() { var confirmClicked = false alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true }) @@ -59,7 +59,7 @@ class AlertManagerTest { } @Test - fun `onDismiss triggers and dismisses alert`() { + fun onDismiss_triggers_and_dismisses_alert() { var dismissClicked = false alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true }) diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 8bbc886af..9bd384614 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -72,6 +72,7 @@ compose.desktop { // App Icon & OS Specific Configurations macOS { iconFile.set(project.file("src/main/resources/icon.icns")) + minimumSystemVersion = "12.0" // TODO: To prepare for real distribution on macOS, you'll need to sign and notarize. // You can inject these from CI environment variables. // bundleID = "org.meshtastic.desktop" diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 7cfe4f918..10a50ac4e 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -3,6 +3,9 @@ -dontwarn com.squareup.wire.AndroidMessage** -dontwarn io.ktor.** +# Room KMP: preserve generated database constructor (required for R8/ProGuard) +-keep class * extends androidx.room.RoomDatabase { (); } + # Suppress ProGuard notes about duplicate resource files (common in Compose Desktop) -dontnote ** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index c32eae750..d92a33366 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -16,13 +16,6 @@ */ package org.meshtastic.desktop.navigation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -68,14 +61,3 @@ fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack) // Connections — shared screen connectionsGraph(backStack) } - -@Composable -internal fun PlaceholderScreen(name: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = name, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index f5224f63d..fff4df006 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -35,13 +35,12 @@ import androidx.navigation3.ui.NavDisplay import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.ui.component.AlertHost +import org.meshtastic.core.ui.component.SharedDialogs import org.meshtastic.core.ui.navigation.icon -import org.meshtastic.core.ui.qr.ScannedQrCodeDialog -import org.meshtastic.core.ui.share.SharedContactDialog import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph @@ -67,15 +66,15 @@ fun DesktopMainScreen( val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() - if (connectionState == ConnectionState.Connected) { - sharedContactRequested?.let { - SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() }) - } + SharedDialogs( + connectionState = connectionState, + sharedContactRequested = sharedContactRequested, + requestChannelSet = requestChannelSet, + onDismissSharedContact = { uiViewModel.clearSharedContactRequested() }, + onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() }, + ) - requestChannelSet?.let { newChannelSet -> - ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() }) - } - } + AlertHost(uiViewModel.alertManager) Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Row(modifier = Modifier.fillMaxSize()) { diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md index e00166729..1682c31e2 100644 --- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md +++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md @@ -14,9 +14,9 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver - Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`. - Don't assume feature/core `@Module` classes are active automatically. - Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`. -- **Don't use Koin 0.4.0's A1 Module Compile Safety checks for inverted dependencies.** -- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another. -- **Don't** expect Koin to inject default parameters automatically. Koin 0.4.0's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values. +- **Don't use Koin K2 Compiler Plugin's A1 Module Compile Safety checks for inverted dependencies.** +- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt` (uses typed `KoinGradleExtension`). We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another. +- **Don't** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values. ### Current code anchors (DI) diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index 3bdef3723..cf0a4aacf 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -120,6 +120,22 @@ Formerly found in 3 prefs files: Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains. +### B5. Cross-platform code deduplication *(resolved 2026-03-21)* + +Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components: + +| Component | Module | Eliminated from | +|---|---|---| +| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | +| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | +| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) | +| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` | +| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals | +| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` | +| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher | + +Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`. + --- ## C. DI Improvements @@ -203,12 +219,12 @@ Ordered by impact × effort: | Area | Previous | Current | Notes | |---|---:|---:|---| | Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | -| Shared feature/UI logic | 9.5/10 | **8.5/10** | All 7 KMP features; connections unified; Vico charts in commonMain | +| Shared feature/UI logic | 9.5/10 | **9/10** | All 7 KMP features; connections unified; cross-platform deduplication complete | | Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | | Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers | | DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | — | **8/10** | 131 commonTest + 89 platform-specific = 219 tests across all 7 features; core:testing established | +| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features | --- diff --git a/docs/kmp-status.md b/docs/kmp-status.md index aaec88b0f..ebaa3be9a 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-03-16 +> Last updated: 2026-03-21 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -72,7 +72,7 @@ Working Compose Desktop application with: | Area | Score | Notes | |---|---|---| | Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified | -| Shared feature/UI logic | **8.5/10** | All 7 KMP; feature:connections unified with dynamic transport detection | +| Shared feature/UI logic | **9/10** | All 7 KMP; feature:connections unified; cross-platform deduplication complete | | Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) | | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated | @@ -87,7 +87,7 @@ Working Compose Desktop application with: |---|---:| | Android-first structural KMP | ~100% | | Shared business logic | ~98% | -| Shared feature/UI | ~95% | +| Shared feature/UI | ~97% | | True multi-target readiness | ~85% | | "Add iOS without surprises" | ~100% | @@ -114,6 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | +| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~200 lines of duplicated code across Android/desktop | ## Navigation Parity Note diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index 91c37fec1..b0a3d738c 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -29,7 +29,6 @@ import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.DiscoveredService import org.meshtastic.core.network.repository.NetworkRepository -import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase( private val radioInterfaceService: RadioInterfaceService, private val usbManagerLazy: Lazy, ) : GetDiscoveredDevicesUseCase { - private val suffixLength = 4 private val macSuffixLength = 8 @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val recentMap = recentList.associateBy({ it.address }) { it.name } - tcpServices - .map { service -> - val address = "t${service.toAddressString()}" - val txtRecords = service.txt - val shortNameBytes = txtRecords["shortname"] - val idBytes = txtRecords["id"] - - val shortName = - shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic) - val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "") - var displayName = recentMap[address] ?: shortName - if (deviceId != null && (displayName.split("_").none { it == deviceId })) { - displayName += "_$deviceId" - } - DeviceListEntry.Tcp(displayName, address) - } - .sortedBy { it.name } + val defaultName = getString(Res.string.meshtastic) + processTcpServices(tcpServices, recentList, defaultName) } val usbDevicesFlow = @@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase( @Suppress("UNCHECKED_CAST", "MagicNumber") val recentList = args[5] as List + // Android-specific: BLE node matching by MAC suffix and Meshtastic short name val bleForUi = bondedBle .map { entry -> @@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase( } .sortedBy { it.name } + // Android-specific: USB node matching via shared helper val usbForUi = ( usbDevices + if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList() ) .map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - db.values.find { node -> - val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT) - suffix != null && - suffix.length >= suffixLength && - node.user.id.lowercase(Locale.ROOT).endsWith(suffix) - } - } else { - null - } - entry.copy(node = matchingNode) + entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager)) } - val discoveredTcpForUi = - processedTcp.map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress } - val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) } - db.values.find { node -> - node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId") - } - } else { - null - } - entry.copy(node = matchingNode) - } - + // Shared TCP logic via helpers + val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager) val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() - val recentTcpForUi = - recentList - .filterNot { discoveredTcpAddresses.contains(it.address) } - .map { DeviceListEntry.Tcp(it.name, it.address) } - .map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT) - db.values.find { node -> - suffix != null && - suffix.length >= suffixLength && - node.user.id.lowercase(Locale.ROOT).endsWith(suffix) - } - } else { - null - } - entry.copy(node = matchingNode) - } - .sortedBy { it.name } + val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager) DiscoveredDevices( bleDevices = bleForUi, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index e53653f9b..ecdaeb3c3 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -23,7 +23,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.NetworkRepository -import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode @@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase( private val networkRepository: NetworkRepository, private val usbScanner: UsbScanner? = null, ) : GetDiscoveredDevicesUseCase { - private val suffixLength = 4 - @Suppress("LongMethod", "CyclomaticComplexMethod") override fun invoke(showMock: Boolean): Flow { val nodeDb = nodeRepository.nodeDBbyNum val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList()) @@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val recentMap = recentList.associateBy({ it.address }) { it.name } - tcpServices - .map { service -> - val address = "t${service.toAddressString()}" - val txtRecords = service.txt - val shortNameBytes = txtRecords["shortname"] - val idBytes = txtRecords["id"] - - val shortName = - shortNameBytes?.let { it.decodeToString() } - ?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") - val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "") - var displayName = recentMap[address] ?: shortName - if (deviceId != null && (displayName.split("_").none { it == deviceId })) { - displayName += "_$deviceId" - } - DeviceListEntry.Tcp(displayName, address) - } - .sortedBy { it.name } + val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") + processTcpServices(tcpServices, recentList, defaultName) } return combine( @@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase( recentAddressesDataSource.recentAddresses, usbFlow, ) { db, processedTcp, resolved, recentList, usbList -> - val discoveredTcpForUi = - processedTcp.map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress } - val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() } - db.values.find { node -> - node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId") - } - } else { - null - } - entry.copy(node = matchingNode) - } - + val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager) val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() - - val recentTcpForUi = - recentList - .filterNot { discoveredTcpAddresses.contains(it.address) } - .map { DeviceListEntry.Tcp(it.name, it.address) } - .map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - val suffix = entry.name.split("_").lastOrNull()?.lowercase() - db.values.find { node -> - suffix != null && - suffix.length >= suffixLength && - node.user.id.lowercase().endsWith(suffix) - } - } else { - null - } - entry.copy(node = matchingNode) - } - .sortedBy { it.name } + val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager) DiscoveredDevices( discoveredTcpDevices = discoveredTcpForUi, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt new file mode 100644 index 000000000..b199882ea --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt @@ -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 . + */ +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, + recentAddresses: List, + defaultShortName: String = "Meshtastic", +): List { + 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, + nodeDb: Map, + resolvedServices: List, + databaseManager: DatabaseManager, +): List = 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, + discoveredAddresses: Set, + nodeDb: Map, + databaseManager: DatabaseManager, +): List = 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, + 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) } + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 3bec4b188..5590843ad 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.getNavRouteFrom import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog -import org.meshtastic.proto.Config import kotlin.uuid.ExperimentalUuidApi /** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */ @@ -102,25 +100,12 @@ fun ConnectionsScreen( onConfigNavigate: (Route) -> Unit, ) { val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() - val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle() val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle() val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() - - // Prevent continuous recomposition from lastHeard and snr updates on the node - val ourNode by - remember(connectionsViewModel.ourNodeInfo) { - connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new -> - old?.num == new?.num && - old?.user == new?.user && - old?.batteryLevel == new?.batteryLevel && - old?.voltage == new?.voltage && - old?.metadata?.firmware_version == new?.metadata?.firmware_version - } - } - .collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value) + val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle() + val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() - val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() @@ -192,63 +177,31 @@ fun ConnectionsScreen( Crossfade(targetState = uiState, label = "connection_state") { state -> when (state) { - 2 -> { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ourNode?.let { node -> - TitledCard(title = stringResource(Res.string.connected_device)) { - CurrentlyConnectedInfo( - node = node, - bleDevice = - bleDevices.find { it.fullAddress == selectedDevice } - as DeviceListEntry.Ble?, - onNavigateToNodeDetails = onNavigateToNodeDetails, - onClickDisconnect = { scanModel.disconnect() }, - ) - } - } + 2 -> + ConnectedDeviceContent( + ourNode = ourNode, + regionUnset = regionUnset, + selectedDevice = selectedDevice, + bleDevices = bleDevices, + onNavigateToNodeDetails = onNavigateToNodeDetails, + onClickDisconnect = { scanModel.disconnect() }, + onSetRegion = { + isWaiting = true + radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) + }, + ) - if (regionUnset && selectedDevice != "m") { - TitledCard(title = null) { - ListItem( - leadingIcon = Icons.Rounded.Language, - text = stringResource(Res.string.set_your_region), - ) { - isWaiting = true - radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) - } - } - } - } - } + 1 -> + ConnectingDeviceContent( + selectedDevice = selectedDevice, + bleDevices = bleDevices, + discoveredTcpDevices = discoveredTcpDevices, + recentTcpDevices = recentTcpDevices, + usbDevices = usbDevices, + onClickDisconnect = { scanModel.disconnect() }, + ) - 1 -> { - val selectedEntry = - bleDevices.find { it.fullAddress == selectedDevice } - ?: discoveredTcpDevices.find { it.fullAddress == selectedDevice } - ?: recentTcpDevices.find { it.fullAddress == selectedDevice } - ?: usbDevices.find { it.fullAddress == selectedDevice } - - val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device) - val address = selectedEntry?.address ?: selectedDevice - - TitledCard(title = stringResource(Res.string.connected_device)) { - ConnectingDeviceInfo( - deviceName = name, - deviceAddress = address, - onClickDisconnect = { scanModel.disconnect() }, - ) - } - } - - else -> { - Card(modifier = Modifier.fillMaxWidth()) { - EmptyStateContent( - imageVector = MeshtasticIcons.NoDevice, - text = stringResource(Res.string.no_device_selected), - modifier = Modifier.height(160.dp), - ) - } - } + else -> NoDeviceContent() } } @@ -334,3 +287,74 @@ fun ConnectionsScreen( } } } + +/** Content shown when connected to a device with node info available. */ +@Composable +private fun ConnectedDeviceContent( + ourNode: org.meshtastic.core.model.Node?, + regionUnset: Boolean, + selectedDevice: String, + bleDevices: List, + 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, + discoveredTcpDevices: List, + recentTcpDevices: List, + usbDevices: List, + 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), + ) + } +} diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt new file mode 100644 index 000000000..3191ac0a1 --- /dev/null +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt @@ -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 . + */ +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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) + } +} diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 11cd70588..c89cbf92f 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -45,16 +45,7 @@ kotlin { implementation(projects.core.di) } - androidMain.dependencies { - implementation(libs.androidx.datastore) - implementation(libs.androidx.datastore.preferences) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.androidx.navigation.common) - implementation(libs.androidx.savedstate.compose) - implementation(libs.androidx.savedstate.ktx) - implementation(libs.material) - } + androidMain.dependencies { implementation(libs.material) } androidUnitTest.dependencies { implementation(libs.junit) diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt index 3e0810824..d05bb1586 100644 --- a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt @@ -16,25 +16,11 @@ */ package org.meshtastic.feature.map.navigation -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import org.meshtastic.core.ui.component.PlaceholderScreen @Composable actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { // Desktop placeholder for now - org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map") -} - -@Composable -internal fun PlaceholderScreen(name: String) { - androidx.compose.foundation.layout.Box( - modifier = androidx.compose.ui.Modifier.fillMaxSize(), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - androidx.compose.material3.Text( - text = name, - style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + PlaceholderScreen(name = "Map") } diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index e5eb00bd5..0d673afd9 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -229,34 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() } } -private fun handleNodeAction( - action: NodeDetailAction, - uiState: NodeDetailUiState, - navigateToMessages: (String) -> Unit, - onNavigateUp: () -> Unit, - onNavigate: (Route) -> Unit, - viewModel: NodeDetailViewModel, -) { - when (action) { - is NodeDetailAction.Navigate -> onNavigate(action.route) - is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) - is NodeDetailAction.HandleNodeMenuAction -> { - when (val menuAction = action.action) { - is NodeMenuAction.DirectMessage -> { - val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) - navigateToMessages(route) - } - is NodeMenuAction.Remove -> { - viewModel.handleNodeMenuAction(menuAction) - onNavigateUp() - } - else -> viewModel.handleNodeMenuAction(menuAction) - } - } - else -> {} - } -} - @Preview(showBackground = true) @Composable private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt new file mode 100644 index 000000000..9ce025604 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -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 . + */ +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 -> {} + } +} diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt similarity index 85% rename from feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt rename to feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 7c3f0da43..3cfd8b36c 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -23,14 +23,13 @@ import dev.mokkery.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User +import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class NodeManagementActionsTest { @@ -51,7 +50,7 @@ class NodeManagementActionsTest { ) @Test - fun `requestRemoveNode shows confirmation alert`() { + fun requestRemoveNode_shows_confirmation_alert() { val node = Node(num = 123, user = User(long_name = "Test Node")) actions.requestRemoveNode(testScope, node) @@ -70,11 +69,4 @@ class NodeManagementActionsTest { ) } } - - @Test - fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) { - // This test might fail due to getString() not being mocked easily - // but let's see if we can at least get requestRemoveNode passing. - // Actually, if getString() fails, the coroutine will fail. - } } diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt similarity index 87% rename from feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt rename to feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt index 41d40b1f4..087792c66 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,17 +16,17 @@ */ package org.meshtastic.feature.node.metrics -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.Telemetry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class EnvironmentMetricsStateTest { @Test - fun `environmentMetricsForGraphing correctly calculates times`() { + fun environmentMetricsForGraphing_correctly_calculates_times() { val now = nowSeconds.toInt() val metrics = listOf( @@ -42,7 +42,7 @@ class EnvironmentMetricsStateTest { } @Test - fun `environmentMetricsForGraphing handles valid zero temperatures`() { + fun environmentMetricsForGraphing_handles_valid_zero_temperatures() { val now = nowSeconds.toInt() val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f))) val state = EnvironmentMetricsState(metrics) diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 0076ecf39..39a787457 100644 --- a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.meshtastic.core.navigation.Route import org.meshtastic.feature.node.compass.CompassViewModel -import org.meshtastic.feature.node.component.NodeMenuAction -import org.meshtastic.feature.node.model.NodeDetailAction @Composable actual fun NodeDetailScreen( @@ -45,24 +43,14 @@ actual fun NodeDetailScreen( uiState = uiState, modifier = modifier, onAction = { action -> - when (action) { - is NodeDetailAction.Navigate -> onNavigate(action.route) - is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) - is NodeDetailAction.HandleNodeMenuAction -> { - when (val menuAction = action.action) { - is NodeMenuAction.DirectMessage -> { - val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) - navigateToMessages(route) - } - is NodeMenuAction.Remove -> { - viewModel.handleNodeMenuAction(menuAction) - onNavigateUp() - } - else -> viewModel.handleNodeMenuAction(menuAction) - } - } - else -> {} - } + handleNodeAction( + action = action, + uiState = uiState, + navigateToMessages = navigateToMessages, + onNavigateUp = onNavigateUp, + onNavigate = onNavigate, + viewModel = viewModel, + ) }, onFirmwareSelect = { /* No-op on desktop for now */ }, onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) }, diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index 48f7b2989..621596e7d 100644 --- a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -16,21 +16,10 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import org.meshtastic.core.ui.component.PlaceholderScreen @Composable actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "Position Log", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + PlaceholderScreen(name = "Position Log") } diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt index c7129dd73..d923c988e 100644 --- a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt @@ -16,27 +16,11 @@ */ package org.meshtastic.feature.node.navigation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import org.meshtastic.core.ui.component.PlaceholderScreen @Composable actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) { // Desktop placeholder for now PlaceholderScreen(name = "Traceroute Map") } - -@Composable -internal fun PlaceholderScreen(name: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = name, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt b/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt similarity index 94% rename from feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt rename to feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt index e5c30d6c4..58cb7ad4c 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt +++ b/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.settings.radio import dev.mokkery.MockMode +import dev.mokkery.answering.returns import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock @@ -58,7 +59,7 @@ class CleanNodeDatabaseViewModelTest { } @Test - fun `getNodesToDelete updates state`() = runTest { + fun getNodesToDelete_updates_state() = runTest { val nodes = listOf(Node(num = 1), Node(num = 2)) everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes @@ -69,7 +70,7 @@ class CleanNodeDatabaseViewModelTest { } @Test - fun `cleanNodes calls useCase and clears state`() = runTest { + fun cleanNodes_calls_useCase_and_clears_state() = runTest { val nodes = listOf(Node(num = 1)) everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes viewModel.getNodesToDelete() diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 29a71be9a..7026f981e 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -20,7 +20,6 @@ import android.app.Activity import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate @@ -46,23 +44,18 @@ import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bottom_nav_settings -import org.meshtastic.core.resources.choose_theme -import org.meshtastic.core.resources.dynamic import org.meshtastic.core.resources.export_configuration import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating -import org.meshtastic.core.resources.theme_dark -import org.meshtastic.core.resources.theme_light -import org.meshtastic.core.resources.theme_system import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection +import org.meshtastic.feature.settings.component.ThemePickerDialog import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.radio.RadioConfigItemList @@ -269,28 +262,3 @@ private fun LanguagePickerDialog(onDismiss: () -> Unit) { }, ) } - -private enum class ThemeOption(val label: StringResource, val mode: Int) { - DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC), - LIGHT(label = Res.string.theme_light, mode = AppCompatDelegate.MODE_NIGHT_NO), - DARK(label = Res.string.theme_dark, mode = AppCompatDelegate.MODE_NIGHT_YES), - SYSTEM(label = Res.string.theme_system, mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM), -} - -@Composable -private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) { - MeshtasticDialog( - title = stringResource(Res.string.choose_theme), - onDismiss = onDismiss, - text = { - Column { - ThemeOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickTheme(option.mode) - onDismiss() - } - } - } - }, - ) -} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 9b894f0ad..c251b4d5e 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -57,32 +57,7 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L } context.contentResolver.openOutputStream(targetUri)?.use { os -> - OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> - logs.forEach { log -> - writer.write("${log.formattedReceivedDate} [${log.messageType}]\n") - writer.write(log.logMessage) - log.decodedPayload?.let { decodedPayload -> - if (decodedPayload.isNotBlank()) { - writer.write("\n\nDecoded Payload:\n{\n") - // Redact Decoded keys. - decodedPayload.lineSequence().forEach { line -> - var outputLine = line - val redacted = redactedKeys.firstOrNull { line.contains(it) } - if (redacted != null) { - val idx = line.indexOf(':') - if (idx != -1) { - outputLine = line.take(idx + 1) - outputLine += "" - } - } - writer.write(outputLine) - writer.write("\n") - } - writer.write("}\n\n") - } - } - } - } + OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) } } Logger.i { "MeshLog exported successfully to $targetUri" } withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) } @@ -91,5 +66,3 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") } } } - -private val redactedKeys = listOf("session_passkey", "private_key", "admin_key") diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ThemePickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ThemePickerDialog.kt new file mode 100644 index 000000000..d9285b45b --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ThemePickerDialog.kt @@ -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 . + */ +@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() + } + } + } + }, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index a23859bd6..1bbc815d2 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt new file mode 100644 index 000000000..6b7eec7eb --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt @@ -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 . + */ +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) { + 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) + "" else line +} diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt similarity index 78% rename from feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt index 2d5790d56..c6d94b815 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt @@ -17,15 +17,17 @@ package org.meshtastic.feature.settings.filter import dev.mokkery.MockMode +import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test +import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.repository.FilterPrefs import org.meshtastic.core.repository.MessageFilter +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals class FilterSettingsViewModelTest { @@ -34,23 +36,23 @@ class FilterSettingsViewModelTest { private lateinit var viewModel: FilterSettingsViewModel - @Before + @BeforeTest fun setUp() { - every { filterPrefs.filterEnabled.value } returns true - every { filterPrefs.filterWords.value } returns setOf("apple", "banana") + every { filterPrefs.filterEnabled } returns MutableStateFlow(true) + every { filterPrefs.filterWords } returns MutableStateFlow(setOf("apple", "banana")) viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter) } @Test - fun `setFilterEnabled updates prefs and state`() { + fun setFilterEnabled_updates_prefs_and_state() { viewModel.setFilterEnabled(false) verify { filterPrefs.setFilterEnabled(false) } assertEquals(false, viewModel.filterEnabled.value) } @Test - fun `addFilterWord updates prefs and rebuilds patterns`() { + fun addFilterWord_updates_prefs_and_rebuilds_patterns() { viewModel.addFilterWord("cherry") verify { filterPrefs.setFilterWords(any()) } @@ -59,7 +61,7 @@ class FilterSettingsViewModelTest { } @Test - fun `removeFilterWord updates prefs and rebuilds patterns`() { + fun removeFilterWord_updates_prefs_and_rebuilds_patterns() { viewModel.removeFilterWord("apple") verify { filterPrefs.setFilterWords(any()) } diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 4ac7f1b2f..84a4f80e4 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.DatabaseConstants import org.meshtastic.core.navigation.Route @@ -52,28 +51,23 @@ import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings -import org.meshtastic.core.resources.choose_theme import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary -import org.meshtastic.core.resources.dynamic import org.meshtastic.core.resources.info import org.meshtastic.core.resources.modules_already_unlocked import org.meshtastic.core.resources.modules_unlocked import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.resources.theme -import org.meshtastic.core.resources.theme_dark -import org.meshtastic.core.resources.theme_light -import org.meshtastic.core.resources.theme_system import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.core.ui.util.rememberShowToastResource import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection +import org.meshtastic.feature.settings.component.ThemePickerDialog import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.radio.RadioConfigItemList @@ -291,31 +285,6 @@ private fun DesktopAppVersionButton( } } -private enum class ThemeOption(val label: StringResource, val mode: Int) { - DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC), - LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO - DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES - SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM -} - -@Composable -private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) { - MeshtasticDialog( - title = stringResource(Res.string.choose_theme), - onDismiss = onDismiss, - text = { - Column { - ThemeOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickTheme(option.mode) - onDismiss() - } - } - } - }, - ) -} - /** * Supported languages — tag must match the CMP `values-` directory names. Empty tag means system default. * Display names are written in the native language for clarity. diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index bbe7962f3..5b63cc90a 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -54,32 +54,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List - OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> - logs.forEach { log -> - writer.write("${log.formattedReceivedDate} [${log.messageType}]\n") - writer.write(log.logMessage) - log.decodedPayload?.let { decodedPayload -> - if (decodedPayload.isNotBlank()) { - writer.write("\n\nDecoded Payload:\n{\n") - // Redact Decoded keys. - decodedPayload.lineSequence().forEach { line -> - var outputLine = line - val redacted = redactedKeys.firstOrNull { line.contains(it) } - if (redacted != null) { - val idx = line.indexOf(':') - if (idx != -1) { - outputLine = line.take(idx + 1) - outputLine += "" - } - } - writer.write(outputLine) - writer.write("\n") - } - writer.write("}\n\n") - } - } - } - } + OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) } } Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" } } catch (e: java.io.IOException) { @@ -92,5 +67,3 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List. - */ -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() } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04396c30b..120cd13ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,15 +9,12 @@ androidxTracing = "1.10.5" datastore = "1.2.1" glance = "1.2.0-rc01" lifecycle = "2.10.0" -jetbrains-lifecycle = "2.10.0" -navigation = "2.9.7" +jetbrains-lifecycle = "2.11.0-alpha01" navigation3 = "1.1.0-alpha04" -navigationevent = "1.0.1" +navigationevent = "1.1.0-alpha01" paging = "3.4.2" room = "3.0.0-alpha01" -savedstate = "1.4.0" koin = "4.2.0" -koin-annotations = "2.1.0" koin-plugin = "0.4.1" # Kotlin @@ -96,14 +93,11 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } # JetBrains KMP lifecycle (use in commonMain and androidMain) jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" } -# AndroidX Navigation (legacy nav-compose; Android-only nav utilities) -androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" } # JetBrains Navigation 3 currently publishes `navigation3-ui` (no separate `navigation3-runtime` artifact). # Both `jetbrains-navigation3-runtime` and `jetbrains-navigation3-ui` resolve to the same coordinate. jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } @@ -116,8 +110,6 @@ androidx-room-paging = { module = "androidx.room3:room3-paging", version.ref = " androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" } androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" } -androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" } -androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }