From 45227fb142fe9abd51f9c7a97482c0b0375b138b Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Jan 2026 19:52:03 -0600
Subject: [PATCH] feat(test): Add comprehensive unit and instrumentation tests
(#4260)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.github/workflows/reusable-android-build.yml | 4 +-
.github/workflows/reusable-android-test.yml | 2 +-
app/build.gradle.kts | 1 +
.../mesh/repository/radio/TCPInterface.kt | 2 +-
.../mesh/service/MeshCommandSender.kt | 4 +-
.../repository/radio/StreamInterfaceTest.kt | 102 ++++++++++
.../mesh/repository/radio/TCPInterfaceTest.kt | 60 ++++++
.../service/FromRadioPacketHandlerTest.kt | 111 +++++++++++
.../service/MeshCommandSenderHopLimitTest.kt | 101 ++++++++++
.../mesh/service/MeshConnectionManagerTest.kt | 181 ++++++++++++++++++
.../mesh/service/MeshDataMapperTest.kt | 103 ++++++----
.../mesh/service/MeshMessageProcessorTest.kt | 101 ++++++++++
.../mesh/service/MeshNodeManagerTest.kt | 123 ++++++++----
.../mesh/service/PacketHandlerTest.kt | 106 ++++++++++
.../kotlin/org/meshtastic/buildlogic/Dokka.kt | 28 ---
.../kotlin/org/meshtastic/buildlogic/Kover.kt | 8 +
.../data/repository/MeshLogRepositoryTest.kt | 121 ++++++++++++
.../composeResources/values/strings.xml | 4 +
.../core/ui/qr/ScannedQrCodeDialogTest.kt | 164 ----------------
.../core/ui/qr/ScannedQrCodeDialog.kt | 55 +++---
.../firmware/ota/BleOtaTransportMtuTest.kt | 80 ++++++++
feature/node/build.gradle.kts | 8 +-
.../node/component/EnvironmentMetrics.kt | 6 +-
.../feature/node/metrics/MetricsViewModel.kt | 8 +-
.../metrics/EnvironmentMetricsStateTest.kt | 93 +++++++++
gradle/libs.versions.toml | 1 +
26 files changed, 1270 insertions(+), 307 deletions(-)
create mode 100644 app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
create mode 100644 app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
create mode 100644 core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
delete mode 100644 core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt
create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt
create mode 100644 feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt
diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml
index dbb3cc978..fd7f34064 100644
--- a/.github/workflows/reusable-android-build.yml
+++ b/.github/workflows/reusable-android-build.yml
@@ -69,7 +69,7 @@ jobs:
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
- name: Build and Run Unit Tests
- run: ./gradlew assembleDebug testGoogleDebugUnitTest testFdroidDebugUnitTest koverXmlReport --continue --scan
+ run: ./gradlew assembleDebug testDebugUnitTest testGoogleDebugUnitTest testFdroidDebugUnitTest koverXmlReport --continue --scan
env:
VERSION_CODE: ${{ env.VERSION_CODE }}
@@ -80,7 +80,7 @@ jobs:
slug: meshtastic/Meshtastic-Android
report_type: coverage
directory: .
- files: build/reports/kover/report.xml
+ files: "**/build/reports/kover/report.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml
index c906ab946..f89459fbf 100644
--- a/.github/workflows/reusable-android-test.yml
+++ b/.github/workflows/reusable-android-test.yml
@@ -111,7 +111,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage
slug: meshtastic/Meshtastic-Android
- files: build/reports/kover/report.xml
+ files: "**/build/reports/kover/report.xml"
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 783bb1fa8..a4e555857 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -247,6 +247,7 @@ dependencies {
androidTestImplementation(libs.hilt.android.testing)
testImplementation(libs.junit)
+ testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
index d37e73908..5985fb9bc 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
@@ -33,7 +33,7 @@ import java.net.InetAddress
import java.net.Socket
import java.net.SocketTimeoutException
-class TCPInterface
+open class TCPInterface
@AssistedInject
constructor(
service: RadioInterfaceService,
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 3cd8247b4..14688a602 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -99,7 +99,7 @@ constructor(
sessionPasskey.set(key)
}
- private fun getHopLimit(): Int = localConfig.value.lora.hopLimit
+ private fun getHopLimit(): Int = localConfig.value.lora.hopLimit.takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
val myNum = nodeManager?.myNodeNum ?: return 0
@@ -422,5 +422,7 @@ constructor(
private const val NODE_ID_PREFIX = "!"
private const val NODE_ID_START_INDEX = 1
private const val HEX_RADIX = 16
+
+ private const val DEFAULT_HOP_LIMIT = 3
}
}
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
new file mode 100644
index 000000000..b0ddc037e
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
@@ -0,0 +1,102 @@
+/*
+ * 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 com.geeksville.mesh.repository.radio
+
+import io.mockk.confirmVerified
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Test
+
+class StreamInterfaceTest {
+
+ private val service: RadioInterfaceService = mockk(relaxed = true)
+
+ // Concrete implementation for testing
+ private class TestStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
+ override fun sendBytes(p: ByteArray) {}
+
+ fun testReadChar(c: Byte) = readChar(c)
+ }
+
+ private val streamInterface = TestStreamInterface(service)
+
+ @Test
+ fun `readChar delivers a 1-byte packet`() {
+ // Header: START1, START2, LenMSB=0, LenLSB=1
+ val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x42)
+
+ packet.forEach { streamInterface.testReadChar(it) }
+
+ verify { service.handleFromRadio(byteArrayOf(0x42)) }
+ }
+
+ @Test
+ fun `readChar handles zero length packet`() {
+ // Header: START1, START2, LenMSB=0, LenLSB=0
+ val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x00)
+
+ packet.forEach { streamInterface.testReadChar(it) }
+
+ verify { service.handleFromRadio(byteArrayOf()) }
+ }
+
+ @Test
+ fun `readChar loses sync on invalid START2`() {
+ // START1, wrong START2, START1, START2, LenMSB=0, LenLSB=1, payload
+ val data = byteArrayOf(0x94.toByte(), 0x00, 0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x55)
+
+ data.forEach { streamInterface.testReadChar(it) }
+
+ verify { service.handleFromRadio(byteArrayOf(0x55)) }
+ }
+
+ @Test
+ fun `readChar handles multiple packets sequentially`() {
+ val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11)
+ val packet2 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x22)
+
+ packet1.forEach { streamInterface.testReadChar(it) }
+ packet2.forEach { streamInterface.testReadChar(it) }
+
+ verify { service.handleFromRadio(byteArrayOf(0x11)) }
+ verify { service.handleFromRadio(byteArrayOf(0x22)) }
+ confirmVerified(service)
+ }
+
+ @Test
+ fun `readChar handles large packet up to MAX_TO_FROM_RADIO_SIZE`() {
+ val size = 512
+ val payload = ByteArray(size) { it.toByte() }
+ val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), (size shr 8).toByte(), (size and 0xff).toByte())
+
+ header.forEach { streamInterface.testReadChar(it) }
+ payload.forEach { streamInterface.testReadChar(it) }
+
+ verify { service.handleFromRadio(payload) }
+ }
+
+ @Test
+ fun `readChar loses sync on overly large packet length`() {
+ // 513 bytes is > 512
+ val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x02, 0x01)
+
+ header.forEach { streamInterface.testReadChar(it) }
+
+ // Should ignore and reset, not expecting handleFromRadio
+ verify(exactly = 0) { service.handleFromRadio(any()) }
+ }
+}
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt
new file mode 100644
index 000000000..57f749fa9
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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 com.geeksville.mesh.repository.radio
+
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.proto.MeshProtos
+
+class TCPInterfaceTest {
+
+ private val service: RadioInterfaceService = mockk(relaxed = true)
+ private val dispatchers: CoroutineDispatchers = mockk(relaxed = true)
+
+ @Test
+ fun `keepAlive generates correct heartbeat bytes`() = runTest {
+ val address = "192.168.1.1:4403"
+ // We need a subclass to capture handleSendToRadio or sendBytes
+ val tcpInterface =
+ object : TCPInterface(service, dispatchers, address) {
+ var capturedBytes: ByteArray? = null
+
+ override fun handleSendToRadio(p: ByteArray) {
+ capturedBytes = p
+ }
+
+ // Override connect to prevent it from starting automatically in init
+ override fun connect() {}
+ }
+
+ tcpInterface.keepAlive()
+
+ val expectedHeartbeat =
+ MeshProtos.ToRadio.newBuilder()
+ .setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance())
+ .build()
+ .toByteArray()
+
+ assertArrayEquals("Heartbeat bytes should match", expectedHeartbeat, tcpInterface.capturedBytes)
+ }
+
+ // Since startConnect is private, we'd normally need reflection or to make a internal method.
+ // For now, testing keepAlive is a good first step for stability.
+}
diff --git a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt
new file mode 100644
index 000000000..548642ab6
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt
@@ -0,0 +1,111 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.ConfigProtos
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.fromRadio
+
+class FromRadioPacketHandlerTest {
+
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val router: MeshRouter = mockk(relaxed = true)
+ private val mqttManager: MeshMqttManager = mockk(relaxed = true)
+ private val packetHandler: PacketHandler = mockk(relaxed = true)
+ private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
+ private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true)
+ private val configHandler: MeshConfigHandler = mockk(relaxed = true)
+
+ private lateinit var handler: FromRadioPacketHandler
+
+ @Before
+ fun setUp() {
+ every { router.configFlowManager } returns configFlowManager
+ every { router.configHandler } returns configHandler
+ handler = FromRadioPacketHandler(serviceRepository, router, mqttManager, packetHandler, serviceNotifications)
+ }
+
+ @Test
+ fun `handleFromRadio routes MY_INFO to configFlowManager`() {
+ val myInfo = MeshProtos.MyNodeInfo.newBuilder().setMyNodeNum(1234).build()
+ val proto = fromRadio { this.myInfo = myInfo }
+
+ handler.handleFromRadio(proto)
+
+ verify { configFlowManager.handleMyInfo(myInfo) }
+ }
+
+ @Test
+ fun `handleFromRadio routes METADATA to configFlowManager`() {
+ val metadata = MeshProtos.DeviceMetadata.newBuilder().setFirmwareVersion("v1.0").build()
+ val proto = fromRadio { this.metadata = metadata }
+
+ handler.handleFromRadio(proto)
+
+ verify { configFlowManager.handleLocalMetadata(metadata) }
+ }
+
+ @Test
+ fun `handleFromRadio routes NODE_INFO to configFlowManager`() {
+ val nodeInfo = MeshProtos.NodeInfo.newBuilder().setNum(1234).build()
+ val proto = fromRadio { this.nodeInfo = nodeInfo }
+
+ handler.handleFromRadio(proto)
+
+ verify { configFlowManager.handleNodeInfo(nodeInfo) }
+ verify { serviceRepository.setStatusMessage(any()) }
+ }
+
+ @Test
+ fun `handleFromRadio routes QUEUESTATUS to packetHandler`() {
+ val queueStatus = MeshProtos.QueueStatus.newBuilder().setFree(5).build()
+ val proto = fromRadio { this.queueStatus = queueStatus }
+
+ handler.handleFromRadio(proto)
+
+ verify { packetHandler.handleQueueStatus(queueStatus) }
+ }
+
+ @Test
+ fun `handleFromRadio routes CONFIG to configHandler`() {
+ val config = ConfigProtos.Config.newBuilder().build()
+ val proto = fromRadio { this.config = config }
+
+ handler.handleFromRadio(proto)
+
+ verify { configHandler.handleDeviceConfig(config) }
+ }
+
+ @Test
+ fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
+ val notification = MeshProtos.ClientNotification.newBuilder().setReplyId(42).build()
+ val proto = fromRadio { this.clientNotification = notification }
+
+ handler.handleFromRadio(proto)
+
+ verify { serviceRepository.setClientNotification(notification) }
+ verify { serviceNotifications.showClientNotification(notification) }
+ verify { packetHandler.removeResponse(42, false) }
+ }
+}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
new file mode 100644
index 000000000..f9502b066
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.proto.ConfigProtos.Config
+import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
+import org.meshtastic.proto.MeshProtos.MeshPacket
+
+class MeshCommandSenderHopLimitTest {
+
+ private val packetHandler: PacketHandler = mockk(relaxed = true)
+ private val nodeManager = MeshNodeManager()
+ private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true)
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+
+ private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = CoroutineScope(testDispatcher)
+
+ private lateinit var commandSender: MeshCommandSender
+
+ @Before
+ fun setUp() {
+ val connectedFlow = MutableStateFlow(ConnectionState.Connected)
+ every { connectionStateHolder.connectionState } returns connectedFlow
+ every { radioConfigRepository.localConfigFlow } returns localConfigFlow
+
+ commandSender = MeshCommandSender(packetHandler, nodeManager, connectionStateHolder, radioConfigRepository)
+ commandSender.start(testScope)
+ }
+
+ @Test
+ fun `sendData uses default hop limit when config hop limit is zero`() = runTest(testDispatcher) {
+ val packet =
+ DataPacket(
+ to = DataPacket.ID_BROADCAST,
+ bytes = byteArrayOf(1, 2, 3),
+ dataType = 1, // PortNum.TEXT_MESSAGE_APP
+ )
+
+ val meshPacketSlot = slot()
+ every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
+
+ // Ensure localConfig has lora.hopLimit = 0
+ localConfigFlow.value =
+ LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(0)).build()
+
+ commandSender.sendData(packet)
+
+ verify(exactly = 1) { packetHandler.sendToRadio(any()) }
+
+ val capturedHopLimit = meshPacketSlot.captured.hopLimit
+ assertTrue("Hop limit should be greater than 0, but was $capturedHopLimit", capturedHopLimit > 0)
+ assertEquals(3, capturedHopLimit)
+ }
+
+ @Test
+ fun `sendData respects non-zero hop limit from config`() = runTest(testDispatcher) {
+ val packet = DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3), dataType = 1)
+
+ val meshPacketSlot = slot()
+ every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
+
+ localConfigFlow.value =
+ LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(7)).build()
+
+ commandSender.sendData(packet)
+
+ verify { packetHandler.sendToRadio(any()) }
+ assertEquals(7, meshPacketSlot.captured.hopLimit)
+ }
+}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
new file mode 100644
index 000000000..bf9d67aa6
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -0,0 +1,181 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.proto.ConfigProtos.Config
+import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
+import org.meshtastic.proto.MeshProtos.ToRadio
+
+class MeshConnectionManagerTest {
+
+ private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
+ private val connectionStateHolder = ConnectionStateHandler()
+ private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
+ private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
+ private val uiPrefs: UiPrefs = mockk(relaxed = true)
+ private val packetHandler: PacketHandler = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val locationManager: MeshLocationManager = mockk(relaxed = true)
+ private val mqttManager: MeshMqttManager = mockk(relaxed = true)
+ private val historyManager: MeshHistoryManager = mockk(relaxed = true)
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val commandSender: MeshCommandSender = mockk(relaxed = true)
+ private val nodeManager: MeshNodeManager = mockk(relaxed = true)
+ private val analytics: PlatformAnalytics = mockk(relaxed = true)
+ private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected)
+ private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private lateinit var manager: MeshConnectionManager
+
+ @Before
+ fun setUp() {
+ mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
+ coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
+ coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String"
+
+ every { radioInterfaceService.connectionState } returns radioConnectionState
+ every { radioConfigRepository.localConfigFlow } returns localConfigFlow
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ manager =
+ MeshConnectionManager(
+ radioInterfaceService,
+ connectionStateHolder,
+ serviceBroadcasts,
+ serviceNotifications,
+ uiPrefs,
+ packetHandler,
+ nodeRepository,
+ locationManager,
+ mqttManager,
+ historyManager,
+ radioConfigRepository,
+ commandSender,
+ nodeManager,
+ analytics,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
+ }
+
+ @Test
+ fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
+ manager.start(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ advanceUntilIdle()
+
+ assertEquals(
+ "State should be Connecting after radio Connected",
+ ConnectionState.Connecting,
+ connectionStateHolder.connectionState.value,
+ )
+ verify { serviceBroadcasts.broadcastConnection() }
+ verify { packetHandler.sendToRadio(any()) }
+ }
+
+ @Test
+ fun `Disconnected state stops services`() = runTest(testDispatcher) {
+ manager.start(backgroundScope)
+ // Transition to Connected first so that Disconnected actually does something
+ radioConnectionState.value = ConnectionState.Connected
+ advanceUntilIdle()
+
+ radioConnectionState.value = ConnectionState.Disconnected
+ advanceUntilIdle()
+
+ assertEquals(
+ "State should be Disconnected after radio Disconnected",
+ ConnectionState.Disconnected,
+ connectionStateHolder.connectionState.value,
+ )
+ verify { packetHandler.stopPacketQueue() }
+ verify { locationManager.stop() }
+ verify { mqttManager.stop() }
+ }
+
+ @Test
+ fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) {
+ // Power saving disabled + Role CLIENT
+ val config =
+ LocalConfig.newBuilder()
+ .apply {
+ powerBuilder.setIsPowerSaving(false)
+ deviceBuilder.setRole(Config.DeviceConfig.Role.CLIENT)
+ }
+ .build()
+ every { radioConfigRepository.localConfigFlow } returns flowOf(config)
+
+ manager.start(backgroundScope)
+ advanceUntilIdle()
+
+ radioConnectionState.value = ConnectionState.DeviceSleep
+ advanceUntilIdle()
+
+ assertEquals(
+ "State should be Disconnected when power saving is off",
+ ConnectionState.Disconnected,
+ connectionStateHolder.connectionState.value,
+ )
+ }
+
+ @Test
+ fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) {
+ // Power saving enabled
+ val config = LocalConfig.newBuilder().apply { powerBuilder.setIsPowerSaving(true) }.build()
+ every { radioConfigRepository.localConfigFlow } returns flowOf(config)
+
+ manager.start(backgroundScope)
+ advanceUntilIdle()
+
+ radioConnectionState.value = ConnectionState.DeviceSleep
+ advanceUntilIdle()
+
+ assertEquals(
+ "State should stay in DeviceSleep when power saving is on",
+ ConnectionState.DeviceSleep,
+ connectionStateHolder.connectionState.value,
+ )
+ }
+}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
index 256aa47d6..af8a7633e 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Meshtastic LLC
+ * Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,81 +17,114 @@
package com.geeksville.mesh.service
import com.google.protobuf.ByteString
+import io.mockk.every
+import io.mockk.mockk
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshProtos
-import org.meshtastic.proto.Portnums
-import org.meshtastic.proto.user
class MeshDataMapperTest {
- private lateinit var dataMapper: MeshDataMapper
- private lateinit var nodeManager: MeshNodeManager
+ private val nodeManager: MeshNodeManager = mockk()
+ private lateinit var mapper: MeshDataMapper
@Before
fun setUp() {
- nodeManager = MeshNodeManager() // Use internal testing constructor
- dataMapper = MeshDataMapper(nodeManager)
+ mapper = MeshDataMapper(nodeManager)
}
@Test
- fun `toNodeID returns broadcast ID for broadcast num`() {
- assertEquals(DataPacket.ID_BROADCAST, dataMapper.toNodeID(DataPacket.NODENUM_BROADCAST))
+ fun `toNodeID resolves broadcast correctly`() {
+ assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST))
}
@Test
- fun `toNodeID returns user ID from node database`() {
- val nodeNum = 123
- val userId = "!0000007b" // hex for 123
- nodeManager.nodeDBbyNodeNum[nodeNum] = NodeEntity(num = nodeNum, user = user { id = userId })
+ fun `toNodeID resolves known node correctly`() {
+ val nodeNum = 1234
+ val nodeId = "!1234abcd"
+ val nodeEntity = mockk()
+ every { nodeEntity.user.id } returns nodeId
+ every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns nodeEntity
- assertEquals(userId, dataMapper.toNodeID(nodeNum))
+ assertEquals(nodeId, mapper.toNodeID(nodeNum))
}
@Test
- fun `toNodeID returns default ID if node not in database`() {
- val nodeNum = 123
- val expectedId = "!0000007b"
- assertEquals(expectedId, dataMapper.toNodeID(nodeNum))
+ fun `toNodeID resolves unknown node to default ID`() {
+ val nodeNum = 1234
+ every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns null
+
+ assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), mapper.toNodeID(nodeNum))
}
@Test
- fun `toDataPacket returns null if no decoded payload`() {
+ fun `toDataPacket returns null when no decoded data`() {
val packet = MeshProtos.MeshPacket.newBuilder().build()
- assertNull(dataMapper.toDataPacket(packet))
+ assertNull(mapper.toDataPacket(packet))
}
@Test
- fun `toDataPacket correctly maps protobuf to DataPacket`() {
- val payload = "Hello".encodeToByteArray()
- val packet =
+ fun `toDataPacket maps basic fields correctly`() {
+ val nodeNum = 1234
+ val nodeId = "!1234abcd"
+ val nodeEntity = mockk()
+ every { nodeEntity.user.id } returns nodeId
+ every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
+
+ val proto =
MeshProtos.MeshPacket.newBuilder()
.apply {
- from = 1
- to = 2
- id = 12345
+ id = 42
+ from = nodeNum
+ to = DataPacket.NODENUM_BROADCAST
rxTime = 1600000000
+ rxSnr = 5.5f
+ rxRssi = -100
+ hopLimit = 3
+ hopStart = 3
decoded =
MeshProtos.Data.newBuilder()
.apply {
- portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
- setPayload(ByteString.copyFrom(payload))
+ portnumValue = 1 // TEXT_MESSAGE_APP
+ payload = ByteString.copyFrom("hello".toByteArray())
+ replyId = 123
}
.build()
}
.build()
- val dataPacket = dataMapper.toDataPacket(packet)
+ val result = mapper.toDataPacket(proto)
+ assertNotNull(result)
+ assertEquals(42, result!!.id)
+ assertEquals(nodeId, result.from)
+ assertEquals(DataPacket.ID_BROADCAST, result.to)
+ assertEquals(1600000000000L, result.time)
+ assertEquals(5.5f, result.snr)
+ assertEquals(-100, result.rssi)
+ assertEquals(1, result.dataType)
+ assertEquals("hello", result.bytes?.decodeToString())
+ assertEquals(123, result.replyId)
+ }
- assertEquals("!00000001", dataPacket?.from)
- assertEquals("!00000002", dataPacket?.to)
- assertEquals(12345, dataPacket?.id)
- assertEquals(1600000000000L, dataPacket?.time)
- assertEquals(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, dataPacket?.dataType)
- assertEquals("Hello", dataPacket?.bytes?.decodeToString())
+ @Test
+ fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
+ val proto =
+ MeshProtos.MeshPacket.newBuilder()
+ .apply {
+ pkiEncrypted = true
+ channel = 1
+ decoded = MeshProtos.Data.getDefaultInstance()
+ }
+ .build()
+
+ every { nodeManager.nodeDBbyNodeNum[any()] } returns null
+
+ val result = mapper.toDataPacket(proto)
+ assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
}
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
new file mode 100644
index 000000000..53e618e44
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.MeshProtos
+
+class MeshMessageProcessorTest {
+
+ private val nodeManager: MeshNodeManager = mockk(relaxed = true)
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val router: MeshRouter = mockk(relaxed = true)
+ private val fromRadioDispatcher: FromRadioPacketHandler = mockk(relaxed = true)
+ private val meshLogRepositoryLazy = dagger.Lazy { meshLogRepository }
+ private val dataHandler: MeshDataHandler = mockk(relaxed = true)
+
+ private val isNodeDbReady = MutableStateFlow(false)
+ private val testDispatcher = StandardTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ private lateinit var processor: MeshMessageProcessor
+
+ @Before
+ fun setUp() {
+ every { nodeManager.isNodeDbReady } returns isNodeDbReady
+ every { router.dataHandler } returns dataHandler
+ processor =
+ MeshMessageProcessor(nodeManager, serviceRepository, meshLogRepositoryLazy, router, fromRadioDispatcher)
+ processor.start(testScope)
+ }
+
+ @Test
+ fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) {
+ val packet =
+ MeshProtos.MeshPacket.newBuilder()
+ .apply {
+ id = 123
+ decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
+ }
+ .build()
+
+ // 1. Database is NOT ready
+ isNodeDbReady.value = false
+ testScheduler.runCurrent() // trigger start() onEach
+
+ processor.handleReceivedMeshPacket(packet, 999)
+
+ // Verify that handleReceivedData has NOT been called yet
+ verify(exactly = 0) { dataHandler.handleReceivedData(any(), any(), any(), any()) }
+
+ // 2. Database becomes ready
+ isNodeDbReady.value = true
+ testScheduler.runCurrent() // trigger onEach(true)
+
+ // Verify that handleReceivedData is now called with the buffered packet
+ verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 123 }, any(), any(), any()) }
+ }
+
+ @Test
+ fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) {
+ val packet =
+ MeshProtos.MeshPacket.newBuilder()
+ .apply {
+ id = 456
+ decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
+ }
+ .build()
+
+ isNodeDbReady.value = true
+ testScheduler.runCurrent()
+
+ processor.handleReceivedMeshPacket(packet, 999)
+
+ verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) }
+ }
+}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt
index c1de393db..1100362e0 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Meshtastic LLC
+ * Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,79 +16,122 @@
*/
package com.geeksville.mesh.service
+import io.mockk.mockk
import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.user
class MeshNodeManagerTest {
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
+ private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
+
private lateinit var nodeManager: MeshNodeManager
@Before
fun setUp() {
- nodeManager = MeshNodeManager() // Use internal testing constructor
+ nodeManager = MeshNodeManager(nodeRepository, serviceBroadcasts, serviceNotifications)
}
@Test
- fun `getOrCreateNodeInfo returns existing node`() {
- val node = NodeEntity(num = 1, longName = "Node 1", shortName = "N1")
- nodeManager.nodeDBbyNodeNum[1] = node
-
- val result = nodeManager.getOrCreateNodeInfo(1)
-
- assertEquals(node, result)
- }
-
- @Test
- fun `getOrCreateNodeInfo creates new node if not exists`() {
- val nodeNum = 456
+ fun `getOrCreateNodeInfo creates default user for unknown node`() {
+ val nodeNum = 1234
val result = nodeManager.getOrCreateNodeInfo(nodeNum)
assertNotNull(result)
assertEquals(nodeNum, result.num)
+ assertTrue(result.user.longName.startsWith("Meshtastic"))
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
}
@Test
- fun `getMyNodeInfo returns info from nodeDB when available`() {
- val myNum = 123
- nodeManager.myNodeNum = myNum
- val myNode =
- NodeEntity(
- num = myNum,
- user =
- user {
- id = "!0000007b"
- longName = "My Node"
- shortName = "MY"
- hwModel = MeshProtos.HardwareModel.TBEAM
- },
- )
- nodeManager.nodeDBbyNodeNum[myNum] = myNode
+ fun `handleReceivedUser preserves existing user if incoming is default`() {
+ val nodeNum = 1234
+ val existingUser = user {
+ id = "!12345678"
+ longName = "My Custom Name"
+ shortName = "MCN"
+ hwModel = MeshProtos.HardwareModel.TLORA_V2
+ }
- // This test will hit the null NodeRepository, so we might need to mock it if we want to test fallbacks.
- // But since we set myNodeNum and nodeDBbyNodeNum, it should return from memory if we are careful.
- // Actually getMyNodeInfo calls nodeRepository.myNodeInfo.value if memory lookup fails.
+ // Setup existing node
+ nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
+
+ val incomingDefaultUser = user {
+ id = "!12345678"
+ longName = "Meshtastic 5678"
+ shortName = "5678"
+ hwModel = MeshProtos.HardwareModel.UNSET
+ }
+
+ nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser)
+
+ val result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals("My Custom Name", result!!.user.longName)
+ assertEquals(MeshProtos.HardwareModel.TLORA_V2, result.user.hwModel)
}
@Test
- fun `clear resets state`() {
- nodeManager.myNodeNum = 123
- nodeManager.nodeDBbyNodeNum[1] = NodeEntity(num = 1)
- nodeManager.isNodeDbReady.value = true
+ fun `handleReceivedUser updates user if incoming is higher detail`() {
+ val nodeNum = 1234
+ val existingUser = user {
+ id = "!12345678"
+ longName = "Meshtastic 5678"
+ shortName = "5678"
+ hwModel = MeshProtos.HardwareModel.UNSET
+ }
+ nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
+
+ val incomingDetailedUser = user {
+ id = "!12345678"
+ longName = "Real User"
+ shortName = "RU"
+ hwModel = MeshProtos.HardwareModel.TLORA_V1
+ }
+
+ nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser)
+
+ val result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals("Real User", result!!.user.longName)
+ assertEquals(MeshProtos.HardwareModel.TLORA_V1, result.user.hwModel)
+ }
+
+ @Test
+ fun `handleReceivedPosition updates node position`() {
+ val nodeNum = 1234
+ val position =
+ MeshProtos.Position.newBuilder()
+ .apply {
+ latitudeI = 450000000
+ longitudeI = 900000000
+ }
+ .build()
+
+ nodeManager.handleReceivedPosition(nodeNum, 9999, position)
+
+ val result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertNotNull(result!!.position)
+ assertEquals(45.0, result.latitude, 0.0001)
+ assertEquals(90.0, result.longitude, 0.0001)
+ }
+
+ @Test
+ fun `clear resets internal state`() {
+ nodeManager.updateNodeInfo(1234) { it.longName = "Test" }
nodeManager.clear()
- assertNull(nodeManager.myNodeNum)
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
- assertFalse(nodeManager.isNodeDbReady.value)
+ assertTrue(nodeManager.nodeDBbyID.isEmpty())
+ assertNull(nodeManager.myNodeNum)
}
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
new file mode 100644
index 000000000..7e13caa76
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.proto.MeshProtos
+
+class PacketHandlerTest {
+
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
+ private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true)
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ private lateinit var handler: PacketHandler
+
+ @Before
+ fun setUp() {
+ handler =
+ PacketHandler(
+ dagger.Lazy { packetRepository },
+ serviceBroadcasts,
+ radioInterfaceService,
+ dagger.Lazy { meshLogRepository },
+ connectionStateHolder,
+ )
+ handler.start(testScope)
+ }
+
+ @Test
+ fun `sendToRadio with ToRadio Builder sends immediately`() {
+ val builder =
+ MeshProtos.ToRadio.newBuilder().apply { packet = MeshProtos.MeshPacket.newBuilder().setId(123).build() }
+
+ handler.sendToRadio(builder)
+
+ verify { radioInterfaceService.sendToRadio(any()) }
+ // Verify broadcast status ENROUTE (via status mapping) is not directly testable easily without more mocks,
+ // but we verify the call to radio service occurred.
+ }
+
+ @Test
+ fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) {
+ val packet = MeshProtos.MeshPacket.newBuilder().setId(456).build()
+ every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+
+ handler.sendToRadio(packet)
+ testScheduler.runCurrent()
+
+ verify { radioInterfaceService.sendToRadio(any()) }
+ }
+
+ @Test
+ fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) {
+ val packet = MeshProtos.MeshPacket.newBuilder().setId(789).build()
+ every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+
+ handler.sendToRadio(packet)
+ testScheduler.runCurrent()
+
+ val status =
+ MeshProtos.QueueStatus.newBuilder()
+ .apply {
+ meshPacketId = 789
+ res = 0 // Success
+ free = 1
+ }
+ .build()
+
+ handler.handleQueueStatus(status)
+ testScheduler.runCurrent()
+
+ // If it completed, the queue job should move to the next packet or finish.
+ // We can't easily check the deferred inside, but we can check if it cleared the internal wait.
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
index 6de949425..ebb7884e5 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
@@ -20,7 +20,6 @@ package org.meshtastic.buildlogic
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.dokka.gradle.DokkaExtension
-import java.io.File
import java.net.URI
fun Project.configureDokka() {
@@ -28,33 +27,6 @@ fun Project.configureDokka() {
// Use the full project path as the module name to ensure uniqueness
moduleName.set(project.path.removePrefix(":").replace(":", "-").ifEmpty { project.name })
- // Discover and register Android source sets (main + flavors)
- val registerAndroidSourceSets = {
- project.file("src").listFiles()
- ?.filter { it.isDirectory && !it.name.contains("test", ignoreCase = true) }
- ?.forEach { sourceDir ->
- val sourceSetName = sourceDir.name
- val ktDir = File(sourceDir, "kotlin")
- val javaDir = File(sourceDir, "java")
- if (ktDir.exists() || javaDir.exists()) {
- dokkaSourceSets.maybeCreate(sourceSetName).apply {
- if (ktDir.exists()) sourceRoots.from(ktDir)
- if (javaDir.exists()) sourceRoots.from(javaDir)
- suppress.set(false)
- }
- }
- }
- }
-
- pluginManager.withPlugin("com.android.library") {
- if (!plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
- registerAndroidSourceSets()
- }
- }
- pluginManager.withPlugin("com.android.application") {
- registerAndroidSourceSets()
- }
-
dokkaSourceSets.configureEach {
perPackageOption {
matchingRegex.set("hilt_aggregated_deps")
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
index 6375dec7b..35e5f29ec 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
@@ -24,6 +24,14 @@ import org.gradle.kotlin.dsl.configure
fun Project.configureKover() {
extensions.configure {
reports {
+ total {
+ xml {
+ onCheck.set(true)
+ }
+ html {
+ onCheck.set(true)
+ }
+ }
filters {
excludes {
// Exclude generated classes
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
new file mode 100644
index 000000000..8e0ff4dc3
--- /dev/null
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.meshtastic.core.database.DatabaseManager
+import org.meshtastic.core.database.MeshtasticDatabase
+import org.meshtastic.core.database.dao.MeshLogDao
+import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.proto.MeshProtos.Data
+import org.meshtastic.proto.MeshProtos.FromRadio
+import org.meshtastic.proto.MeshProtos.MeshPacket
+import org.meshtastic.proto.Portnums.PortNum
+import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
+import org.meshtastic.proto.TelemetryProtos.Telemetry
+import java.util.UUID
+
+class MeshLogRepositoryTest {
+
+ private val dbManager: DatabaseManager = mockk()
+ private val appDatabase: MeshtasticDatabase = mockk()
+ private val meshLogDao: MeshLogDao = mockk()
+ private val meshLogPrefs: MeshLogPrefs = mockk()
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs)
+
+ init {
+ every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
+ every { appDatabase.meshLogDao() } returns meshLogDao
+ }
+
+ @Test
+ fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
+ val zeroTemp = 0.0f
+ val envMetrics = EnvironmentMetrics.newBuilder().setTemperature(zeroTemp).build()
+ val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
+
+ val meshPacket =
+ MeshPacket.newBuilder()
+ .setDecoded(
+ Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
+ )
+ .build()
+
+ val meshLog =
+ MeshLog(
+ uuid = UUID.randomUUID().toString(),
+ message_type = "telemetry",
+ received_date = System.currentTimeMillis(),
+ raw_message = "",
+ fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
+ )
+
+ // Using reflection to test private method parseTelemetryLog
+ val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
+ method.isAccessible = true
+ val result = method.invoke(repository, meshLog) as Telemetry?
+
+ assertNotNull(result)
+ val resultMetrics = result?.environmentMetrics
+ assertNotNull(resultMetrics)
+ assertEquals(zeroTemp, resultMetrics?.temperature!!, 0.01f)
+ }
+
+ @Test
+ fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
+ val envMetrics = EnvironmentMetrics.newBuilder().build() // Temperature not set
+ val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
+
+ val meshPacket =
+ MeshPacket.newBuilder()
+ .setDecoded(
+ Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
+ )
+ .build()
+
+ val meshLog =
+ MeshLog(
+ uuid = UUID.randomUUID().toString(),
+ message_type = "telemetry",
+ received_date = System.currentTimeMillis(),
+ raw_message = "",
+ fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
+ )
+
+ val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
+ method.isAccessible = true
+ val result = method.invoke(repository, meshLog) as Telemetry?
+
+ assertNotNull(result)
+ val resultMetrics = result?.environmentMetrics
+
+ // Should be NaN as per repository logic for missing fields
+ assertEquals(Float.NaN, resultMetrics?.temperature!!, 0.01f)
+ }
+}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 3db9a108e..ce83b8e20 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -1137,4 +1137,8 @@
Estimated area: unknown accuracy
Mark as read
Now
+ Add Channels
+ The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.
+ Replace Channels & Settings
+ This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.
diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt
deleted file mode 100644
index 07cf076e2..000000000
--- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt
+++ /dev/null
@@ -1,164 +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.ui.qr
-
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import com.meshtastic.core.strings.getString
-import org.junit.Assert
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.model.Channel
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.accept
-import org.meshtastic.core.strings.add
-import org.meshtastic.core.strings.cancel
-import org.meshtastic.core.strings.new_channel_rcvd
-import org.meshtastic.core.strings.replace
-import org.meshtastic.proto.AppOnlyProtos.ChannelSet
-import org.meshtastic.proto.ConfigProtos
-import org.meshtastic.proto.channelSet
-import org.meshtastic.proto.channelSettings
-import org.meshtastic.proto.copy
-
-@RunWith(AndroidJUnit4::class)
-class ScannedQrCodeDialogTest {
-
- @get:Rule val composeTestRule = createComposeRule()
-
- private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
-
- private fun getRandomKey() = Channel.getRandomKey()
-
- private val channels = channelSet {
- settings.add(Channel.default.settings)
- loraConfig = Channel.default.loraConfig
- }
-
- private val incoming = channelSet {
- settings.addAll(
- listOf(
- Channel.default.settings,
- channelSettings {
- name = "2"
- psk = getRandomKey()
- },
- channelSettings {
- name = "3"
- psk = getRandomKey()
- },
- channelSettings {
- name = "admin"
- psk = getRandomKey()
- },
- ),
- )
- loraConfig =
- Channel.default.loraConfig.copy { modemPreset = ConfigProtos.Config.LoRaConfig.ModemPreset.SHORT_FAST }
- }
-
- private fun testScannedQrCodeDialog(onDismiss: () -> Unit = {}, onConfirm: (ChannelSet) -> Unit = {}) =
- composeTestRule.setContent {
- ScannedQrCodeDialog(channels = channels, incoming = incoming, onDismiss = onDismiss, onConfirm = onConfirm)
- }
-
- @Test
- fun testScannedQrCodeDialog_showsDialogTitle() {
- composeTestRule.apply {
- testScannedQrCodeDialog()
-
- // Verify that the dialog title is displayed
- onNodeWithText(getString(Res.string.new_channel_rcvd)).assertIsDisplayed()
- }
- }
-
- @Test
- fun testScannedQrCodeDialog_showsAddAndReplaceButtons() {
- composeTestRule.apply {
- testScannedQrCodeDialog()
-
- // Verify that the "Add" and "Replace" buttons are displayed
- onNodeWithText(getString(Res.string.add)).assertIsDisplayed()
- onNodeWithText(getString(Res.string.replace)).assertIsDisplayed()
- }
- }
-
- @Test
- fun testScannedQrCodeDialog_showsCancelAndAcceptButtons() {
- composeTestRule.apply {
- testScannedQrCodeDialog()
-
- // Verify the "Cancel" and "Accept" buttons are displayed
- onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed()
- onNodeWithText(getString(Res.string.accept)).assertIsDisplayed()
- }
- }
-
- @Test
- fun testScannedQrCodeDialog_clickCancelButton() {
- var onDismissClicked = false
- composeTestRule.apply {
- testScannedQrCodeDialog(onDismiss = { onDismissClicked = true })
-
- // Click the "Cancel" button
- onNodeWithText(getString(Res.string.cancel)).performClick()
- }
-
- // Verify onDismiss is called
- Assert.assertTrue(onDismissClicked)
- }
-
- @Test
- fun testScannedQrCodeDialog_replaceChannels() {
- var actualChannelSet: ChannelSet? = null
- composeTestRule.apply {
- testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
-
- // Click the "Accept" button
- onNodeWithText(getString(Res.string.accept)).performClick()
- }
-
- // Verify onConfirm is called with the correct ChannelSet
- Assert.assertEquals(incoming, actualChannelSet)
- }
-
- @Test
- fun testScannedQrCodeDialog_addChannels() {
- var actualChannelSet: ChannelSet? = null
- composeTestRule.apply {
- testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
-
- // Click the "Add" button then the "Accept" button
- onNodeWithText(getString(Res.string.add)).performClick()
- onNodeWithText(getString(Res.string.accept)).performClick()
- }
-
- // Verify onConfirm is called with the correct ChannelSet
- val expectedChannelSet =
- channels.copy {
- val list = LinkedHashSet(settings + incoming.settingsList)
- settings.clear()
- settings.addAll(list)
- }
- Assert.assertEquals(expectedChannelSet, actualChannelSet)
- }
-}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
index b4f2179ba..40526e30e 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
@@ -54,9 +54,11 @@ import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.accept
import org.meshtastic.core.strings.add
+import org.meshtastic.core.strings.add_channels_description
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.new_channel_rcvd
import org.meshtastic.core.strings.replace
+import org.meshtastic.core.strings.replace_channels_and_settings_description
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
@@ -124,7 +126,13 @@ fun ScannedQrCodeDialog(
val selectedChannelSet =
channelSet.copy {
- val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
+ // When adding (not replacing), include all previous channels + selected new channels.
+ // Since 'channelSet.settings' already contains the merged distinct list, we just filter it.
+ val result =
+ settings.filterIndexed { i, _ ->
+ val isExisting = !shouldReplace && i < channels.settingsCount
+ isExisting || channelSelections.getOrNull(i) == true
+ }
settings.clear()
settings.addAll(result)
}
@@ -153,30 +161,6 @@ fun ScannedQrCodeDialog(
if (current.usePreset != new.usePreset) {
changes.add("Use Preset: ${current.usePreset} -> ${new.usePreset}")
}
- if (current.txEnabled != new.txEnabled) {
- changes.add("Transmit Enabled: ${current.txEnabled} -> ${new.txEnabled}")
- }
- if (current.channelNum != new.channelNum) {
- changes.add("Channel Number: ${current.channelNum} -> ${new.channelNum}")
- }
- if (current.bandwidth != new.bandwidth) {
- changes.add("Bandwidth: ${current.bandwidth} -> ${new.bandwidth}")
- }
- if (current.codingRate != new.codingRate) {
- changes.add("Coding Rate: ${current.codingRate} -> ${new.codingRate}")
- }
- if (current.spreadFactor != new.spreadFactor) {
- changes.add("Spread Factor: ${current.spreadFactor} -> ${new.spreadFactor}")
- }
- if (current.sx126XRxBoostedGain != new.sx126XRxBoostedGain) {
- changes.add("RX Boosted Gain: ${current.sx126XRxBoostedGain} -> ${new.sx126XRxBoostedGain}")
- }
- if (current.overrideFrequency != new.overrideFrequency) {
- changes.add("Override Frequency: ${current.overrideFrequency} -> ${new.overrideFrequency}")
- }
- if (current.ignoreMqtt != new.ignoreMqtt) {
- changes.add("Ignore MQTT: ${current.ignoreMqtt} -> ${new.ignoreMqtt}")
- }
changes
} else {
@@ -204,13 +188,30 @@ fun ScannedQrCodeDialog(
style = MaterialTheme.typography.titleLarge,
)
}
+
+ item {
+ Text(
+ text =
+ stringResource(
+ if (shouldReplace) {
+ Res.string.replace_channels_and_settings_description
+ } else {
+ Res.string.add_channels_description
+ },
+ ),
+ modifier = Modifier.padding(bottom = 16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
itemsIndexed(channelSet.settingsList) { index, channel ->
+ val isExisting = !shouldReplace && index < channels.settingsCount
val channelObj = Channel(channel, channelSet.loraConfig)
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
- enabled = true,
- isSelected = channelSelections[index],
+ enabled = !isExisting,
+ isSelected = if (isExisting) true else channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
channelSelections[index] = it
diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt
new file mode 100644
index 000000000..02702d398
--- /dev/null
+++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.firmware.ota
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
+import no.nordicsemi.kotlin.ble.client.RemoteService
+import no.nordicsemi.kotlin.ble.client.android.CentralManager
+import no.nordicsemi.kotlin.ble.client.android.Peripheral
+import no.nordicsemi.kotlin.ble.client.android.ScanResult
+import no.nordicsemi.kotlin.ble.core.ConnectionState
+import org.junit.Test
+import java.util.UUID
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.toKotlinUuid
+
+private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
+private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
+private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
+class BleOtaTransportMtuTest {
+
+ private val centralManager: CentralManager = mockk(relaxed = true)
+ private val address = "00:11:22:33:44:55"
+ private val transport = BleOtaTransport(centralManager, address)
+
+ @Test
+ fun `connect requests MTU`() = runTest {
+ val peripheral: Peripheral = mockk(relaxed = true)
+ val otaChar: RemoteCharacteristic = mockk(relaxed = true)
+ val txChar: RemoteCharacteristic = mockk(relaxed = true)
+ val service: RemoteService = mockk(relaxed = true)
+ val scanResult: ScanResult = mockk(relaxed = true)
+
+ every { scanResult.peripheral } returns peripheral
+ every { centralManager.scan(any(), any()) } returns flowOf(scanResult)
+ every { peripheral.address } returns address
+ every { peripheral.state } returns MutableStateFlow(ConnectionState.Connected)
+ coEvery { peripheral.services(any()) } returns MutableStateFlow(listOf(service))
+ every { service.uuid } returns SERVICE_UUID.toKotlinUuid()
+ every { service.characteristics } returns listOf(otaChar, txChar)
+ every { otaChar.uuid } returns OTA_CHARACTERISTIC_UUID.toKotlinUuid()
+ every { txChar.uuid } returns TX_CHARACTERISTIC_UUID.toKotlinUuid()
+ coEvery { centralManager.connect(any(), any()) } returns Unit
+ every { txChar.subscribe() } returns MutableSharedFlow()
+
+ transport.connect().getOrThrow()
+
+ // Verify connect was called with automaticallyRequestHighestValueLength = true
+ coVerify {
+ centralManager.connect(
+ peripheral,
+ CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
+ )
+ }
+ }
+}
diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts
index 0e20a7817..a039d5a4d 100644
--- a/feature/node/build.gradle.kts
+++ b/feature/node/build.gradle.kts
@@ -55,6 +55,10 @@ dependencies {
googleImplementation(libs.location.services)
googleImplementation(libs.maps.compose)
-
- androidTestImplementation(libs.androidx.test.runner)
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.robolectric)
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index 0e74feb66..aa531ffb0 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +40,6 @@ import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
-import org.meshtastic.core.strings.R
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.dew_point
@@ -74,7 +72,7 @@ internal fun EnvironmentMetrics(
remember(node.environmentMetrics, isFahrenheit, displayUnits) {
buildList {
with(node.environmentMetrics) {
- if (hasTemperature()) {
+ if (!temperature.isNaN()) {
add(
VectorMetricInfo(
Res.string.temperature,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 215f12ff5..d94df2c3c 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -58,6 +58,7 @@ import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
+import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
@@ -240,11 +241,14 @@ constructor(
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.moduleConfig
+ val displayUnits = profile.config.display.units
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
- isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
- displayUnits = profile.config.display.units,
+ isFahrenheit =
+ moduleConfig.telemetry.environmentDisplayFahrenheit ||
+ (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
+ displayUnits = displayUnits,
)
}
}
diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt
new file mode 100644
index 000000000..5bec4745b
--- /dev/null
+++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.node.metrics
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshtastic.feature.node.model.TimeFrame
+import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
+import org.meshtastic.proto.TelemetryProtos.Telemetry
+
+class EnvironmentMetricsStateTest {
+
+ @Test
+ fun `environmentMetricsFiltered correctly calculates times`() {
+ val now = (System.currentTimeMillis() / 1000).toInt()
+ val metrics =
+ listOf(
+ Telemetry.newBuilder()
+ .setTime(now - 100)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
+ .build(),
+ Telemetry.newBuilder()
+ .setTime(now - 50)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(22f))
+ .build(),
+ Telemetry.newBuilder()
+ .setTime(now)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
+ .build(),
+ )
+ val state = EnvironmentMetricsState(metrics)
+ val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
+
+ assertEquals(now - 100, result.times.first)
+ assertEquals(now, result.times.second)
+ }
+
+ @Test
+ fun `environmentMetricsFiltered ignores invalid timestamps`() {
+ val now = (System.currentTimeMillis() / 1000).toInt()
+ val metrics =
+ listOf(
+ Telemetry.newBuilder()
+ .setTime(0)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
+ .build(),
+ Telemetry.newBuilder()
+ .setTime(now)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
+ .build(),
+ )
+ val state = EnvironmentMetricsState(metrics)
+ val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
+
+ // Only the valid timestamp should be considered for filters
+ assertEquals(now, result.times.first)
+ assertEquals(now, result.times.second)
+ assertEquals(1, result.metrics.size)
+ }
+
+ @Test
+ fun `environmentMetricsFiltered handles valid zero temperatures`() {
+ val now = (System.currentTimeMillis() / 1000).toInt()
+ val metrics =
+ listOf(
+ Telemetry.newBuilder()
+ .setTime(now)
+ .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(0.0f))
+ .build(),
+ )
+ val state = EnvironmentMetricsState(metrics)
+ val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
+
+ assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
+ assertEquals(0.0f, result.rightMinMax.first, 0.01f)
+ assertEquals(0.0f, result.rightMinMax.second, 0.01f)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3e9b9157b..4c29f22ab 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -141,6 +141,7 @@ androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }
junit = { module = "junit:junit", version = "4.13.2" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
# Other
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }