mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
Refactor command handling, enhance tests, and improve discovery logic (#4878)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -19,15 +19,16 @@ package org.meshtastic.core.ble
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BleRetryTest {
|
||||
|
||||
@Test
|
||||
fun `retryBleOperation returns immediately on success`() = runTest {
|
||||
fun retryBleOperation_returns_immediately_on_success() = runTest {
|
||||
var attempts = 0
|
||||
val result =
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
@@ -39,7 +40,7 @@ class BleRetryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryBleOperation retries on exception and succeeds`() = runTest {
|
||||
fun retryBleOperation_retries_on_exception_and_succeeds() = runTest {
|
||||
var attempts = 0
|
||||
val result =
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
@@ -54,32 +55,30 @@ class BleRetryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryBleOperation throws exception after max attempts`() = runTest {
|
||||
fun retryBleOperation_throws_exception_after_max_attempts() = runTest {
|
||||
var attempts = 0
|
||||
var caughtException: Exception? = null
|
||||
try {
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
attempts++
|
||||
throw RuntimeException("Persistent error")
|
||||
val ex =
|
||||
assertFailsWith<RuntimeException> {
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
attempts++
|
||||
throw RuntimeException("Persistent error")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
caughtException = e
|
||||
}
|
||||
|
||||
assertTrue(caughtException is RuntimeException)
|
||||
assertEquals("Persistent error", caughtException?.message)
|
||||
assertTrue(ex is RuntimeException)
|
||||
assertEquals("Persistent error", ex.message)
|
||||
assertEquals(3, attempts)
|
||||
}
|
||||
|
||||
@Test(expected = CancellationException::class)
|
||||
fun `retryBleOperation does not retry CancellationException`() = runTest {
|
||||
@Test
|
||||
fun retryBleOperation_does_not_retry_CancellationException() = runTest {
|
||||
var attempts = 0
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
attempts++
|
||||
throw CancellationException("Cancelled")
|
||||
assertFailsWith<CancellationException> {
|
||||
retryBleOperation(count = 3, delayMs = 10L) {
|
||||
attempts++
|
||||
throw CancellationException("Cancelled")
|
||||
}
|
||||
}
|
||||
// Test fails if it catches and doesn't rethrow, or if it retries.
|
||||
// It shouldn't reach the assertion below because the exception should be thrown immediately.
|
||||
assertEquals(1, attempts)
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,11 @@ import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Constants
|
||||
@@ -57,18 +59,16 @@ class CommandSenderImpl(
|
||||
private val packetHandler: PacketHandler,
|
||||
private val nodeManager: NodeManager,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val tracerouteHandler: TracerouteHandler,
|
||||
private val neighborInfoHandler: NeighborInfoHandler,
|
||||
) : CommandSender {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
||||
private val sessionPasskey = atomic(ByteString.EMPTY)
|
||||
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
|
||||
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
|
||||
|
||||
private val localConfig = MutableStateFlow(LocalConfig())
|
||||
private val channelSet = MutableStateFlow(ChannelSet())
|
||||
|
||||
override var lastNeighborInfo: NeighborInfo? = null
|
||||
|
||||
// We'll need a way to track connection state in shared code,
|
||||
// maybe via ServiceRepository or similar.
|
||||
// For now I'll assume it's injected or available.
|
||||
@@ -251,7 +251,7 @@ class CommandSenderImpl(
|
||||
}
|
||||
|
||||
override fun requestTraceroute(requestId: Int, destNum: Int) {
|
||||
tracerouteStartTimes[requestId] = nowMillis
|
||||
tracerouteHandler.recordStartTime(requestId)
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
@@ -302,11 +302,11 @@ class CommandSenderImpl(
|
||||
}
|
||||
|
||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
neighborInfoStartTimes[requestId] = nowMillis
|
||||
neighborInfoHandler.recordStartTime(requestId)
|
||||
val myNum = nodeManager.myNodeNum ?: 0
|
||||
if (destNum == myNum) {
|
||||
val neighborInfoToSend =
|
||||
lastNeighborInfo
|
||||
neighborInfoHandler.lastNeighborInfo
|
||||
?: run {
|
||||
val oneHour = 1.hours.inWholeMinutes.toInt()
|
||||
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
@@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl(
|
||||
private val packetHandler: PacketHandler,
|
||||
) : MeshConfigFlowManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private val configOnlyNonce = 69420
|
||||
private val nodeInfoNonce = 69421
|
||||
private val wantConfigDelay = 100L
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
@@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl(
|
||||
|
||||
override fun handleConfigComplete(configCompleteId: Int) {
|
||||
when (configCompleteId) {
|
||||
configOnlyNonce -> handleConfigOnlyComplete()
|
||||
nodeInfoNonce -> handleNodeInfoComplete()
|
||||
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
|
||||
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
|
||||
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
@@ -253,13 +254,13 @@ class MeshConnectionManagerImpl(
|
||||
}
|
||||
|
||||
override fun startConfigOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) }
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
||||
startHandshakeStallGuard(1, action)
|
||||
action()
|
||||
}
|
||||
|
||||
override fun startNodeInfoOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
||||
startHandshakeStallGuard(2, action)
|
||||
action()
|
||||
}
|
||||
@@ -340,8 +341,6 @@ class MeshConnectionManagerImpl(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONFIG_ONLY_NONCE = 69420
|
||||
private const val NODE_INFO_NONCE = 69421
|
||||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||
private val HANDSHAKE_TIMEOUT = 30.seconds
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okio.IOException
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
@@ -37,12 +35,10 @@ import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.Reaction
|
||||
import org.meshtastic.core.model.util.MeshDataMapper
|
||||
import org.meshtastic.core.model.util.SfppHasher
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
@@ -59,6 +55,7 @@ import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
@@ -75,8 +72,6 @@ import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.StatusMessage
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import org.meshtastic.proto.StoreForwardPlusPlus
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.proto.Waypoint
|
||||
@@ -107,17 +102,21 @@ class MeshDataHandlerImpl(
|
||||
private val configHandler: Lazy<MeshConfigHandler>,
|
||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||
private val commandSender: CommandSender,
|
||||
private val historyManager: HistoryManager,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
private val tracerouteHandler: TracerouteHandler,
|
||||
private val neighborInfoHandler: NeighborInfoHandler,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val messageFilter: MessageFilter,
|
||||
private val storeForwardHandler: StoreForwardPacketHandler,
|
||||
) : MeshDataHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val batteryMutex = Mutex()
|
||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
storeForwardHandler.start(scope)
|
||||
}
|
||||
|
||||
private val rememberDataType =
|
||||
@@ -191,11 +190,11 @@ class MeshDataHandlerImpl(
|
||||
}
|
||||
|
||||
PortNum.STORE_FORWARD_APP -> {
|
||||
handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
}
|
||||
|
||||
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
|
||||
handleStoreForwardPlusPlus(packet)
|
||||
storeForwardHandler.handleStoreForwardPlusPlus(packet)
|
||||
}
|
||||
|
||||
PortNum.ADMIN_APP -> {
|
||||
@@ -235,98 +234,6 @@ class MeshDataHandlerImpl(
|
||||
rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
|
||||
private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = StoreAndForward.ADAPTER.decode(payload)
|
||||
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ReturnCount")
|
||||
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val sfpp =
|
||||
try {
|
||||
StoreForwardPlusPlus.ADAPTER.decode(payload)
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
|
||||
return
|
||||
}
|
||||
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
|
||||
|
||||
when (sfpp.sfpp_message_type) {
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
|
||||
-> {
|
||||
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
|
||||
|
||||
// If it has a commit hash, it's already on the chain (Confirmed)
|
||||
// Otherwise it's still being routed via SF++ (Routing)
|
||||
val status =
|
||||
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
|
||||
|
||||
// Prefer a full 16-byte hash calculated from the message bytes if available
|
||||
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
|
||||
val hash =
|
||||
when {
|
||||
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
|
||||
!isFragment && sfpp.message.size != 0 -> {
|
||||
SfppHasher.computeMessageHash(
|
||||
encryptedPayload = sfpp.message.toByteArray(),
|
||||
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
|
||||
to =
|
||||
if (sfpp.encapsulated_to == 0) {
|
||||
DataPacket.NODENUM_BROADCAST
|
||||
} else {
|
||||
sfpp.encapsulated_to
|
||||
},
|
||||
from = sfpp.encapsulated_from,
|
||||
id = sfpp.encapsulated_id,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
Logger.d {
|
||||
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
|
||||
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
|
||||
}
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.updateSFPPStatus(
|
||||
packetId = sfpp.encapsulated_id,
|
||||
from = sfpp.encapsulated_from,
|
||||
to = sfpp.encapsulated_to,
|
||||
hash = hash,
|
||||
status = status,
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
myNodeNum = nodeManager.myNodeNum ?: 0,
|
||||
)
|
||||
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
||||
}
|
||||
}
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
|
||||
scope.handledLaunch {
|
||||
sfpp.message_hash.let {
|
||||
packetRepository.value.updateSFPPStatusByHash(
|
||||
hash = it.toByteArray(),
|
||||
status = MessageStatus.SFPP_CONFIRMED,
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
|
||||
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
|
||||
}
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
|
||||
Logger.i { "SF++: Node ${packet.from} is requesting links" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePaxCounter(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
@@ -559,52 +466,6 @@ class MeshDataHandlerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
|
||||
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
|
||||
// For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
|
||||
// In the original, it was used for logging.
|
||||
val h = s.history
|
||||
val lastRequest = h?.last_request ?: 0
|
||||
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
||||
when {
|
||||
s.stats != null -> {
|
||||
val text = s.stats.toString()
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
h != null -> {
|
||||
val text =
|
||||
"Total messages: ${h.history_messages}\n" +
|
||||
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
||||
"Last request: ${h.last_request}"
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
rememberDataPacket(u, myNodeNum)
|
||||
// historyManager call remains same
|
||||
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
|
||||
}
|
||||
s.heartbeat != null -> {
|
||||
val hb = s.heartbeat!!
|
||||
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
|
||||
}
|
||||
s.text != null -> {
|
||||
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
|
||||
dataPacket.to = DataPacket.ID_BROADCAST
|
||||
}
|
||||
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
|
||||
rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
||||
if (dataPacket.dataType !in rememberDataType) return
|
||||
val fromLocal =
|
||||
@@ -807,7 +668,5 @@ class MeshDataHandlerImpl(
|
||||
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
|
||||
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
|
||||
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
|
||||
private val batteryMutex = Mutex()
|
||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
@@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo
|
||||
class NeighborInfoHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val commandSender: CommandSender,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
) : NeighborInfoHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
override var lastNeighborInfo: NeighborInfo? = null
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
override fun recordStartTime(requestId: Int) {
|
||||
startTimes.update { it.put(requestId, nowMillis) }
|
||||
}
|
||||
|
||||
override fun handleNeighborInfo(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val ni = NeighborInfo.ADAPTER.decode(payload)
|
||||
@@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl(
|
||||
// Store the last neighbor info from our connected radio
|
||||
val from = packet.from
|
||||
if (from == nodeManager.myNodeNum) {
|
||||
commandSender.lastNeighborInfo = ni
|
||||
lastNeighborInfo = ni
|
||||
Logger.d { "Stored last neighbor info from connected radio" }
|
||||
}
|
||||
|
||||
@@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl(
|
||||
|
||||
// Format for UI response
|
||||
val requestId = packet.decoded?.request_id ?: 0
|
||||
val start = commandSender.neighborInfoStartTimes.remove(requestId)
|
||||
val start = startTimes.value[requestId]
|
||||
startTimes.update { it.remove(requestId) }
|
||||
|
||||
val neighbors =
|
||||
ni.neighbors.joinToString("\n") { n ->
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okio.IOException
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.util.SfppHasher
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import org.meshtastic.proto.StoreForwardPlusPlus
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */
|
||||
@Single
|
||||
class StoreForwardPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val historyManager: HistoryManager,
|
||||
private val dataHandler: Lazy<MeshDataHandler>,
|
||||
) : StoreForwardPacketHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = StoreAndForward.ADAPTER.decode(payload)
|
||||
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ReturnCount")
|
||||
override fun handleStoreForwardPlusPlus(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val sfpp =
|
||||
try {
|
||||
StoreForwardPlusPlus.ADAPTER.decode(payload)
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
|
||||
return
|
||||
}
|
||||
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
|
||||
|
||||
when (sfpp.sfpp_message_type) {
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
|
||||
-> handleLinkProvide(sfpp)
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp)
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
|
||||
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
|
||||
}
|
||||
|
||||
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
|
||||
Logger.i { "SF++: Node ${packet.from} is requesting links" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) {
|
||||
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
|
||||
|
||||
val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
|
||||
|
||||
val hash =
|
||||
when {
|
||||
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
|
||||
!isFragment && sfpp.message.size != 0 -> {
|
||||
SfppHasher.computeMessageHash(
|
||||
encryptedPayload = sfpp.message.toByteArray(),
|
||||
to =
|
||||
if (sfpp.encapsulated_to == 0) {
|
||||
DataPacket.NODENUM_BROADCAST
|
||||
} else {
|
||||
sfpp.encapsulated_to
|
||||
},
|
||||
from = sfpp.encapsulated_from,
|
||||
id = sfpp.encapsulated_id,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
Logger.d {
|
||||
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
|
||||
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
|
||||
}
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.updateSFPPStatus(
|
||||
packetId = sfpp.encapsulated_id,
|
||||
from = sfpp.encapsulated_from,
|
||||
to = sfpp.encapsulated_to,
|
||||
hash = hash,
|
||||
status = status,
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
myNodeNum = nodeManager.myNodeNum ?: 0,
|
||||
)
|
||||
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) {
|
||||
scope.handledLaunch {
|
||||
sfpp.message_hash.let {
|
||||
packetRepository.value.updateSFPPStatusByHash(
|
||||
hash = it.toByteArray(),
|
||||
status = MessageStatus.SFPP_CONFIRMED,
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
|
||||
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
|
||||
val h = s.history
|
||||
val lastRequest = h?.last_request ?: 0
|
||||
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
||||
when {
|
||||
s.stats != null -> {
|
||||
val text = s.stats.toString()
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
h != null -> {
|
||||
val text =
|
||||
"Total messages: ${h.history_messages}\n" +
|
||||
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
||||
"Last request: ${h.last_request}"
|
||||
val u =
|
||||
dataPacket.copy(
|
||||
bytes = text.encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
|
||||
}
|
||||
s.heartbeat != null -> {
|
||||
val hb = s.heartbeat!!
|
||||
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
|
||||
}
|
||||
s.text != null -> {
|
||||
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
|
||||
dataPacket.to = DataPacket.ID_BROADCAST
|
||||
}
|
||||
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,20 @@
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getFullTracerouteResponse
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
@@ -42,33 +44,43 @@ class TracerouteHandlerImpl(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val commandSender: CommandSender,
|
||||
) : TracerouteHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
|
||||
override fun recordStartTime(requestId: Int) {
|
||||
startTimes.update { it.put(requestId, nowMillis) }
|
||||
}
|
||||
|
||||
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
|
||||
// Decode the route discovery once — avoids triple protobuf decode
|
||||
val routeDiscovery = packet.fullRouteDiscovery ?: return
|
||||
val forwardRoute = routeDiscovery.route
|
||||
val returnRoute = routeDiscovery.route_back
|
||||
|
||||
// Require both directions for a "full" traceroute response
|
||||
if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return
|
||||
|
||||
val full =
|
||||
packet.getFullTracerouteResponse(
|
||||
routeDiscovery.getTracerouteResponse(
|
||||
getUser = { num ->
|
||||
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
|
||||
"${node.user.long_name} (${node.user.short_name})"
|
||||
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
|
||||
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
|
||||
?: "Unknown" // TODO: Use core:resources once available in core:data
|
||||
},
|
||||
headerTowards = "Route towards destination:",
|
||||
headerBack = "Route back to us:",
|
||||
) ?: return
|
||||
)
|
||||
|
||||
val requestId = packet.decoded?.request_id ?: 0
|
||||
|
||||
if (logUuid != null) {
|
||||
scope.handledLaunch {
|
||||
logInsertJob?.join()
|
||||
val routeDiscovery = packet.fullRouteDiscovery
|
||||
val forwardRoute = routeDiscovery?.route.orEmpty()
|
||||
val returnRoute = routeDiscovery?.route_back.orEmpty()
|
||||
val routeNodeNums = (forwardRoute + returnRoute).distinct()
|
||||
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
|
||||
val snapshotPositions =
|
||||
@@ -77,28 +89,27 @@ class TracerouteHandlerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
val start = commandSender.tracerouteStartTimes.remove(requestId)
|
||||
val start = startTimes.value[requestId]
|
||||
startTimes.update { it.remove(requestId) }
|
||||
val responseText =
|
||||
if (start != null) {
|
||||
val elapsedMs = nowMillis - start
|
||||
val seconds = elapsedMs / MILLIS_PER_SECOND
|
||||
Logger.i { "Traceroute $requestId complete in $seconds s" }
|
||||
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s"
|
||||
"$full\n\n$durationText"
|
||||
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
|
||||
} else {
|
||||
full
|
||||
}
|
||||
|
||||
val routeDiscovery = packet.fullRouteDiscovery
|
||||
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
|
||||
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
|
||||
|
||||
serviceRepository.setTracerouteResponse(
|
||||
TracerouteResponse(
|
||||
message = responseText,
|
||||
destinationNodeNum = destination,
|
||||
requestId = requestId,
|
||||
forwardRoute = routeDiscovery?.route.orEmpty(),
|
||||
returnRoute = routeDiscovery?.route_back.orEmpty(),
|
||||
forwardRoute = forwardRoute,
|
||||
returnRoute = returnRoute,
|
||||
logUuid = logUuid,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,15 +16,47 @@
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
class FromRadioPacketHandlerImplTest {
|
||||
/*
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshRouter
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.MqttClientProxyMessage
|
||||
import org.meshtastic.proto.MyNodeInfo
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
|
||||
|
||||
class FromRadioPacketHandlerImplTest {
|
||||
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
private val mqttManager: MqttManager = mock(MockMode.autofill)
|
||||
private val packetHandler: PacketHandler = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||
private val router: MeshRouter = mock(MockMode.autofill)
|
||||
|
||||
private lateinit var handler: FromRadioPacketHandlerImpl
|
||||
|
||||
@Before
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockkStatic("org.meshtastic.core.resources.GetStringKt")
|
||||
every { router.configFlowManager } returns configFlowManager
|
||||
every { router.configHandler } returns configHandler
|
||||
|
||||
handler =
|
||||
FromRadioPacketHandlerImpl(
|
||||
@@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { router.configFlowManager.handleMyInfo(myInfo) }
|
||||
verify { configFlowManager.handleMyInfo(myInfo) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest {
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { router.configFlowManager.handleLocalMetadata(metadata) }
|
||||
verify { configFlowManager.handleLocalMetadata(metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
|
||||
val nodeInfo = NodeInfo(num = 1234)
|
||||
val nodeInfo = ProtoNodeInfo(num = 1234)
|
||||
val proto = FromRadio(node_info = nodeInfo)
|
||||
|
||||
every { router.configFlowManager.newNodeCount } returns 1
|
||||
every { configFlowManager.newNodeCount } returns 1
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
|
||||
verify { configFlowManager.handleNodeInfo(nodeInfo) }
|
||||
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
|
||||
}
|
||||
|
||||
@@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest {
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { router.configFlowManager.handleConfigComplete(nonce) }
|
||||
verify { configFlowManager.handleConfigComplete(nonce) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest {
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { router.configHandler.handleDeviceConfig(config) }
|
||||
verify { configHandler.handleDeviceConfig(config) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
|
||||
val notification = ClientNotification(message = "test")
|
||||
val proto = FromRadio(clientNotification = notification)
|
||||
fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val proto = FromRadio(moduleConfig = moduleConfig)
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { serviceRepository.setClientNotification(notification) }
|
||||
verify { packetHandler.removeResponse(0, complete = false) }
|
||||
verify { configHandler.handleModuleConfig(moduleConfig) }
|
||||
}
|
||||
|
||||
*/
|
||||
@Test
|
||||
fun `handleFromRadio routes CHANNEL to configHandler`() {
|
||||
val channel = Channel(index = 0)
|
||||
val proto = FromRadio(channel = channel)
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { configHandler.handleChannel(channel) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
|
||||
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
|
||||
val proto = FromRadio(mqttClientProxyMessage = proxyMsg)
|
||||
|
||||
handler.handleFromRadio(proto)
|
||||
|
||||
verify { mqttManager.handleMqttProxyMessage(proxyMsg) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
|
||||
val notification = ClientNotification(message = "test")
|
||||
val proto = FromRadio(clientNotification = notification)
|
||||
|
||||
// Note: getString() from Compose Resources requires Skiko native lib which
|
||||
// is not available in headless JVM tests. We test the parts that don't trigger it.
|
||||
try {
|
||||
handler.handleFromRadio(proto)
|
||||
} catch (_: Throwable) {
|
||||
// Expected: Skiko can't load in headless JVM/native
|
||||
}
|
||||
|
||||
verify { serviceRepository.setClientNotification(notification) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class HistoryManagerImplTest {
|
||||
|
||||
|
||||
@@ -17,10 +17,25 @@
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.ContactSettings
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.MeshDataMapper
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
@@ -35,12 +50,23 @@ import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshDataHandlerTest {
|
||||
|
||||
private lateinit var handler: MeshDataHandlerImpl
|
||||
@@ -56,12 +82,15 @@ class MeshDataHandlerTest {
|
||||
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||
private val commandSender: CommandSender = mock(MockMode.autofill)
|
||||
private val historyManager: HistoryManager = mock(MockMode.autofill)
|
||||
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
||||
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
|
||||
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
|
||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
private val messageFilter: MessageFilter = mock(MockMode.autofill)
|
||||
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
@@ -79,13 +108,21 @@ class MeshDataHandlerTest {
|
||||
configHandler = lazy { configHandler },
|
||||
configFlowManager = lazy { configFlowManager },
|
||||
commandSender = commandSender,
|
||||
historyManager = historyManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
tracerouteHandler = tracerouteHandler,
|
||||
neighborInfoHandler = neighborInfoHandler,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
messageFilter = messageFilter,
|
||||
storeForwardHandler = storeForwardHandler,
|
||||
)
|
||||
handler.start(testScope)
|
||||
|
||||
// Default: mapper returns null for empty packets, which is the safe default
|
||||
every { dataMapper.toDataPacket(any()) } returns null
|
||||
// Stub commonly accessed properties to avoid NPE from autofill
|
||||
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,8 +131,582 @@ class MeshDataHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData processes packet`() {
|
||||
fun `handleReceivedData returns early when dataMapper returns null`() {
|
||||
val packet = MeshPacket()
|
||||
every { dataMapper.toDataPacket(packet) } returns null
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
// Should not broadcast if dataMapper returns null
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData does not broadcast for position from local node`() {
|
||||
val myNodeNum = 123
|
||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = DataPacket.nodeNumToDefaultId(myNodeNum),
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = position.encode().toByteString(),
|
||||
dataType = PortNum.POSITION_APP.value,
|
||||
time = 1000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
// Position from local node: shouldBroadcast stays as !fromUs = false
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData broadcasts for remote packets`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = DataPacket.nodeNumToDefaultId(remoteNum),
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = null,
|
||||
dataType = PortNum.PRIVATE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData tracks analytics`() {
|
||||
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!other",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = null,
|
||||
dataType = PortNum.PRIVATE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { analytics.track("num_data_receive", any()) }
|
||||
}
|
||||
|
||||
// --- Position handling ---
|
||||
|
||||
@Test
|
||||
fun `position packet delegates to nodeManager`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = position.encode().toByteString(),
|
||||
dataType = PortNum.POSITION_APP.value,
|
||||
time = 1000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
|
||||
}
|
||||
|
||||
// --- NodeInfo handling ---
|
||||
|
||||
@Test
|
||||
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = user.encode().toByteString(),
|
||||
dataType = PortNum.NODEINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nodeinfo packet from local node is ignored`() {
|
||||
val myNodeNum = 123
|
||||
val user = User(id = "!local", long_name = "Local", short_name = "L")
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = user.encode().toByteString(),
|
||||
dataType = PortNum.NODEINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// --- Paxcounter handling ---
|
||||
|
||||
@Test
|
||||
fun `paxcounter packet delegates to nodeManager`() {
|
||||
val remoteNum = 456
|
||||
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = pax.encode().toByteString(),
|
||||
dataType = PortNum.PAXCOUNTER_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
|
||||
}
|
||||
|
||||
// --- Traceroute handling ---
|
||||
|
||||
@Test
|
||||
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = "!local",
|
||||
bytes = byteArrayOf().toByteString(),
|
||||
dataType = PortNum.TRACEROUTE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- NeighborInfo handling ---
|
||||
|
||||
@Test
|
||||
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
|
||||
val ni = NeighborInfo(node_id = 456)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = ni.encode().toByteString(),
|
||||
dataType = PortNum.NEIGHBORINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { neighborInfoHandler.handleNeighborInfo(packet) }
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- Store-and-Forward handling ---
|
||||
|
||||
@Test
|
||||
fun `store forward packet delegates to storeForwardHandler`() {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = byteArrayOf().toByteString(),
|
||||
dataType = PortNum.STORE_FORWARD_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
|
||||
}
|
||||
|
||||
// --- Routing/ACK-NAK handling ---
|
||||
|
||||
@Test
|
||||
fun `routing packet with successful ack broadcasts and removes response`() {
|
||||
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = routing.encode().toByteString(),
|
||||
dataType = PortNum.ROUTING_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { packetHandler.removeResponse(99, complete = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `routing packet always broadcasts`() {
|
||||
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = routing.encode().toByteString(),
|
||||
dataType = PortNum.ROUTING_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- Telemetry handling ---
|
||||
|
||||
@Test
|
||||
fun `telemetry packet updates node via nodeManager`() {
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 2000,
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||
)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = telemetry.encode().toByteString(),
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
time = 2000000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { nodeManager.updateNode(456, any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `telemetry from local node also updates connectionManager`() {
|
||||
val myNodeNum = 123
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 2000,
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||
)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = telemetry.encode().toByteString(),
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
time = 2000000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { connectionManager.updateTelemetry(any()) }
|
||||
}
|
||||
|
||||
// --- Text message handling ---
|
||||
|
||||
@Test
|
||||
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 42,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf(
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate text message is not inserted again`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 42,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
// Return existing packet on duplicate check
|
||||
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
|
||||
packetRepository.insert(any(), any(), any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reaction handling ---
|
||||
|
||||
@Test
|
||||
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
|
||||
val emojiBytes = "👍".encodeToByteArray()
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 99,
|
||||
from = 456,
|
||||
to = 123,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = emojiBytes.toByteString(),
|
||||
reply_id = 42,
|
||||
emoji = 1,
|
||||
),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 99,
|
||||
from = "!remote",
|
||||
to = "!local",
|
||||
bytes = emojiBytes.toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.nodeDBbyNodeNum } returns
|
||||
mapOf(
|
||||
456 to Node(num = 456, user = User(id = "!remote")),
|
||||
123 to Node(num = 123, user = User(id = "!local")),
|
||||
)
|
||||
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
|
||||
every { nodeManager.myNodeNum } returns 123
|
||||
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insertReaction(any(), 123) }
|
||||
}
|
||||
|
||||
// --- Range test / detection sensor handling ---
|
||||
|
||||
@Test
|
||||
fun `range test packet is remembered as text message type`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 55,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 55,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "test".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.RANGE_TEST_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf(
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Range test should be remembered with TEXT_MESSAGE_APP dataType
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// --- Admin message handling ---
|
||||
|
||||
@Test
|
||||
fun `admin message sets session passkey`() {
|
||||
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
|
||||
val packet =
|
||||
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = admin.encode().toByteString(),
|
||||
dataType = PortNum.ADMIN_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { commandSender.setSessionPasskey(any()) }
|
||||
}
|
||||
|
||||
// --- Message filtering ---
|
||||
|
||||
@Test
|
||||
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 77,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "spam content".encodeToByteArray().toByteString(),
|
||||
),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 77,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "spam content".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
|
||||
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter("spam content", false) } returns true
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify insert was called with filtered = true (6th param)
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message from ignored node is filtered`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 88,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 88,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,22 +16,29 @@
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.meshtastic.core.repository.FilterPrefs
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MessageFilterImplTest {
|
||||
/*
|
||||
|
||||
private lateinit var filterPrefs: FilterPrefs
|
||||
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean>
|
||||
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>>
|
||||
private val filterEnabledFlow = MutableStateFlow(true)
|
||||
private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
|
||||
private lateinit var filterService: MessageFilterImpl
|
||||
|
||||
@Before
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
filterEnabledFlow = MutableStateFlow(true)
|
||||
filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
|
||||
filterPrefs = mockk {
|
||||
every { filterEnabled } returns filterEnabledFlow
|
||||
every { filterWords } returns filterWordsFlow
|
||||
}
|
||||
filterPrefs = mock(MockMode.autofill)
|
||||
every { filterPrefs.filterEnabled } returns filterEnabledFlow
|
||||
every { filterPrefs.filterWords } returns filterWordsFlow
|
||||
filterService = MessageFilterImpl(filterPrefs)
|
||||
}
|
||||
|
||||
@@ -92,6 +99,4 @@ class MessageFilterImplTest {
|
||||
filterService.rebuildPatterns()
|
||||
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -16,17 +16,36 @@
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
class NodeManagerImplTest {
|
||||
/*
|
||||
|
||||
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
|
||||
private lateinit var nodeManager: NodeManagerImpl
|
||||
|
||||
@Before
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
mockkStatic("org.meshtastic.core.resources.GetStringKt")
|
||||
|
||||
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
|
||||
}
|
||||
|
||||
@@ -63,8 +82,9 @@ class NodeManagerImplTest {
|
||||
@Test
|
||||
fun `handleReceivedUser updates user if incoming is higher detail`() {
|
||||
val nodeNum = 1234
|
||||
// Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString)
|
||||
val existingUser =
|
||||
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
|
||||
User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2)
|
||||
|
||||
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
|
||||
|
||||
@@ -81,29 +101,30 @@ class NodeManagerImplTest {
|
||||
@Test
|
||||
fun `handleReceivedPosition updates node position`() {
|
||||
val nodeNum = 1234
|
||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||
val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000)
|
||||
|
||||
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
|
||||
|
||||
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
||||
assertNotNull(result!!.position)
|
||||
assertEquals(45.0, result.latitude, 0.0001)
|
||||
assertEquals(90.0, result.longitude, 0.0001)
|
||||
assertNotNull(result)
|
||||
assertNotNull(result.position)
|
||||
assertEquals(450000000, result.position.latitude_i)
|
||||
assertEquals(900000000, result.position.longitude_i)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
|
||||
val nodeNum = 1234
|
||||
val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
|
||||
val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
|
||||
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
|
||||
|
||||
// Receive "zero" position with new satellite count
|
||||
val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
|
||||
val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
|
||||
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
|
||||
|
||||
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
||||
assertEquals(45.0, result!!.latitude, 0.0001)
|
||||
assertEquals(90.0, result.longitude, 0.0001)
|
||||
assertEquals(450000000, result!!.position.latitude_i)
|
||||
assertEquals(900000000, result.position.longitude_i)
|
||||
assertEquals(5, result.position.sats_in_view)
|
||||
assertEquals(1001, result.lastHeard)
|
||||
}
|
||||
@@ -111,13 +132,13 @@ class NodeManagerImplTest {
|
||||
@Test
|
||||
fun `handleReceivedPosition for local node ignores purely empty packets`() {
|
||||
val myNum = 1111
|
||||
val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
|
||||
val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
|
||||
|
||||
nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
|
||||
|
||||
val result = nodeManager.nodeDBbyNodeNum[myNum]
|
||||
// Should still be a default/unset node if it didn't exist, or shouldn't have position
|
||||
assertTrue(result == null || result.position.latitude_i == null)
|
||||
// Should still be null since the empty position for local node is ignored
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,11 +146,7 @@ class NodeManagerImplTest {
|
||||
val nodeNum = 1234
|
||||
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
|
||||
|
||||
val telemetry =
|
||||
org.meshtastic.proto.Telemetry(
|
||||
time = 2000,
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
|
||||
)
|
||||
val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50))
|
||||
|
||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||
|
||||
@@ -140,10 +157,7 @@ class NodeManagerImplTest {
|
||||
@Test
|
||||
fun `handleReceivedTelemetry updates device metrics`() {
|
||||
val nodeNum = 1234
|
||||
val telemetry =
|
||||
org.meshtastic.proto.Telemetry(
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
|
||||
)
|
||||
val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f))
|
||||
|
||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||
|
||||
@@ -157,10 +171,7 @@ class NodeManagerImplTest {
|
||||
fun `handleReceivedTelemetry updates environment metrics`() {
|
||||
val nodeNum = 1234
|
||||
val telemetry =
|
||||
org.meshtastic.proto.Telemetry(
|
||||
environment_metrics =
|
||||
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
|
||||
)
|
||||
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f))
|
||||
|
||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||
|
||||
@@ -180,5 +191,39 @@ class NodeManagerImplTest {
|
||||
assertNull(nodeManager.myNodeNum)
|
||||
}
|
||||
|
||||
*/
|
||||
@Test
|
||||
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
|
||||
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
|
||||
assertEquals(DataPacket.ID_BROADCAST, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toNodeID returns default hex ID for unknown node`() {
|
||||
val result = nodeManager.toNodeID(0x1234)
|
||||
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toNodeID returns user ID for known node`() {
|
||||
val nodeNum = 5678
|
||||
val userId = "!customid"
|
||||
nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) }
|
||||
val result = nodeManager.toNodeID(nodeNum)
|
||||
assertEquals(userId, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeByNodenum removes node from both maps`() {
|
||||
val nodeNum = 1234
|
||||
nodeManager.updateNode(nodeNum) {
|
||||
Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T"))
|
||||
}
|
||||
assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
|
||||
assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode"))
|
||||
|
||||
nodeManager.removeByNodenum(nodeNum)
|
||||
|
||||
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
|
||||
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ constructor(
|
||||
private val radioController: RadioController,
|
||||
) {
|
||||
/** Identifies nodes that match the cleanup criteria. */
|
||||
suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List<Node> {
|
||||
open suspend fun getNodesToClean(
|
||||
olderThanDays: Float,
|
||||
onlyUnknownNodes: Boolean,
|
||||
currentTimeSeconds: Long,
|
||||
): List<Node> {
|
||||
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
|
||||
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
|
||||
|
||||
@@ -49,7 +53,7 @@ constructor(
|
||||
}
|
||||
|
||||
/** Performs the cleanup of specified nodes. */
|
||||
suspend fun cleanNodes(nodeNums: List<Int>) {
|
||||
open suspend fun cleanNodes(nodeNums: List<Int>) {
|
||||
if (nodeNums.isEmpty()) return
|
||||
|
||||
nodeRepository.deleteNodes(nodeNums)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
class NodeInfoTest {
|
||||
/*
|
||||
|
||||
private val model = HardwareModel.ANDROID_SIM
|
||||
private val node =
|
||||
listOf(
|
||||
NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)),
|
||||
NodeInfo(5, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)),
|
||||
NodeInfo(6, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)),
|
||||
NodeInfo(7, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)),
|
||||
NodeInfo(8, MeshUser("+four", "User Four", "U4", model), Position(37.116, 121.1, 40)),
|
||||
)
|
||||
|
||||
private val currentDefaultLocale = LocaleListCompat.getDefault().get(0) ?: Locale.US
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Locale.setDefault(Locale.US)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Locale.setDefault(currentDefaultLocale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun distanceGood() {
|
||||
assertEquals(1111, node[1].distance(node[2]))
|
||||
assertEquals(111, node[1].distance(node[3]))
|
||||
assertEquals(1779, node[1].distance(node[4]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun distanceStrGood() {
|
||||
assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
|
||||
assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
|
||||
assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
|
||||
assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -76,7 +76,7 @@ private fun formatTraceroutePath(nodesList: List<String>, snrList: List<Int>): S
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun RouteDiscovery.getTracerouteResponse(
|
||||
fun RouteDiscovery.getTracerouteResponse(
|
||||
getUser: (nodeNum: Int) -> String,
|
||||
headerTowards: String = "Route traced toward destination:\n\n",
|
||||
headerBack: String = "Route traced back to us:\n\n",
|
||||
@@ -98,15 +98,6 @@ fun MeshPacket.getTracerouteResponse(
|
||||
headerBack: String = "Route traced back to us:\n\n",
|
||||
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack)
|
||||
|
||||
/** Returns a traceroute response string only when the result is complete (both directions). */
|
||||
fun MeshPacket.getFullTracerouteResponse(
|
||||
getUser: (nodeNum: Int) -> String,
|
||||
headerTowards: String = "Route traced toward destination:\n\n",
|
||||
headerBack: String = "Route traced back to us:\n\n",
|
||||
): String? = fullRouteDiscovery
|
||||
?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() }
|
||||
?.getTracerouteResponse(getUser, headerTowards, headerBack)
|
||||
|
||||
enum class TracerouteMapAvailability {
|
||||
Ok,
|
||||
MissingEndpoints,
|
||||
|
||||
@@ -16,81 +16,83 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
class CapabilitiesTest {
|
||||
/*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CapabilitiesTest {
|
||||
|
||||
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
|
||||
|
||||
@Test
|
||||
fun canMuteNodeRequiresV2718() {
|
||||
fun canMuteNode_requires_V2_7_18() {
|
||||
assertFalse(caps("2.7.15").canMuteNode)
|
||||
assertTrue(caps("2.7.18").canMuteNode)
|
||||
assertTrue(caps("2.8.0").canMuteNode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canRequestNeighborInfoIsCurrentlyDisabled() {
|
||||
fun canRequestNeighborInfo_is_currently_disabled() {
|
||||
assertFalse(caps("2.7.14").canRequestNeighborInfo)
|
||||
assertFalse(caps("3.0.0").canRequestNeighborInfo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canSendVerifiedContactsRequiresV2712() {
|
||||
fun canSendVerifiedContacts_requires_V2_7_12() {
|
||||
assertFalse(caps("2.7.11").canSendVerifiedContacts)
|
||||
assertTrue(caps("2.7.12").canSendVerifiedContacts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canToggleTelemetryEnabledRequiresV2712() {
|
||||
fun canToggleTelemetryEnabled_requires_V2_7_12() {
|
||||
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
|
||||
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canToggleUnmessageableRequiresV269() {
|
||||
fun canToggleUnmessageable_requires_V2_6_9() {
|
||||
assertFalse(caps("2.6.8").canToggleUnmessageable)
|
||||
assertTrue(caps("2.6.9").canToggleUnmessageable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsQrCodeSharingRequiresV268() {
|
||||
fun supportsQrCodeSharing_requires_V2_6_8() {
|
||||
assertFalse(caps("2.6.7").supportsQrCodeSharing)
|
||||
assertTrue(caps("2.6.8").supportsQrCodeSharing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsSecondaryChannelLocationRequiresV2610() {
|
||||
fun supportsSecondaryChannelLocation_requires_V2_6_10() {
|
||||
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
|
||||
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsStatusMessageRequiresV2717() {
|
||||
fun supportsStatusMessage_requires_V2_7_17() {
|
||||
assertFalse(caps("2.7.16").supportsStatusMessage)
|
||||
assertTrue(caps("2.7.17").supportsStatusMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsTrafficManagementConfigRequiresV300() {
|
||||
fun supportsTrafficManagementConfig_requires_V3_0_0() {
|
||||
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
|
||||
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsTakConfigRequiresV2719() {
|
||||
fun supportsTakConfig_requires_V2_7_19() {
|
||||
assertFalse(caps("2.7.18").supportsTakConfig)
|
||||
assertTrue(caps("2.7.19").supportsTakConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportsEsp32OtaRequiresV2718() {
|
||||
fun supportsEsp32Ota_requires_V2_7_18() {
|
||||
assertFalse(caps("2.7.17").supportsEsp32Ota)
|
||||
assertTrue(caps("2.7.18").supportsEsp32Ota)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullFirmwareReturnsAllFalse() {
|
||||
fun nullFirmware_returns_all_false() {
|
||||
val c = caps(null)
|
||||
assertFalse(c.canMuteNode)
|
||||
assertFalse(c.canRequestNeighborInfo)
|
||||
@@ -106,7 +108,7 @@ class CapabilitiesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
|
||||
fun forceEnableAll_returns_true_regardless_of_version() {
|
||||
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
|
||||
assertTrue(c.canMuteNode)
|
||||
assertTrue(c.canSendVerifiedContacts)
|
||||
@@ -114,23 +116,4 @@ class CapabilitiesTest {
|
||||
assertTrue(c.supportsTrafficManagementConfig)
|
||||
assertTrue(c.supportsTakConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceVersionParsingIsRobust() {
|
||||
assertEquals(20712, DeviceVersion("2.7.12").asInt)
|
||||
assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
|
||||
assertEquals(30000, DeviceVersion("3.0.0").asInt)
|
||||
assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
|
||||
assertEquals(0, DeviceVersion("invalid").asInt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceVersionComparisonIsCorrect() {
|
||||
assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
|
||||
assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
|
||||
assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
|
||||
assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -16,62 +16,55 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
class ChannelOptionTest {
|
||||
/*
|
||||
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class ChannelOptionTest {
|
||||
|
||||
/**
|
||||
* This test ensures that every `ModemPreset` defined in the protobufs has a corresponding entry in our
|
||||
* `ChannelOption` enum.
|
||||
* Ensures that every [ModemPreset] defined in the protobufs has a corresponding entry in [ChannelOption].
|
||||
*
|
||||
* If this test fails, it means a `ModemPreset` was added or changed in the firmware/protobufs, and you must update
|
||||
* the `ChannelOption` enum to match.
|
||||
* If this test fails, a ModemPreset was added or changed in the firmware/protobufs and you must update the
|
||||
* [ChannelOption] enum to match.
|
||||
*/
|
||||
@Test
|
||||
fun `ensure every ModemPreset is mapped in ChannelOption`() {
|
||||
// Get all possible ModemPreset values.
|
||||
val unmappedPresets =
|
||||
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
|
||||
fun ensure_every_ModemPreset_is_mapped_in_ChannelOption() {
|
||||
val unmappedPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
|
||||
|
||||
unmappedPresets.forEach { preset ->
|
||||
// Attempt to find the corresponding ChannelOption
|
||||
val channelOption = ChannelOption.from(preset)
|
||||
|
||||
// Assert that a mapping exists, with a detailed failure message.
|
||||
assertNotNull(
|
||||
channelOption,
|
||||
"Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " +
|
||||
"Please add a corresponding entry to the ChannelOption enum class.",
|
||||
channelOption,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This test ensures that there are no extra entries in `ChannelOption` that don't correspond to a valid
|
||||
* `ModemPreset`.
|
||||
* Ensures that there are no extra entries in [ChannelOption] that don't correspond to a valid [ModemPreset].
|
||||
*
|
||||
* If this test fails, it means a `ModemPreset` was removed from the protobufs, and you must remove the
|
||||
* corresponding entry from the `ChannelOption` enum.
|
||||
* If this test fails, a ModemPreset was removed from the protobufs and you must remove the corresponding entry from
|
||||
* the [ChannelOption] enum.
|
||||
*/
|
||||
@Test
|
||||
fun `ensure no extra mappings exist in ChannelOption`() {
|
||||
val protoPresets =
|
||||
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
|
||||
fun ensure_no_extra_mappings_exist_in_ChannelOption() {
|
||||
val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
|
||||
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
|
||||
|
||||
assertEquals(
|
||||
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
|
||||
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
|
||||
protoPresets,
|
||||
mappedPresets,
|
||||
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
|
||||
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Each ChannelOption must map to a unique ModemPreset.",
|
||||
protoPresets.size,
|
||||
ChannelOption.entries.size,
|
||||
"Each ChannelOption must map to a unique ModemPreset.",
|
||||
)
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -16,10 +16,11 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
class DeviceVersionTest {
|
||||
/*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DeviceVersionTest {
|
||||
|
||||
/** make sure we match the python and device code behavior */
|
||||
@Test
|
||||
fun canParse() {
|
||||
assertEquals(10000, DeviceVersion("1.0.0").asInt)
|
||||
@@ -28,5 +29,21 @@ class DeviceVersionTest {
|
||||
assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt)
|
||||
}
|
||||
|
||||
*/
|
||||
@Test
|
||||
fun twoPartVersionAppends_zero() {
|
||||
assertEquals(20700, DeviceVersion("2.7").asInt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidVersionReturns_zero() {
|
||||
assertEquals(0, DeviceVersion("invalid").asInt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun comparisonIsCorrect() {
|
||||
kotlin.test.assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
|
||||
kotlin.test.assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
|
||||
assertEquals(DeviceVersion("2.7.12"), DeviceVersion("2.7.12"))
|
||||
kotlin.test.assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ kotlin {
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.turbine)
|
||||
implementation(libs.kotest.assertions)
|
||||
implementation(libs.kotest.property)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.serialization.json.Json
|
||||
import org.meshtastic.core.model.MqttJsonPayload
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MQTTRepositoryImplTest {
|
||||
|
||||
@@ -67,8 +68,8 @@ class MQTTRepositoryImplTest {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val jsonStr = json.encodeToString(MqttJsonPayload.serializer(), payload)
|
||||
|
||||
assert(jsonStr.contains("\"type\":\"text\""))
|
||||
assert(jsonStr.contains("\"from\":12345678"))
|
||||
assert(jsonStr.contains("\"payload\":\"Hello World\""))
|
||||
assertTrue(jsonStr.contains("\"type\":\"text\""))
|
||||
assertTrue(jsonStr.contains("\"from\":12345678"))
|
||||
assertTrue(jsonStr.contains("\"payload\":\"Hello World\""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import org.koin.core.annotation.Single
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import javax.jmdns.JmDNS
|
||||
import javax.jmdns.ServiceEvent
|
||||
import javax.jmdns.ServiceListener
|
||||
@@ -34,9 +35,14 @@ class JvmServiceDiscovery : ServiceDiscovery {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override val resolvedServices: Flow<List<DiscoveredService>> =
|
||||
callbackFlow {
|
||||
trySend(emptyList()) // Emit initial empty list so downstream combine() is not blocked
|
||||
|
||||
val bindAddress = findLanAddress() ?: InetAddress.getLocalHost()
|
||||
Logger.i { "JmDNS binding to ${bindAddress.hostAddress}" }
|
||||
|
||||
val jmdns =
|
||||
try {
|
||||
JmDNS.create(InetAddress.getLocalHost())
|
||||
JmDNS.create(bindAddress)
|
||||
} catch (e: IOException) {
|
||||
Logger.e(e) { "Failed to create JmDNS" }
|
||||
null
|
||||
@@ -93,4 +99,24 @@ class JvmServiceDiscovery : ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Finds a non-loopback, up, IPv4 LAN address for JmDNS to bind to. On many systems (especially Windows),
|
||||
* [InetAddress.getLocalHost] resolves to `127.0.0.1` or `::1`, which prevents JmDNS from seeing multicast
|
||||
* traffic on the actual LAN interface.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught", "LoopWithTooManyJumpStatements")
|
||||
internal fun findLanAddress(): InetAddress? = try {
|
||||
NetworkInterface.getNetworkInterfaces()
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to enumerate network interfaces, falling back to getLocalHost()" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.network.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JvmServiceDiscoveryTest {
|
||||
|
||||
@Test
|
||||
fun `resolvedServices emits initial empty list immediately`() = runTest {
|
||||
val discovery = JvmServiceDiscovery()
|
||||
discovery.resolvedServices.test {
|
||||
val first = awaitItem()
|
||||
assertNotNull(first, "First emission should not be null")
|
||||
assertTrue(first.isEmpty(), "First emission should be an empty list")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findLanAddress returns non-loopback address or null`() {
|
||||
val address = JvmServiceDiscovery.findLanAddress()
|
||||
// On CI machines there may be no LAN interface, so null is acceptable
|
||||
if (address != null) {
|
||||
assertTrue(!address.isLoopbackAddress, "Address should not be loopback")
|
||||
assertTrue(address is java.net.Inet4Address, "Address should be IPv4")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findLanAddress does not throw`() {
|
||||
// Ensure the method handles exceptions gracefully
|
||||
val result = runCatching { JvmServiceDiscovery.findLanAddress() }
|
||||
assertTrue(result.isSuccess, "findLanAddress should not throw: ${result.exceptionOrNull()}")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
|
||||
/** Interface for sending commands and packets to the mesh network. */
|
||||
@Suppress("TooManyFunctions")
|
||||
@@ -43,15 +42,6 @@ interface CommandSender {
|
||||
/** Generates a new unique packet ID. */
|
||||
fun generatePacketId(): Int
|
||||
|
||||
/** The latest neighbor info received from the connected radio. */
|
||||
var lastNeighborInfo: NeighborInfo?
|
||||
|
||||
/** Start times of traceroute requests for duration calculation. */
|
||||
val tracerouteStartTimes: MutableMap<Int, Long>
|
||||
|
||||
/** Start times of neighbor info requests for duration calculation. */
|
||||
val neighborInfoStartTimes: MutableMap<Int, Long>
|
||||
|
||||
/** Sets the session passkey for admin messages. */
|
||||
fun setSessionPasskey(key: ByteString)
|
||||
|
||||
|
||||
@@ -14,25 +14,20 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
class PositionTest {
|
||||
/*
|
||||
/**
|
||||
* Shared constants for the two-stage mesh handshake protocol.
|
||||
*
|
||||
* Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests
|
||||
* the full node database.
|
||||
*
|
||||
* Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these.
|
||||
*/
|
||||
object HandshakeConstants {
|
||||
/** Nonce sent in `want_config_id` to request config-only (Stage 1). */
|
||||
const val CONFIG_NONCE = 69420
|
||||
|
||||
@Test
|
||||
fun degGood() {
|
||||
assertEquals(Position.degI(89.0), 890000000)
|
||||
assertEquals(Position.degI(-89.0), -890000000)
|
||||
|
||||
assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
|
||||
assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
|
||||
val position = Position(37.1, 121.1, 35)
|
||||
assertTrue(position.time != 0)
|
||||
}
|
||||
|
||||
*/
|
||||
/** Nonce sent in `want_config_id` to request node info only (Stage 2). */
|
||||
const val NODE_INFO_NONCE = 69421
|
||||
}
|
||||
@@ -18,12 +18,19 @@ package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
|
||||
/** Interface for handling neighbor info responses from the mesh. */
|
||||
interface NeighborInfoHandler {
|
||||
/** Starts the neighbor info handler with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/** Records the start time for a neighbor info request. */
|
||||
fun recordStartTime(requestId: Int)
|
||||
|
||||
/** The latest neighbor info received from the connected radio. */
|
||||
var lastNeighborInfo: NeighborInfo?
|
||||
|
||||
/**
|
||||
* Processes a neighbor info packet.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling Store & Forward (legacy) and SF++ packets. */
|
||||
interface StoreForwardPacketHandler {
|
||||
/** Starts the handler with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/**
|
||||
* Handles a legacy Store & Forward packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param dataPacket The decoded data packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
|
||||
|
||||
/**
|
||||
* Handles a Store Forward++ packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
*/
|
||||
fun handleStoreForwardPlusPlus(packet: MeshPacket)
|
||||
}
|
||||
@@ -25,6 +25,9 @@ interface TracerouteHandler {
|
||||
/** Starts the traceroute handler with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/** Records the start time for a traceroute request. */
|
||||
fun recordStartTime(requestId: Int)
|
||||
|
||||
/**
|
||||
* Processes a traceroute packet.
|
||||
*
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
|
||||
/**
|
||||
* A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the
|
||||
* AIDL changes, this class will fail to compile.
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
|
||||
open class FakeIMeshService : IMeshService.Stub() {
|
||||
override fun subscribeReceiver(packageName: String?, receiverName: String?) {}
|
||||
|
||||
override fun setOwner(user: MeshUser?) {}
|
||||
|
||||
override fun setRemoteOwner(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
||||
|
||||
override fun getRemoteOwner(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun getMyId(): String = "fake_id"
|
||||
|
||||
override fun getPacketId(): Int = 1234
|
||||
|
||||
override fun send(packet: DataPacket?) {}
|
||||
|
||||
override fun getNodes(): List<NodeInfo> = emptyList()
|
||||
|
||||
override fun getConfig(): ByteArray = byteArrayOf()
|
||||
|
||||
override fun setConfig(payload: ByteArray?) {}
|
||||
|
||||
override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
||||
|
||||
override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {}
|
||||
|
||||
override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
||||
|
||||
override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {}
|
||||
|
||||
override fun setRingtone(destNum: Int, ringtone: String?) {}
|
||||
|
||||
override fun getRingtone(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun setCannedMessages(destNum: Int, messages: String?) {}
|
||||
|
||||
override fun getCannedMessages(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun setChannel(payload: ByteArray?) {}
|
||||
|
||||
override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
||||
|
||||
override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {}
|
||||
|
||||
override fun beginEditSettings(destNum: Int) {}
|
||||
|
||||
override fun commitEditSettings(destNum: Int) {}
|
||||
|
||||
override fun removeByNodenum(requestID: Int, nodeNum: Int) {}
|
||||
|
||||
override fun requestPosition(destNum: Int, position: Position?) {}
|
||||
|
||||
override fun setFixedPosition(destNum: Int, position: Position?) {}
|
||||
|
||||
override fun requestTraceroute(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestShutdown(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestReboot(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestFactoryReset(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun rebootToDfu(destNum: Int) {}
|
||||
|
||||
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {}
|
||||
|
||||
override fun getChannelSet(): ByteArray = byteArrayOf()
|
||||
|
||||
override fun connectionState(): String = "CONNECTED"
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
|
||||
|
||||
override fun getMyNodeInfo(): MyNodeInfo? = null
|
||||
|
||||
override fun startFirmwareUpdate() {}
|
||||
|
||||
override fun getUpdateStatus(): Int = 0
|
||||
|
||||
override fun startProvideLocation() {}
|
||||
|
||||
override fun stopProvideLocation() {}
|
||||
|
||||
override fun requestUserInfo(destNum: Int) {}
|
||||
|
||||
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {}
|
||||
|
||||
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
|
||||
|
||||
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
|
||||
/**
|
||||
* Shared composable that observes [AlertManager.currentAlert] and renders a [MeshtasticDialog] when an alert is
|
||||
* present. This eliminates duplicated alert-rendering boilerplate across Android and Desktop host shells.
|
||||
*
|
||||
* Usage: Place `AlertHost(alertManager)` once in the top-level composable of each platform host.
|
||||
*/
|
||||
@Composable
|
||||
fun AlertHost(alertManager: AlertManager) {
|
||||
val alertDialogState by alertManager.currentAlert.collectAsStateWithLifecycle()
|
||||
alertDialogState?.let { state ->
|
||||
MeshtasticDialog(
|
||||
title = state.title,
|
||||
titleRes = state.titleRes,
|
||||
message = state.message,
|
||||
messageRes = state.messageRes,
|
||||
html = state.html,
|
||||
icon = state.icon,
|
||||
text = state.composableMessage?.let { msg -> { msg.Content() } },
|
||||
confirmText = state.confirmText,
|
||||
confirmTextRes = state.confirmTextRes,
|
||||
onConfirm = state.onConfirm,
|
||||
dismissText = state.dismissText,
|
||||
dismissTextRes = state.dismissTextRes,
|
||||
onDismiss = state.onDismiss,
|
||||
choices = state.choices,
|
||||
dismissable = state.dismissable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Shared placeholder screen for desktop/JVM feature stubs that are not yet implemented. Displays a centered label in
|
||||
* [MaterialTheme.typography.headlineMedium].
|
||||
*/
|
||||
@Composable
|
||||
fun PlaceholderScreen(name: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
/**
|
||||
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
|
||||
* connected and requests are pending.
|
||||
*
|
||||
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedDialogs(
|
||||
connectionState: ConnectionState,
|
||||
sharedContactRequested: SharedContact?,
|
||||
requestChannelSet: ChannelSet?,
|
||||
onDismissSharedContact: () -> Unit,
|
||||
onDismissChannelSet: () -> Unit,
|
||||
) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||
|
||||
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
@@ -27,6 +29,7 @@ import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
|
||||
@KoinViewModel
|
||||
@@ -46,6 +49,28 @@ class ConnectionsViewModel(
|
||||
|
||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
/**
|
||||
* Filtered [ourNodeInfo] that only emits when display-relevant fields change, preventing continuous recomposition
|
||||
* from lastHeard/snr updates.
|
||||
*/
|
||||
val ourNodeForDisplay: StateFlow<Node?> =
|
||||
nodeRepository.ourNodeInfo
|
||||
.distinctUntilChanged { old, new ->
|
||||
old?.num == new?.num &&
|
||||
old?.user == new?.user &&
|
||||
old?.batteryLevel == new?.batteryLevel &&
|
||||
old?.voltage == new?.voltage &&
|
||||
old?.metadata?.firmware_version == new?.metadata?.firmware_version
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = nodeRepository.ourNodeInfo.value)
|
||||
|
||||
/** Whether the LoRa region is UNSET and needs to be configured. */
|
||||
val regionUnset: StateFlow<Boolean> =
|
||||
radioConfigRepository.localConfigFlow
|
||||
.map { it.lora?.region == Config.LoRaConfig.RegionCode.UNSET }
|
||||
.distinctUntilChanged()
|
||||
.stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
|
||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class UIViewModel(
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
private val notificationManager: NotificationManager,
|
||||
packetRepository: PacketRepository,
|
||||
private val alertManager: AlertManager,
|
||||
val alertManager: AlertManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
||||
@@ -121,8 +121,6 @@ class UIViewModel(
|
||||
_scrollToTopEventFlow.tryEmit(event)
|
||||
}
|
||||
|
||||
val currentAlert = alertManager.currentAlert
|
||||
|
||||
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = forwardRoute,
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AlertManagerTest {
|
||||
|
||||
private val alertManager = AlertManager()
|
||||
|
||||
@Test
|
||||
fun `showAlert updates currentAlert flow`() {
|
||||
fun showAlert_updates_currentAlert_flow() {
|
||||
val title = "Test Title"
|
||||
val message = "Test Message"
|
||||
|
||||
@@ -34,12 +34,12 @@ class AlertManagerTest {
|
||||
|
||||
val alertData = alertManager.currentAlert.value
|
||||
assertNotNull(alertData)
|
||||
assertEquals(title, alertData?.title)
|
||||
assertEquals(message, alertData?.message)
|
||||
assertEquals(title, alertData.title)
|
||||
assertEquals(message, alertData.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissAlert clears currentAlert flow`() {
|
||||
fun dismissAlert_clears_currentAlert_flow() {
|
||||
alertManager.showAlert(title = "Title")
|
||||
assertNotNull(alertManager.currentAlert.value)
|
||||
|
||||
@@ -48,7 +48,7 @@ class AlertManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onConfirm triggers and dismisses alert`() {
|
||||
fun onConfirm_triggers_and_dismisses_alert() {
|
||||
var confirmClicked = false
|
||||
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
|
||||
|
||||
@@ -59,7 +59,7 @@ class AlertManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onDismiss triggers and dismisses alert`() {
|
||||
fun onDismiss_triggers_and_dismisses_alert() {
|
||||
var dismissClicked = false
|
||||
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })
|
||||
|
||||
Reference in New Issue
Block a user