feat(test): Add comprehensive unit and instrumentation tests (#4260)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-19 19:52:03 -06:00 committed by GitHub
parent 4e2c429180
commit 45227fb142
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1270 additions and 307 deletions

View file

@ -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() }}

View file

@ -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() }}

View file

@ -247,6 +247,7 @@ dependencies {
androidTestImplementation(libs.hilt.android.testing)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}

View file

@ -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,

View file

@ -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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()) }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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.
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MeshPacket>()
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<MeshPacket>()) }
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<MeshPacket>()
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<MeshPacket>()) }
assertEquals(7, meshPacketSlot.captured.hopLimit)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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>(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<MyNodeEntity?>(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<ToRadio.Builder>()) }
}
@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,
)
}
}

View file

@ -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<NodeEntity>()
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<NodeEntity>()
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()) }
}
}

View file

@ -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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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.
}
}

View file

@ -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")

View file

@ -24,6 +24,14 @@ import org.gradle.kotlin.dsl.configure
fun Project.configureKover() {
extensions.configure<KoverProjectExtension> {
reports {
total {
xml {
onCheck.set(true)
}
html {
onCheck.set(true)
}
}
filters {
excludes {
// Exclude generated classes

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -1137,4 +1137,8 @@
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
<string name="mark_as_read">Mark as read</string>
<string name="now">Now</string>
<string name="add_channels_title">Add Channels</string>
<string name="add_channels_description">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.</string>
<string name="replace_channels_and_settings_title">Replace Channels &amp; Settings</string>
<string name="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string>
</resources>

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}
}
}

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,

View file

@ -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,
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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" }