refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)

This commit is contained in:
James Rich 2026-04-04 13:07:44 -05:00 committed by GitHub
parent e111b61e4e
commit 6af3ad6f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 3808 additions and 735 deletions

View file

@ -31,7 +31,7 @@ object Exceptions {
*/
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
// Log locally first
Logger.e(exception) { "Exceptions.report: $tag $message" }
Logger.e(exception) { "Exceptions.report: ${tag ?: "no-tag"} ${message ?: "no-message"}" }
reporter?.invoke(exception, tag, message)
}
}
@ -47,6 +47,17 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
/** Suspend-compatible variant of [ignoreException]. */
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
}
}
}
/**
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
* should not crash the process but are still unexpected.

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ExceptionsTest {
@AfterTest
fun tearDown() {
Exceptions.reporter = null
}
// ---------- Exceptions.report ----------
@Test
fun `report invokes configured reporter with all arguments`() {
var captured: Triple<Throwable, String?, String?>? = null
Exceptions.reporter = { ex, tag, msg -> captured = Triple(ex, tag, msg) }
val error = RuntimeException("boom")
Exceptions.report(error, tag = "MyTag", message = "context")
assertEquals(error, captured?.first)
assertEquals("MyTag", captured?.second)
assertEquals("context", captured?.third)
}
@Test
fun `report works with null tag and message`() {
var captured: Triple<Throwable, String?, String?>? = null
Exceptions.reporter = { ex, tag, msg -> captured = Triple(ex, tag, msg) }
Exceptions.report(RuntimeException("x"))
assertNull(captured?.second)
assertNull(captured?.third)
}
@Test
fun `report does not crash when no reporter is configured`() {
Exceptions.reporter = null
// Should not throw
Exceptions.report(RuntimeException("no reporter"))
}
// ---------- ignoreException ----------
@Test
fun `ignoreException swallows exceptions from inner block`() {
var reached = false
ignoreException { throw IllegalStateException("expected") }
reached = true
assertTrue(reached)
}
@Test
fun `ignoreException does not swallow when inner succeeds`() {
var executed = false
ignoreException { executed = true }
assertTrue(executed)
}
@Test
fun `ignoreException silent mode suppresses logging`() {
// Should not crash even in silent mode
ignoreException(silent = true) { throw RuntimeException("silent") }
}
@Test
fun `ignoreException non-silent mode logs but does not crash`() {
ignoreException(silent = false) { throw RuntimeException("logged") }
}
// ---------- ignoreExceptionSuspend ----------
@Test
fun `ignoreExceptionSuspend swallows exceptions`() = runTest {
var reached = false
ignoreExceptionSuspend { throw IllegalArgumentException("async boom") }
reached = true
assertTrue(reached)
}
@Test
fun `ignoreExceptionSuspend silent mode suppresses logging`() = runTest {
ignoreExceptionSuspend(silent = true) { throw RuntimeException("silent async") }
}
@Test
fun `ignoreExceptionSuspend executes block normally when no exception`() = runTest {
var executed = false
ignoreExceptionSuspend { executed = true }
assertTrue(executed)
}
// ---------- exceptionReporter ----------
@Test
fun `exceptionReporter reports exceptions to configured reporter`() {
var reportCalled = false
Exceptions.reporter = { _, _, _ -> reportCalled = true }
exceptionReporter { throw RuntimeException("reported") }
assertTrue(reportCalled)
}
@Test
fun `exceptionReporter does not invoke reporter when block succeeds`() {
var reportCalled = false
Exceptions.reporter = { _, _, _ -> reportCalled = true }
exceptionReporter {
// no exception
}
assertFalse(reportCalled)
}
@Test
fun `exceptionReporter works without configured reporter`() {
Exceptions.reporter = null
// Should not crash
exceptionReporter { throw RuntimeException("no reporter configured") }
}
}

View file

@ -0,0 +1,86 @@
/*
* 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 org.koin.core.annotation.Single
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
/**
* Implementation of [AdminPacketHandler] that processes admin messages, including session passkeys, device/module
* configuration, and metadata.
*/
@Single
class AdminPacketHandlerImpl(
private val nodeManager: NodeManager,
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
) : AdminPacketHandler {
override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" }
// Guard against clearing a valid passkey: firmware always embeds the key in every
// admin response, but a missing (default-empty) field must not reset the stored value.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) {
Logger.d { "Session passkey updated (${incomingPasskey.size} bytes)" }
commandSender.setSessionPasskey(incomingPasskey)
}
val fromNum = packet.from
u.get_module_config_response?.let {
if (fromNum == myNodeNum) {
configHandler.value.handleModuleConfig(it)
} else {
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
}
u.get_device_metadata_response?.let {
if (fromNum == myNodeNum) {
configFlowManager.value.handleLocalMetadata(it)
} else {
nodeManager.insertMetadata(fromNum, it)
}
}
}
}
/** Returns a short summary of the non-null admin message fields for logging. */
private fun AdminMessage.summarize(): String = buildList {
get_config_response?.let { add("get_config_response") }
get_module_config_response?.let { add("get_module_config_response") }
get_channel_response?.let { add("get_channel_response") }
get_device_metadata_response?.let { add("get_device_metadata_response") }
if (session_passkey.size > 0) add("session_passkey")
}
.joinToString()
.ifEmpty { "empty" }

View file

@ -19,14 +19,12 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
@ -62,7 +60,7 @@ class CommandSenderImpl(
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
@ -98,7 +96,7 @@ class CommandSenderImpl(
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
val myNum = nodeManager.myNodeNum ?: return 0
val myNum = nodeManager.myNodeNum.value ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
@ -169,8 +167,20 @@ class CommandSenderImpl(
packetHandler.sendToRadio(packet)
}
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
wantResponse: Boolean,
initFn: () -> AdminMessage,
): Boolean {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
return packetHandler.sendToRadioAndAwait(packet)
}
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum ?: return
val myNum = nodeManager.myNodeNum.value ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
@ -230,11 +240,11 @@ class CommandSenderImpl(
AdminMessage(remove_fixed_position = true)
}
}
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos, nowMillis)
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
}
override fun requestUserInfo(destNum: Int) {
val myNum = nodeManager.myNodeNum ?: return
val myNum = nodeManager.myNodeNum.value ?: return
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
packetHandler.sendToRadio(
buildMeshPacket(
@ -303,7 +313,7 @@ class CommandSenderImpl(
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum ?: 0
val myNum = nodeManager.myNodeNum.value ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
neighborInfoHandler.lastNeighborInfo
@ -392,7 +402,7 @@ class CommandSenderImpl(
}
return MeshPacket(
from = nodeManager.myNodeNum ?: 0,
from = nodeManager.myNodeNum.value ?: 0,
to = to,
id = id,
want_ack = wantAck,

View file

@ -127,7 +127,6 @@ class FromRadioPacketHandlerImpl(
notificationManager.dispatch(
Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert),
)
packetHandler.removeResponse(0, complete = false)
}
}
}

View file

@ -16,14 +16,13 @@
*/
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 org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
@ -66,7 +65,7 @@ class MeshActionHandlerImpl(
private val messageProcessor: Lazy<MeshMessageProcessor>,
private val radioConfigRepository: RadioConfigRepository,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -77,9 +76,10 @@ class MeshActionHandlerImpl(
private const val EMOJI_INDICATOR = 1
}
override fun onServiceAction(action: ServiceAction) {
ignoreException {
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
override suspend fun onServiceAction(action: ServiceAction) {
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
ignoreExceptionSuspend {
val myNodeNum = nodeManager.myNodeNum.value ?: return@ignoreExceptionSuspend
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
@ -87,7 +87,12 @@ class MeshActionHandlerImpl(
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = action.contact) }
val accepted =
runCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
action.result.complete(accepted)
}
is ServiceAction.GetDeviceMetadata -> {
commandSender.sendAdmin(action.destNum, wantResponse = true) {
@ -180,6 +185,7 @@ class MeshActionHandlerImpl(
}
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
@ -253,7 +259,7 @@ class MeshActionHandlerImpl(
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
// Optimistically persist module config locally so the UI reflects the
// new values immediately instead of waiting for the next want_config handshake.
if (destNum == nodeManager.myNodeNum) {
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
}
}
@ -329,6 +335,7 @@ class MeshActionHandlerImpl(
}
override fun handleRequestReboot(requestId: Int, destNum: Int) {
Logger.i { "Reboot requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
@ -340,6 +347,7 @@ class MeshActionHandlerImpl(
}
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
Logger.i { "Factory reset requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
@ -356,6 +364,7 @@ class MeshActionHandlerImpl(
override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
Logger.i { "Device address changed, switching database and clearing node DB" }
meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()

View file

@ -18,12 +18,10 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
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.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
@ -58,47 +56,91 @@ class MeshConfigFlowManagerImpl(
private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val wantConfigDelay = 100L
override fun start(scope: CoroutineScope) {
this.scope = scope
}
private val newNodes = mutableListOf<NodeInfo>()
override val newNodeCount: Int
get() = newNodes.size
/**
* Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase,
* eliminating the possibility of accessing stale or uninitialized fields.
*
* Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware
* cannot trigger the wrong stage handler or drive the state machine backward.
*/
private sealed class HandshakeState {
/** No handshake in progress. */
data object Idle : HandshakeState()
private var rawMyNodeInfo: ProtoMyNodeInfo? = null
private var lastMetadata: DeviceMetadata? = null
private var newMyNodeInfo: SharedMyNodeInfo? = null
private var myNodeInfo: SharedMyNodeInfo? = null
/**
* Stage 1: receiving device config, module config, channels, and metadata.
*
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
* together by [buildMyNodeInfo] at Stage 1 completion.
*/
data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) :
HandshakeState()
/**
* Stage 2: receiving node-info packets from the firmware.
*
* [myNodeInfo] was committed at the Stage 12 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
data class ReceivingNodeInfo(
val myNodeInfo: SharedMyNodeInfo,
val nodes: MutableList<NodeInfo> = mutableListOf(),
) : HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
}
private var handshakeState: HandshakeState = HandshakeState.Idle
override val newNodeCount: Int
get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0
override fun handleConfigComplete(configCompleteId: Int) {
val state = handshakeState
when (configCompleteId) {
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
HandshakeConstants.CONFIG_NONCE -> {
if (state !is HandshakeState.ReceivingConfig) {
Logger.w { "Ignoring Stage 1 config_complete in state=$state" }
return
}
handleConfigOnlyComplete(state)
}
HandshakeConstants.NODE_INFO_NONCE -> {
if (state !is HandshakeState.ReceivingNodeInfo) {
Logger.w { "Ignoring Stage 2 config_complete in state=$state" }
return
}
handleNodeInfoComplete(state)
}
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}
private fun handleConfigOnlyComplete() {
private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) {
Logger.i { "Config-only complete (Stage 1)" }
if (newMyNodeInfo == null) {
Logger.w {
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata)
if (finalizedInfo == null) {
Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" }
handshakeState = HandshakeState.Idle
scope.handledLaunch {
delay(wantConfigDelay)
connectionManager.value.startConfigOnly()
}
regenMyNodeInfo(lastMetadata)
return
}
val finalizedInfo = newMyNodeInfo
if (finalizedInfo == null) {
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
} else {
myNodeInfo = finalizedInfo
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
}
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
@ -118,19 +160,34 @@ class MeshConfigFlowManagerImpl(
}
}
private fun handleNodeInfoComplete() {
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Logger.i { "NodeInfo complete (Stage 2)" }
val entities = newNodes.map { info ->
nodeManager.installNodeInfo(info, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[info.num]!!
}
newNodes.clear()
val info = state.myNodeInfo
// Transition state immediately (synchronously) to prevent duplicate handling.
// The async work below (DB writes, broadcasts) proceeds without the guard.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
// Snapshot and clear immediately so that a concurrent stall-guard retry (which
// resends want_config_id and causes the firmware to restart the node_info burst)
// starts accumulating into a fresh list rather than doubling this batch.
val nodesToProcess = state.nodes.toList()
state.nodes.clear()
val entities =
nodesToProcess.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
null
}
}
scope.handledLaunch {
myNodeInfo?.let {
nodeRepository.installConfig(it, entities)
sendAnalytics(it)
}
nodeRepository.installConfig(info, entities)
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceRepository.setConnectionState(ConnectionState.Connected)
@ -139,16 +196,18 @@ class MeshConfigFlowManagerImpl(
}
}
private fun sendAnalytics(mi: SharedMyNodeInfo) {
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
}
override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.my_node_num
regenMyNodeInfo(lastMetadata)
// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
nodeManager.setMyNodeNum(myInfo.my_node_num)
// Clear persisted radio config so the new handshake starts from a clean slate.
// DataStore serializes its own writes, so the clear will precede subsequent
// setLocalConfig / updateChannelSettings calls dispatched by later packets in this
// session (handleFromRadio processes packets sequentially, so later dispatches always
// occur after this one returns).
scope.handledLaunch {
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
@ -160,12 +219,26 @@ class MeshConfigFlowManagerImpl(
override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
lastMetadata = metadata
regenMyNodeInfo(metadata)
val state = handshakeState
if (state is HandshakeState.ReceivingConfig) {
state.metadata = metadata
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
// but the DB write does not need to wait until then.
if (metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) }
}
} else {
Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" }
}
}
override fun handleNodeInfo(info: NodeInfo) {
newNodes.add(info)
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
state.nodes.add(info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}
}
override fun handleFileInfo(info: FileInfo) {
@ -177,46 +250,38 @@ class MeshConfigFlowManagerImpl(
connectionManager.value.startConfigOnly()
}
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
try {
val mi =
with(myInfo) {
SharedMyNodeInfo(
myNodeNum = my_node_num,
hasGPS = false,
model =
when (val hwModel = metadata?.hw_model) {
null,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
couldUpdate = false,
shouldUpdate = false,
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = myInfo.pio_env.ifEmpty { null },
)
}
if (metadata != null && metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) }
}
newMyNodeInfo = mi
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Failed to regenMyNodeInfo" }
}
} else {
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
/**
* Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function no side effects.
* Returns null only if construction throws.
*/
private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try {
with(raw) {
SharedMyNodeInfo(
myNodeNum = my_node_num,
hasGPS = false,
model =
when (val hwModel = metadata?.hw_model) {
null,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
couldUpdate = false,
shouldUpdate = false,
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = pio_env.ifEmpty { null },
)
}
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Failed to build MyNodeInfo" }
null
}
}

View file

@ -16,15 +16,14 @@
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
@ -42,7 +41,7 @@ class MeshConfigHandlerImpl(
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val _localConfig = MutableStateFlow(LocalConfig())
override val localConfig = _localConfig.asStateFlow()
@ -57,16 +56,18 @@ class MeshConfigHandlerImpl(
}
override fun handleDeviceConfig(config: Config) {
Logger.d { "Device config received: ${config.summarize()}" }
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setConnectionProgress("Device config received")
}
override fun handleModuleConfig(config: ModuleConfig) {
Logger.d { "Module config received: ${config.summarize()}" }
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setConnectionProgress("Module config received")
config.statusmessage?.let { sm ->
nodeManager.myNodeNum?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
}
}
@ -85,6 +86,40 @@ class MeshConfigHandlerImpl(
}
override fun handleDeviceUIConfig(config: DeviceUIConfig) {
Logger.d { "DeviceUI config received" }
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
}
}
/** Returns a short summary of which Config variant is set. */
private fun Config.summarize(): String = when {
device != null -> "device"
position != null -> "position"
power != null -> "power"
network != null -> "network"
display != null -> "display"
lora != null -> "lora"
bluetooth != null -> "bluetooth"
security != null -> "security"
else -> "unknown"
}
/** Returns a short summary of which ModuleConfig variant is set. */
@Suppress("CyclomaticComplexMethod")
private fun ModuleConfig.summarize(): String = when {
mqtt != null -> "mqtt"
serial != null -> "serial"
external_notification != null -> "external_notification"
store_forward != null -> "store_forward"
range_test != null -> "range_test"
telemetry != null -> "telemetry"
canned_message != null -> "canned_message"
audio != null -> "audio"
remote_hardware != null -> "remote_hardware"
neighbor_info != null -> "neighbor_info"
ambient_lighting != null -> "ambient_lighting"
detection_sensor != null -> "detection_sensor"
paxcounter != null -> "paxcounter"
statusmessage != null -> "statusmessage"
else -> "unknown"
}

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
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.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
@ -84,7 +82,7 @@ class MeshConnectionManagerImpl(
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
@ -127,22 +125,20 @@ class MeshConnectionManagerImpl(
.launchIn(scope)
}
private fun onRadioConnectionState(newState: ConnectionState) {
scope.handledLaunch {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
private suspend fun onRadioConnectionState(newState: ConnectionState) {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
val effectiveState =
when (newState) {
is ConnectionState.Connected -> ConnectionState.Connected
is ConnectionState.DeviceSleep ->
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
is ConnectionState.Connecting -> ConnectionState.Connecting
is ConnectionState.Disconnected -> ConnectionState.Disconnected
}
onConnectionChanged(effectiveState)
}
val effectiveState =
when (newState) {
is ConnectionState.Connected -> ConnectionState.Connected
is ConnectionState.DeviceSleep ->
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
is ConnectionState.Connecting -> ConnectionState.Connecting
is ConnectionState.Disconnected -> ConnectionState.Disconnected
}
onConnectionChanged(effectiveState)
}
private fun onConnectionChanged(c: ConnectionState) {
@ -195,23 +191,27 @@ class MeshConnectionManagerImpl(
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w {
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled."
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled"
}
action()
delay(HANDSHAKE_RETRY_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
Logger.e { "Handshake still stalled after retry, forcing reconnect" }
onConnectionChanged(ConnectionState.Disconnected)
}
}
}
}
private fun handleDeviceSleep() {
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
}
private fun handleDeviceSleep() {
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
tearDownConnection()
if (connectTimeMsec != 0L) {
val now = nowMillis
@ -230,7 +230,7 @@ class MeshConnectionManagerImpl(
val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
delay(timeout.seconds)
Logger.w { "Device timeout out, setting disconnected" }
Logger.w { "Device timed out, setting disconnected" }
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Logger.d { "device sleep timeout cancelled" }
@ -242,9 +242,7 @@ class MeshConnectionManagerImpl(
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
tearDownConnection()
analytics.track(
EVENT_MESH_DISCONNECT,
@ -285,7 +283,7 @@ class MeshConnectionManagerImpl(
handshakeTimeout?.cancel()
handshakeTimeout = null
val myNodeNum = nodeManager.myNodeNum ?: 0
val myNodeNum = nodeManager.myNodeNum.value ?: 0
// Set device time now that the full node picture is ready. Sending this during Stage 1
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.

View file

@ -20,14 +20,10 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
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.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
@ -37,11 +33,8 @@ import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.MeshDataMapper
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.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
@ -56,38 +49,33 @@ 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.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.milliseconds
/**
* Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
*
* This class handles the complexity of:
* 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, SFPP).
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, Telemetry, Admin, SFPP).
* 3. Managing message history and persistence.
* 4. Triggering notifications for various packet types (Text, Waypoints, Battery).
* 5. Tracking received telemetry for node updates.
* 4. Triggering notifications for various packet types (Text, Waypoints).
*/
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Single
class MeshDataHandlerImpl(
private val nodeManager: NodeManager,
@ -99,24 +87,20 @@ class MeshDataHandlerImpl(
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
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,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
private lateinit var scope: CoroutineScope
override fun start(scope: CoroutineScope) {
this.scope = scope
storeForwardHandler.start(scope)
telemetryHandler.start(scope)
}
private val rememberDataType =
@ -157,7 +141,7 @@ class MeshDataHandlerImpl(
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
PortNum.TELEMETRY_APP -> handleTelemetry(packet, dataPacket, myNodeNum)
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
else ->
shouldBroadcast =
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
@ -198,7 +182,7 @@ class MeshDataHandlerImpl(
}
PortNum.ADMIN_APP -> {
handleAdminMessage(packet, myNodeNum)
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
}
PortNum.NEIGHBORINFO_APP -> {
@ -255,37 +239,6 @@ class MeshDataHandlerImpl(
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
}
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
// Guard against clearing a valid passkey: firmware always embeds the key in every
// admin response, but a missing (default-empty) field must not reset the stored value.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) commandSender.setSessionPasskey(incomingPasskey)
val fromNum = packet.from
u.get_module_config_response?.let {
if (fromNum == myNodeNum) {
configHandler.value.handleModuleConfig(it)
} else {
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
}
u.get_device_metadata_response?.let {
if (fromNum == myNodeNum) {
configFlowManager.value.handleLocalMetadata(it)
} else {
nodeManager.insertMetadata(fromNum, it)
}
}
}
private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val decoded = packet.decoded ?: return
if (decoded.reply_id != 0 && decoded.emoji != 0) {
@ -311,107 +264,6 @@ class MeshDataHandlerImpl(
rememberDataPacket(dataPacket, myNodeNum)
}
@Suppress("LongMethod")
private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
}
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
connectionManager.value.updateTelemetry(t)
}
nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
var nextNode = node
when {
metrics != null -> {
nextNode = nextNode.copy(deviceMetrics = metrics)
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
if (
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
notificationManager.dispatch(
Notification(
title =
getStringSuspend(
Res.string.low_battery_title,
nextNode.user.short_name,
),
message =
getStringSuspend(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
),
category = Notification.Category.Battery,
),
)
}
}
} else {
scope.launch {
batteryMutex.withLock {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
}
notificationManager.cancel(nextNode.num)
}
}
}
}
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
nextNode.copy(lastHeard = newLastHeard)
}
}
@Suppress("ReturnCount")
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
val metrics = t.device_metrics ?: return false
val batteryLevel = metrics.battery_level ?: 0
when {
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
shouldDisplay = true
forceDisplay = true
}
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
isRemote -> shouldDisplay = true
}
if (shouldDisplay) {
val now = nowSeconds
batteryMutex.withLock {
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
}
}
}
return false
}
private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
val payload = packet.decoded?.payload ?: return
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
@ -628,12 +480,13 @@ class MeshDataHandlerImpl(
return@handledLaunch
}
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum ?: 0)
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0)
// Find the original packet to get the contactKey
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
val targetId = if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
val targetId =
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
@ -642,7 +495,11 @@ class MeshDataHandlerImpl(
if (!isSilent) {
val channelName =
if (originalPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(originalPacket.channel)?.name
radioConfigRepository.channelSetFlow
.first()
.settings
.getOrNull(originalPacket.channel)
?.name
} else {
null
}
@ -660,11 +517,5 @@ class MeshDataHandlerImpl(
companion object {
private const val HOPS_AWAY_UNAVAILABLE = -1
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
}
}

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -27,7 +26,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
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.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
@ -55,7 +53,7 @@ class MeshMessageProcessorImpl(
private val router: Lazy<MeshRouter>,
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf<Int, String>()
@ -152,6 +150,7 @@ class MeshMessageProcessorImpl(
earlyMutex.withLock {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
Logger.w { "Early packet buffer full ($queueSize), dropping oldest packet" }
earlyReceivedPackets.removeFirstOrNull()
}
earlyReceivedPackets.addLast(preparedPacket)
@ -162,16 +161,17 @@ class MeshMessageProcessorImpl(
private fun flushEarlyReceivedPackets(reason: String) {
scope.launch {
val packets = earlyMutex.withLock {
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
val packets =
earlyMutex.withLock {
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
if (packets.isEmpty()) return@launch
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
val myNodeNum = nodeManager.myNodeNum.value
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
}

View file

@ -20,12 +20,10 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
@ -39,7 +37,7 @@ class MqttManagerImpl(
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private var mqttMessageFlow: Job? = null
override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {

View file

@ -21,10 +21,8 @@ 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.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
@ -39,7 +37,7 @@ class NeighborInfoHandlerImpl(
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val startTimes = atomic(persistentMapOf<Int, Long>())
@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl(
// Store the last neighbor info from our connected radio
val from = packet.from
if (from == nodeManager.myNodeNum) {
if (from == nodeManager.myNodeNum.value) {
lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}

View file

@ -21,13 +21,11 @@ import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
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.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
@ -62,7 +60,7 @@ class NodeManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
@ -84,7 +82,11 @@ class NodeManagerImpl(
allowNodeDbWrites.value = allowed
}
override var myNodeNum: Int? = null
override val myNodeNum = MutableStateFlow<Int?>(null)
override fun setMyNodeNum(num: Int?) {
myNodeNum.value = num
}
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -101,7 +103,7 @@ class NodeManagerImpl(
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
}
}
@ -110,7 +112,7 @@ class NodeManagerImpl(
_nodeDBbyID.value = persistentMapOf()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum = null
myNodeNum.value = null
}
override fun getMyNodeInfo(): MyNodeInfo? {
@ -135,7 +137,7 @@ class NodeManagerImpl(
}
override fun getMyId(): String {
val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
}
@ -271,9 +273,8 @@ class NodeManagerImpl(
if (shouldPreserveExistingUser(node.user, user)) {
// keep existing names
} else {
var newUser = user.let {
if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it
}
var newUser =
user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -29,7 +30,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
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.ConnectionState
import org.meshtastic.core.model.DataPacket
@ -67,16 +67,21 @@ class PacketHandlerImpl(
}
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(ioDispatcher)
private lateinit var scope: CoroutineScope
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
override fun start(scope: CoroutineScope) {
this.scope = scope
queueStopped = false // Safe: called before any concurrent operations on this scope.
}
override fun sendToRadio(p: ToRadio) {
@ -104,22 +109,52 @@ class PacketHandlerImpl(
override fun sendToRadio(packet: MeshPacket) {
scope.launch {
queueMutex.withLock { queuedPackets.add(packet) }
startPacketQueue()
queueMutex.withLock {
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
// Pre-register the deferred so the queue processor and QueueStatus handler
// can find it immediately — no polling required.
val deferred = CompletableDeferred<Boolean>()
responseMutex.withLock { queueResponse[packet.id] = deferred }
queueMutex.withLock {
queuedPackets.add(packet)
startPacketQueueLocked()
}
return try {
withTimeout(TIMEOUT) { deferred.await() }
} catch (e: TimeoutCancellationException) {
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" }
false
} catch (e: CancellationException) {
throw e // Preserve structured concurrency cancellation propagation.
} catch (e: Exception) {
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" }
false
} finally {
responseMutex.withLock { queueResponse.remove(packet.id) }
}
}
override fun stopPacketQueue() {
if (queueJob?.isActive == true) {
// Run async so callers (non-suspend) don't block, but all mutations are
// serialized under the same mutexes used by the queue processor and senders.
scope.launch {
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
queueJob = null
scope.launch {
queueMutex.withLock { queuedPackets.clear() }
responseMutex.withLock {
queueResponse.values.lastOrNull { !it.isCompleted }?.complete(false)
queueResponse.clear()
}
queueMutex.withLock {
queueStopped = true
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
}
responseMutex.withLock {
queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
queueResponse.clear()
}
}
}
@ -144,33 +179,47 @@ class PacketHandlerImpl(
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
}
private fun startPacketQueue() {
/**
* Starts the packet queue processor. Must be called while holding [queueMutex] to ensure the check-then-start is
* atomic preventing two concurrent callers from launching duplicate processors.
*/
private fun startPacketQueueLocked() {
if (queueStopped) return
if (queueJob?.isActive == true) return
queueJob = scope.handledLaunch {
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
responseMutex.withLock { queueResponse.remove(packet.id) }
queueJob =
scope.handledLaunch {
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: CancellationException) {
throw e // Preserve structured concurrency cancellation propagation.
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
}
// Do NOT remove from queueResponse here. Removal is owned by:
// - handleQueueStatus (normal completion path)
// - sendToRadioAndAwait's finally block (for await-style callers)
// - stopPacketQueue (bulk cleanup on disconnect)
}
} finally {
// Hold queueMutex so that clearing queueJob and the restart decision are
// atomic with respect to new senders calling startPacketQueueLocked().
queueMutex.withLock {
queueJob = null
if (!queueStopped && queuedPackets.isNotEmpty()) {
startPacketQueueLocked()
}
}
}
} finally {
queueJob = null
if (queueMutex.withLock { queuedPackets.isNotEmpty() }) {
startPacketQueue()
}
}
}
}
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
@ -194,8 +243,8 @@ class PacketHandlerImpl(
@Suppress("TooGenericExceptionCaught")
private suspend fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
val deferred = CompletableDeferred<Boolean>()
responseMutex.withLock { queueResponse[packet.id] = deferred }
// Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one.
val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
try {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()

View file

@ -18,12 +18,10 @@ 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
@ -48,7 +46,7 @@ class StoreForwardPacketHandlerImpl(
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
) : StoreForwardPacketHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -116,7 +114,7 @@ class StoreForwardPacketHandlerImpl(
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
@ -126,7 +124,7 @@ class StoreForwardPacketHandlerImpl(
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
myNodeNum = nodeManager.myNodeNum.value ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
@ -145,10 +143,8 @@ class StoreForwardPacketHandlerImpl(
}
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" }
val lastRequest = s.history?.last_request ?: 0
Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
@ -159,7 +155,8 @@ class StoreForwardPacketHandlerImpl(
)
dataHandler.value.rememberDataPacket(u, myNodeNum)
}
h != null -> {
s.history != null -> {
val h = s.history!!
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +

View file

@ -0,0 +1,170 @@
/*
* 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.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Telemetry
import kotlin.time.Duration.Companion.milliseconds
/**
* Implementation of [TelemetryPacketHandler] that processes telemetry packets and manages battery-level notifications
* with cooldown logic.
*/
@Single
class TelemetryPacketHandlerImpl(
private val nodeManager: NodeManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val notificationManager: NotificationManager,
) : TelemetryPacketHandler {
private lateinit var scope: CoroutineScope
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
override fun start(scope: CoroutineScope) {
this.scope = scope
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
}
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
connectionManager.value.updateTelemetry(t)
}
nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
var nextNode = node
when {
metrics != null -> {
nextNode = nextNode.copy(deviceMetrics = metrics)
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
if (
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
notificationManager.dispatch(
Notification(
title =
getStringSuspend(
Res.string.low_battery_title,
nextNode.user.short_name,
),
message =
getStringSuspend(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
),
category = Notification.Category.Battery,
),
)
}
}
} else {
scope.launch {
batteryMutex.withLock {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
}
notificationManager.cancel(nextNode.num)
}
}
}
}
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
nextNode.copy(lastHeard = newLastHeard)
}
}
@Suppress("ReturnCount")
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
val metrics = t.device_metrics ?: return false
val batteryLevel = metrics.battery_level ?: 0
when {
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
shouldDisplay = true
forceDisplay = true
}
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
isRemote -> shouldDisplay = true
}
if (shouldDisplay) {
val now = nowSeconds
batteryMutex.withLock {
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
}
}
}
return false
}
companion object {
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
}
}

View file

@ -22,11 +22,9 @@ 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.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
@ -45,7 +43,7 @@ class TracerouteHandlerImpl(
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private lateinit var scope: CoroutineScope
private val startTimes = atomic(persistentMapOf<Int, Long>())

View file

@ -0,0 +1,224 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verify
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import kotlin.test.BeforeTest
import kotlin.test.Test
class AdminPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val configHandler = mock<MeshConfigHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private lateinit var handler: AdminPacketHandlerImpl
private val myNodeNum = 12345
@BeforeTest
fun setUp() {
handler =
AdminPacketHandlerImpl(
nodeManager = nodeManager,
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
commandSender = commandSender,
)
}
private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket {
val payload = AdminMessage.ADAPTER.encode(adminMessage).toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload))
}
// ---------- Session passkey ----------
@Test
fun `session passkey is updated when present`() {
val passkey = ByteString.of(1, 2, 3, 4)
val adminMsg = AdminMessage(session_passkey = passkey)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { commandSender.setSessionPasskey(passkey) }
}
@Test
fun `empty session passkey does not clear existing passkey`() {
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// setSessionPasskey should NOT be called for empty passkey
}
// ---------- get_config_response ----------
@Test
fun `get_config_response from own node delegates to configHandler`() {
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
val adminMsg = AdminMessage(get_config_response = config)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleDeviceConfig(config) }
}
@Test
fun `get_config_response from remote node is ignored`() {
val config = Config(device = Config.DeviceConfig())
val adminMsg = AdminMessage(get_config_response = config)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// configHandler.handleDeviceConfig should NOT be called
}
// ---------- get_module_config_response ----------
@Test
fun `get_module_config_response from own node delegates to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleModuleConfig(moduleConfig) }
}
@Test
fun `get_module_config_response from remote node updates node status`() {
val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low"))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") }
}
@Test
fun `get_module_config_response from remote without status message does not crash`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// No crash, no updateNodeStatus call
}
// ---------- get_channel_response ----------
@Test
fun `get_channel_response from own node delegates to configHandler`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleChannel(channel) }
}
@Test
fun `get_channel_response from remote node is ignored`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// configHandler.handleChannel should NOT be called
}
// ---------- get_device_metadata_response ----------
@Test
fun `device metadata from own node delegates to configFlowManager`() {
val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `device metadata from remote node delegates to nodeManager`() {
val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { nodeManager.insertMetadata(remoteNode, metadata) }
}
// ---------- Edge cases ----------
@Test
fun `packet with null decoded payload is ignored`() {
val packet = MeshPacket(from = myNodeNum, decoded = null)
handler.handleAdminMessage(packet, myNodeNum)
// No crash
}
@Test
fun `packet with empty payload bytes is ignored`() {
val packet =
MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY))
handler.handleAdminMessage(packet, myNodeNum)
// No crash — decodes as default AdminMessage with no fields set
}
@Test
fun `combined admin message with passkey and config response`() {
val passkey = ByteString.of(5, 6, 7, 8)
val config = Config(lora = Config.LoRaConfig())
val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { commandSender.setSessionPasskey(passkey) }
verify { configHandler.handleDeviceConfig(config) }
}
}

View file

@ -0,0 +1,583 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.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.verify.VerifyMode.Companion.not
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshActionHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
private lateinit var handler: MeshActionHandlerImpl
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
companion object {
private const val MY_NODE_NUM = 12345
private const val REMOTE_NODE_NUM = 67890
}
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
handler =
MeshActionHandlerImpl(
nodeManager = nodeManager,
commandSender = commandSender,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
dataHandler = lazy { dataHandler },
analytics = analytics,
meshPrefs = meshPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
messageProcessor = lazy { messageProcessor },
radioConfigRepository = radioConfigRepository,
)
}
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new_addr")
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress("new_addr") }
verify { nodeManager.clear() }
verifySuspend { messageProcessor.clearEarlyPackets() }
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
handler.handleUpdateLastAddress("same_addr")
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
verify(not) { nodeManager.clear() }
}
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress(null) }
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase(null) }
}
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
}
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new")
advanceUntilIdle()
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase("new") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
// ---- onServiceAction: null myNodeNum early-return ----
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
handler.start(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: Favorite ----
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: Ignore ----
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
}
// ---- onServiceAction: Mute ----
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: GetDeviceMetadata ----
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
handler.start(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: SendContact ----
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
handler.start(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertTrue(action.result.await())
}
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
handler.start(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertFalse(action.result.await())
}
// ---- onServiceAction: ImportContact ----
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
handler.start(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
handler.onServiceAction(ServiceAction.ImportContact(contact))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSetOwner ----
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
handler.start(testScope)
val meshUser =
MeshUser(
id = "!12345678",
longName = "Test Long",
shortName = "TL",
hwModel = HardwareModel.UNSET,
isLicensed = false,
)
handler.handleSetOwner(meshUser, MY_NODE_NUM)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSend ----
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
handler.start(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
verify { commandSender.sendData(any()) }
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
}
// ---- handleRequestPosition: 3 branches ----
@Test
fun handleRequestPosition_sameNode_doesNothing() {
handler.start(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
verify(not) { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
handler.start(testScope)
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
}
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
handler.start(testScope)
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
verify { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
handler.start(testScope)
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
// Should send zero position regardless of valid input
verify { commandSender.requestPosition(any(), any()) }
}
// ---- handleSetConfig: optimistic persist ----
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
handler.start(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
val payload = Config.ADAPTER.encode(config)
handler.handleSetConfig(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
}
// ---- handleSetModuleConfig: conditional persist ----
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
handler.start(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
}
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
handler.start(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
}
// ---- handleSetChannel: null payload guard ----
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
handler.start(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
val payload = Channel.ADAPTER.encode(channel)
handler.handleSetChannel(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
}
@Test
fun handleSetChannel_nullPayload_doesNothing() {
handler.start(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRemoveByNodenum ----
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
handler.start(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteOwner ----
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
handler.start(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = User.ADAPTER.encode(user)
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleGetRemoteConfig: sessionkey vs regular ----
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
handler.start(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
handler.start(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteChannel: null payload guard ----
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
handler.start(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
handler.start(testScope)
val channel = Channel(index = 2)
val payload = Channel.ADAPTER.encode(channel)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestRebootOta: null hash ----
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
handler.start(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
handler.start(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestNodedbReset ----
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
handler.start(testScope)
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- Helper ----
private fun createTestNode(
num: Int,
isFavorite: Boolean = false,
isIgnored: Boolean = false,
isMuted: Boolean = false,
): Node = Node(
num = num,
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
)
}

View file

@ -0,0 +1,377 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import 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.encodeUtf8
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
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.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.NodeInfo
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@OptIn(ExperimentalCoroutinesApi::class)
class MeshConfigFlowManagerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var manager: MeshConfigFlowManagerImpl
private val myNodeNum = 12345
private val protoMyNodeInfo =
ProtoMyNodeInfo(
my_node_num = myNodeNum,
min_app_version = 30000,
device_id = "test-device".encodeUtf8(),
pio_env = "",
)
private val metadata =
DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
@BeforeTest
fun setUp() {
every { commandSender.getCurrentPacketId() } returns 100
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
manager =
MeshConfigFlowManagerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
packetHandler = packetHandler,
)
manager.start(testScope)
}
// ---------- handleMyInfo ----------
@Test
fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(myNodeNum) }
}
@Test
fun `handleMyInfo clears persisted radio config`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verifySuspend { radioConfigRepository.clearChannelSet() }
verifySuspend { radioConfigRepository.clearLocalConfig() }
verifySuspend { radioConfigRepository.clearLocalModuleConfig() }
verifySuspend { radioConfigRepository.clearDeviceUIConfig() }
verifySuspend { radioConfigRepository.clearFileManifest() }
}
// ---------- handleLocalMetadata ----------
@Test
fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) }
}
@Test
fun `handleLocalMetadata skips empty metadata`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
// Default/empty DeviceMetadata should not trigger insertMetadata
manager.handleLocalMetadata(DeviceMetadata())
advanceUntilIdle()
// insertMetadata should only have been called zero times for default metadata
// (we just verify no crash occurs)
}
@Test
fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest {
// State is Idle — handleLocalMetadata should be a no-op
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// No crash, no insertMetadata call
}
// ---------- handleConfigComplete Stage 1 ----------
@Test
fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
verify { connectionManager.startNodeInfoOnly() }
}
@Test
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
// No metadata provided
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
}
@Test
fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest {
// State is Idle — should be a no-op
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// No crash, no onRadioConfigLoaded
}
@Test
fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
}
// ---------- handleNodeInfo ----------
@Test
fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest {
// Transition to Stage 2
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// Now in ReceivingNodeInfo
manager.handleNodeInfo(NodeInfo(num = 100))
manager.handleNodeInfo(NodeInfo(num = 200))
assertEquals(2, manager.newNodeCount)
}
@Test
fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest {
// State is Idle
manager.handleNodeInfo(NodeInfo(num = 999))
assertEquals(0, manager.newNodeCount)
}
// ---------- handleConfigComplete Stage 2 ----------
@Test
fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest {
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
// Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
manager.handleNodeInfo(NodeInfo(num = 100))
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
verify { nodeManager.setNodeDbReady(true) }
verify { nodeManager.setAllowNodeDbWrites(true) }
verify { serviceBroadcasts.broadcastConnection() }
verify { connectionManager.onNodeDbReady() }
}
@Test
fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest {
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
// No crash
}
@Test
fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// No handleNodeInfo calls — empty node list
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
}
// ---------- Unknown config_complete_id ----------
@Test
fun `Unknown config_complete_id is ignored`() = testScope.runTest {
manager.handleConfigComplete(99999)
advanceUntilIdle()
// No crash
}
// ---------- newNodeCount ----------
@Test
fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() {
assertEquals(0, manager.newNodeCount)
}
// ---------- handleFileInfo ----------
@Test
fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest {
val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024)
manager.handleFileInfo(fileInfo)
advanceUntilIdle()
verifySuspend { radioConfigRepository.addFileInfo(fileInfo) }
}
// ---------- triggerWantConfig ----------
@Test
fun `triggerWantConfig delegates to connectionManager startConfigOnly`() {
manager.triggerWantConfig()
verify { connectionManager.startConfigOnly() }
}
// ---------- Full handshake flow ----------
@Test
fun `Full handshake from Idle to Complete`() = testScope.runTest {
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
// Stage 0: Idle -> handleMyInfo
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(myNodeNum) }
// Receive metadata during Stage 1
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// Stage 1 complete
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
// Receive NodeInfo during Stage 2
manager.handleNodeInfo(NodeInfo(num = 100))
assertEquals(1, manager.newNodeCount)
// Stage 2 complete
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
// After complete, newNodeCount should be 0 (state is Complete)
assertEquals(0, manager.newNodeCount)
}
// ---------- Interrupted handshake ----------
@Test
fun `handleMyInfo resets stale handshake state`() = testScope.runTest {
// Start first handshake
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// Before Stage 1 completes, a new handleMyInfo arrives (device rebooted)
val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999)
manager.handleMyInfo(newMyInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(99999) }
}
}

View file

@ -0,0 +1,230 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class MeshConfigHandlerImplTest {
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var handler: MeshConfigHandlerImpl
@BeforeTest
fun setUp() {
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
handler =
MeshConfigHandlerImpl(
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
nodeManager = nodeManager,
)
}
// ---------- start and flow wiring ----------
@Test
fun `start wires localConfig flow from repository`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
localConfigFlow.value = config
advanceUntilIdle()
assertEquals(config, handler.localConfig.value)
}
@Test
fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
moduleConfigFlow.value = config
advanceUntilIdle()
assertEquals(config, handler.moduleConfig.value)
}
// ---------- handleDeviceConfig ----------
@Test
fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
handler.handleDeviceConfig(config)
advanceUntilIdle()
verifySuspend { radioConfigRepository.setLocalConfig(config) }
verify { serviceRepository.setConnectionProgress("Device config received") }
}
@Test
fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val configs =
listOf(
Config(position = Config.PositionConfig()),
Config(power = Config.PowerConfig()),
Config(network = Config.NetworkConfig()),
Config(display = Config.DisplayConfig()),
Config(lora = Config.LoRaConfig()),
Config(bluetooth = Config.BluetoothConfig()),
Config(security = Config.SecurityConfig()),
)
for (config in configs) {
handler.handleDeviceConfig(config)
advanceUntilIdle()
}
// All should have been persisted (7 configs)
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
}
// ---------- handleModuleConfig ----------
@Test
fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
handler.handleModuleConfig(config)
advanceUntilIdle()
verifySuspend { radioConfigRepository.setLocalModuleConfig(config) }
verify { serviceRepository.setConnectionProgress("Module config received") }
}
@Test
fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val myNum = 123
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNum)
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
handler.handleModuleConfig(config)
advanceUntilIdle()
verify { nodeManager.updateNodeStatus(myNum, "Active") }
}
@Test
fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(null)
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
handler.handleModuleConfig(config)
advanceUntilIdle()
// No crash — updateNodeStatus should not be called
}
// ---------- handleChannel ----------
@Test
fun `handleChannel persists channel settings`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val channel = Channel(index = 0)
handler.handleChannel(channel)
advanceUntilIdle()
verifySuspend { radioConfigRepository.updateChannelSettings(channel) }
}
@Test
fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns
MyNodeInfo(
myNodeNum = 123,
hasGPS = false,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = null,
)
val channel = Channel(index = 2)
handler.handleChannel(channel)
advanceUntilIdle()
verify { serviceRepository.setConnectionProgress("Channels (3 / 8)") }
}
@Test
fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) {
handler.start(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns null
val channel = Channel(index = 0)
handler.handleChannel(channel)
advanceUntilIdle()
verify { serviceRepository.setConnectionProgress("Channels (1)") }
}
// ---------- handleDeviceUIConfig ----------
@Test
fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) {
handler.start(backgroundScope)
val config = DeviceUIConfig()
handler.handleDeviceUIConfig(config)
advanceUntilIdle()
verifySuspend { radioConfigRepository.setDeviceUIConfig(config) }
}
}

View file

@ -255,7 +255,7 @@ class MeshConnectionManagerImplTest {
)
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns 123
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.start(any(), any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null

View file

@ -35,10 +35,7 @@ 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.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
@ -51,6 +48,7 @@ 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.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Data
@ -79,15 +77,13 @@ class MeshDataHandlerTest {
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val commandSender: CommandSender = 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 telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@ -105,15 +101,13 @@ class MeshDataHandlerTest {
serviceNotifications = serviceNotifications,
analytics = analytics,
dataMapper = dataMapper,
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
commandSender = commandSender,
connectionManager = lazy { connectionManager },
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
radioConfigRepository = radioConfigRepository,
messageFilter = messageFilter,
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
)
handler.start(testScope)
@ -428,7 +422,7 @@ class MeshDataHandlerTest {
// --- Telemetry handling ---
@Test
fun `telemetry packet updates node via nodeManager`() {
fun `telemetry packet delegates to telemetryHandler`() {
val telemetry =
Telemetry(
time = 2000,
@ -451,11 +445,11 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { nodeManager.updateNode(456, any(), any(), any()) }
verify { telemetryHandler.handleTelemetry(packet, any(), 123) }
}
@Test
fun `telemetry from local node also updates connectionManager`() {
fun `telemetry from local node delegates to telemetryHandler`() {
val myNodeNum = 123
val telemetry =
Telemetry(
@ -479,7 +473,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, myNodeNum)
verify { connectionManager.updateTelemetry(any()) }
verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) }
}
// --- Text message handling ---
@ -490,10 +484,8 @@ class MeshDataHandlerTest {
MeshPacket(
id = 42,
from = 456,
decoded = Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "hello".encodeToByteArray().toByteString(),
),
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
@ -510,7 +502,8 @@ class MeshDataHandlerTest {
// 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")),
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
@ -525,10 +518,8 @@ class MeshDataHandlerTest {
MeshPacket(
id = 42,
from = 456,
decoded = Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "hello".encodeToByteArray().toByteString(),
),
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
@ -583,7 +574,7 @@ class MeshDataHandlerTest {
123 to Node(num = 123, user = User(id = "!local")),
)
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
every { nodeManager.myNodeNum } returns 123
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
handler.handleReceivedData(packet, 123)
@ -600,7 +591,8 @@ class MeshDataHandlerTest {
MeshPacket(
id = 55,
from = 456,
decoded = Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
decoded =
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
@ -616,7 +608,8 @@ class MeshDataHandlerTest {
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")),
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
@ -629,7 +622,7 @@ class MeshDataHandlerTest {
// --- Admin message handling ---
@Test
fun `admin message sets session passkey`() {
fun `admin message delegates to adminPacketHandler`() {
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()))
@ -644,7 +637,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { commandSender.setSessionPasskey(any()) }
verify { adminPacketHandler.handleAdminMessage(packet, 123) }
}
// --- Message filtering ---
@ -688,10 +681,8 @@ class MeshDataHandlerTest {
MeshPacket(
id = 88,
from = 456,
decoded = Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "hello".encodeToByteArray().toByteString(),
),
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(

View file

@ -0,0 +1,355 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.BeforeTest
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MeshMessageProcessorImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val meshLogRepository = mock<MeshLogRepository>(MockMode.autofill)
private val router = mock<MeshRouter>(MockMode.autofill)
private val fromRadioDispatcher = mock<FromRadioPacketHandler>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var processor: MeshMessageProcessorImpl
private val myNodeNum = 12345
private val isNodeDbReady = MutableStateFlow(false)
@BeforeTest
fun setUp() {
every { nodeManager.isNodeDbReady } returns isNodeDbReady
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
every { router.dataHandler } returns dataHandler
processor =
MeshMessageProcessorImpl(
nodeManager = nodeManager,
serviceRepository = serviceRepository,
meshLogRepository = lazy { meshLogRepository },
router = lazy { router },
fromRadioDispatcher = fromRadioDispatcher,
)
}
// ---------- handleFromRadio: non-packet variants ----------
@Test
fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) {
processor.start(backgroundScope)
val logRecord = LogRecord(message = "test log")
val fromRadio = FromRadio(log_record = logRecord)
val bytes = FromRadio.ADAPTER.encode(fromRadio)
processor.handleFromRadio(bytes, myNodeNum)
advanceUntilIdle()
verify { fromRadioDispatcher.handleFromRadio(any()) }
}
@Test
fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) {
processor.start(backgroundScope)
// Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails,
// fallback decode as LogRecord succeeds
val logRecord = LogRecord(message = "fallback log")
val bytes = LogRecord.ADAPTER.encode(logRecord)
processor.handleFromRadio(bytes, myNodeNum)
advanceUntilIdle()
// Should have been dispatched as a FromRadio with log_record set
verify { fromRadioDispatcher.handleFromRadio(any()) }
}
@Test
fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) {
processor.start(backgroundScope)
// Invalid protobuf bytes — both parses should fail
val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())
processor.handleFromRadio(garbage, myNodeNum)
advanceUntilIdle()
// No crash
}
// ---------- handleReceivedMeshPacket: early buffering ----------
@Test
fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
MeshPacket(
id = 1,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1000,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
// Packet should be buffered, not processed
// (no emitMeshPacket call since DB is not ready)
}
@Test
fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
MeshPacket(
id = 1,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1000,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
// Now make DB ready
isNodeDbReady.value = true
advanceUntilIdle()
// Buffered packet should have been flushed and processed
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}
@Test
fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = false
// The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test,
// but we test the boundary behavior conceptually. Instead, test that multiple
// packets are accumulated properly.
repeat(5) { i ->
val packet =
MeshPacket(
id = i,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1000 + i,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
}
advanceUntilIdle()
// Flush
isNodeDbReady.value = true
advanceUntilIdle()
// All 5 packets should have been processed
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}
// ---------- handleReceivedMeshPacket: rx_time normalization ----------
@Test
fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
MeshPacket(
id = 1,
from = myNodeNum,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 0, // should be replaced with current time
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}
@Test
fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
MeshPacket(
id = 2,
from = myNodeNum,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1700000000,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}
// ---------- handleReceivedMeshPacket: node updates ----------
@Test
fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
MeshPacket(
id = 10,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1700000000,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
// Should have called updateNode for myNodeNum (lastHeard update)
verify { nodeManager.updateNode(myNodeNum, withBroadcast = true, any(), any()) }
}
@Test
fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val senderNode = 999
val packet =
MeshPacket(
id = 10,
from = senderNode,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1700000000,
channel = 1,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
// Should have called updateNode for the sender
verify { nodeManager.updateNode(senderNode, withBroadcast = false, any(), any()) }
}
// ---------- handleReceivedMeshPacket: null decoded ----------
@Test
fun `packet with null decoded is skipped`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val packet = MeshPacket(id = 1, from = 999, decoded = null)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
// No crash, no emitMeshPacket call (decoded is null so processReceivedMeshPacket returns early)
}
// ---------- handleReceivedMeshPacket: null myNodeNum ----------
@Test
fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
MeshPacket(
id = 10,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1700000000,
)
processor.handleReceivedMeshPacket(packet, null)
advanceUntilIdle()
// emitMeshPacket should still be called, but node updates should be skipped
verifySuspend { serviceRepository.emitMeshPacket(any()) }
}
// ---------- clearEarlyPackets ----------
@Test
fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) {
processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
MeshPacket(
id = 1,
from = 999,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
rx_time = 1000,
)
processor.handleReceivedMeshPacket(packet, myNodeNum)
advanceUntilIdle()
processor.clearEarlyPackets()
advanceUntilIdle()
// Now make DB ready — the buffer should be empty, nothing to flush
isNodeDbReady.value = true
advanceUntilIdle()
// emitMeshPacket should NOT have been called (buffer was cleared)
}
// ---------- logVariant ----------
@Test
fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) {
processor.start(backgroundScope)
val logRecord = LogRecord(message = "device log")
val fromRadio = FromRadio(log_record = logRecord)
val bytes = FromRadio.ADAPTER.encode(fromRadio)
processor.handleFromRadio(bytes, myNodeNum)
advanceUntilIdle()
verifySuspend { meshLogRepository.insert(any()) }
}
}

View file

@ -188,7 +188,7 @@ class NodeManagerImplTest {
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertTrue(nodeManager.nodeDBbyID.isEmpty())
assertNull(nodeManager.myNodeNum)
assertNull(nodeManager.myNodeNum.value)
}
@Test

View file

@ -0,0 +1,341 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import 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
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
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.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import kotlin.test.BeforeTest
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class StoreForwardPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var handler: StoreForwardPacketHandlerImpl
private val myNodeNum = 12345
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
handler =
StoreForwardPacketHandlerImpl(
nodeManager = nodeManager,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
historyManager = historyManager,
dataHandler = lazy { dataHandler },
)
handler.start(testScope)
}
private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket {
val payload = StoreAndForward.ADAPTER.encode(sf).toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
}
private fun makeSfppPacket(from: Int, sfpp: StoreForwardPlusPlus): MeshPacket {
val payload = StoreForwardPlusPlus.ADAPTER.encode(sfpp).toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
}
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
bytes = null,
dataType = PortNum.STORE_FORWARD_APP.value,
)
// ---------- Legacy S&F: stats ----------
@Test
fun `handleStoreAndForward stats creates text data packet`() = testScope.runTest {
val sf =
StoreAndForward(
stats = StoreAndForward.Statistics(messages_total = 100, messages_saved = 50, messages_max = 200),
)
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
}
// ---------- Legacy S&F: history ----------
@Test
fun `handleStoreAndForward history creates text packet and updates last request`() = testScope.runTest {
val sf =
StoreAndForward(
history =
StoreAndForward.History(history_messages = 42, window = 3600000, last_request = 1700000000),
)
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
verify { historyManager.updateStoreForwardLastRequest("router_history", 1700000000, "Unknown") }
}
// ---------- Legacy S&F: heartbeat ----------
@Test
fun `handleStoreAndForward heartbeat does not crash`() = testScope.runTest {
val sf = StoreAndForward(heartbeat = StoreAndForward.Heartbeat(period = 900, secondary = 1))
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No crash, just logs
}
// ---------- Legacy S&F: text ----------
@Test
fun `handleStoreAndForward text with broadcast rr sets to broadcast`() = testScope.runTest {
val sf =
StoreAndForward(
text = "Hello from router".encodeToByteArray().toByteString(),
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST,
)
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
}
@Test
fun `handleStoreAndForward text without broadcast rr preserves destination`() = testScope.runTest {
val sf =
StoreAndForward(
text = "Direct message".encodeToByteArray().toByteString(),
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT,
)
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
}
// ---------- Legacy S&F: null payload ----------
@Test
fun `handleStoreAndForward with null payload returns early`() = testScope.runTest {
val packet = MeshPacket(from = 999, decoded = null)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No crash
}
// ---------- Legacy S&F: empty message ----------
@Test
fun `handleStoreAndForward with no fields set does not crash`() = testScope.runTest {
val sf = StoreAndForward()
val packet = makeSfPacket(999, sf)
val dataPacket = makeDataPacket(999)
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No crash — falls through to else branch
}
// ---------- SF++: LINK_PROVIDE ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE with message_hash updates status`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
encapsulated_id = 42,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02, 0x03, 0x04),
commit_hash = ByteString.EMPTY,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
}
// ---------- SF++: CANON_ANNOUNCE ----------
@Test
fun `handleStoreForwardPlusPlus CANON_ANNOUNCE updates status by hash`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE,
message_hash = ByteString.of(0xAA.toByte(), 0xBB.toByte()),
encapsulated_rxtime = 1700000000,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatusByHash(any(), any(), any()) }
}
// ---------- SF++: CHAIN_QUERY ----------
@Test
fun `handleStoreForwardPlusPlus CHAIN_QUERY logs info without crash`() = testScope.runTest {
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash, just logs
}
// ---------- SF++: LINK_REQUEST ----------
@Test
fun `handleStoreForwardPlusPlus LINK_REQUEST logs info without crash`() = testScope.runTest {
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash, just logs
}
// ---------- SF++: invalid payload ----------
@Test
fun `handleStoreForwardPlusPlus with null payload returns early`() = testScope.runTest {
val packet = MeshPacket(from = 999, decoded = null)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash
}
// ---------- SF++: fragment types ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE_FIRSTHALF handled as link provide`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
encapsulated_id = 55,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02),
commit_hash = ByteString.EMPTY,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE_SECONDHALF handled as link provide`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
encapsulated_id = 56,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x03, 0x04),
commit_hash = ByteString.EMPTY,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
// ---------- SF++: commit_hash present changes status ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE with commit_hash sets SFPP_CONFIRMED`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
encapsulated_id = 77,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02),
commit_hash = ByteString.of(0xAA.toByte()), // non-empty
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
}

View file

@ -0,0 +1,204 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.DataPacket
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.BeforeTest
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TelemetryPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var handler: TelemetryPacketHandlerImpl
private val myNodeNum = 12345
private val remoteNodeNum = 99999
@BeforeTest
fun setUp() {
handler =
TelemetryPacketHandlerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
notificationManager = notificationManager,
)
handler.start(testScope)
}
private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket {
val payload = Telemetry.ADAPTER.encode(telemetry).toByteString()
return MeshPacket(
from = from,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = payload),
rx_time = 1700000000,
)
}
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
bytes = null,
dataType = PortNum.TELEMETRY_APP.value,
)
// ---------- Device metrics from local node ----------
@Test
fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest {
val telemetry =
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f))
val packet = makeTelemetryPacket(myNodeNum, telemetry)
val dataPacket = makeDataPacket(myNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { connectionManager.updateTelemetry(any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
}
// ---------- Device metrics from remote node ----------
@Test
fun `remote device metrics updates node but not connectionManager`() = testScope.runTest {
val telemetry =
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f))
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
val dataPacket = makeDataPacket(remoteNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
}
// ---------- Environment metrics ----------
@Test
fun `environment metrics updates node with environment data`() = testScope.runTest {
val telemetry =
Telemetry(
time = 1700000000,
environment_metrics = EnvironmentMetrics(temperature = 25.5f, relative_humidity = 60.0f),
)
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
val dataPacket = makeDataPacket(remoteNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
}
// ---------- Power metrics ----------
@Test
fun `power metrics updates node with power data`() = testScope.runTest {
val telemetry = Telemetry(time = 1700000000, power_metrics = PowerMetrics(ch1_voltage = 3.3f))
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
val dataPacket = makeDataPacket(remoteNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
}
// ---------- Telemetry time handling ----------
@Test
fun `telemetry with time 0 gets time from dataPacket`() = testScope.runTest {
val telemetry = Telemetry(time = 0, device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.8f))
val packet = makeTelemetryPacket(myNodeNum, telemetry)
val dataPacket = makeDataPacket(myNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
}
// ---------- Null payload ----------
@Test
fun `handleTelemetry with null decoded payload returns early`() = testScope.runTest {
val packet = MeshPacket(from = myNodeNum, decoded = null)
val dataPacket = makeDataPacket(myNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No crash
}
@Test
fun `handleTelemetry with empty payload bytes returns early`() = testScope.runTest {
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = okio.ByteString.EMPTY),
)
val dataPacket = makeDataPacket(myNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No crash — decodeOrNull returns null for empty payload
}
// ---------- Battery notification: healthy battery does NOT trigger ----------
@Test
fun `healthy battery level does not trigger low battery notification`() = testScope.runTest {
val telemetry =
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.0f))
val packet = makeTelemetryPacket(myNodeNum, telemetry)
val dataPacket = makeDataPacket(myNodeNum)
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
// No dispatch call — battery is healthy
}
}

View file

@ -17,8 +17,10 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room3.Room
import androidx.room3.RoomDatabase
@ -63,5 +65,7 @@ actual fun deleteDatabase(dbName: String) {
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
/** Creates an Android DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> =
PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
produceFile = { ContextServices.app.preferencesDataStoreFile(name) },
)

View file

@ -62,7 +62,6 @@ open class DatabaseManager(
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
// Expose the DB cache limit as a reactive stream so UI can observe changes.
override val cacheLimit: StateFlow<Int> =
datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
@ -81,26 +80,35 @@ open class DatabaseManager(
}
}
private val dbCache = mutableMapOf<String, MeshtasticDatabase>()
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
/**
* The currently active database, built lazily on first access. Room's `onOpen` callback is itself lazy (not invoked
* until the first query), so construction only allocates the builder and connection pool actual I/O is deferred.
*/
override val currentDb: StateFlow<MeshtasticDatabase> =
_currentDb
.filterNotNull()
.stateIn(
managerScope,
SharingStarted.Eagerly,
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
)
.stateIn(managerScope, SharingStarted.Eagerly, getOrOpenDatabase(DatabaseConstants.DEFAULT_DB_NAME))
private val _currentAddress = MutableStateFlow<String?>(null)
val currentAddress: StateFlow<String?> = _currentAddress
private val dbCache = mutableMapOf<String, MeshtasticDatabase>() // key = dbName
/** Initialize the active database for [address]. */
suspend fun init(address: String?) {
switchActiveDatabase(address)
}
/**
* Returns a cached [MeshtasticDatabase] or builds a new one for [dbName]. The caller must hold [mutex] when
* modifying [dbCache] concurrently; however, this helper is also used from [currentDb]'s `initialValue` where the
* mutex is not yet relevant (single-threaded construction).
*/
private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase =
dbCache.getOrPut(dbName) { getDatabaseBuilder(dbName).build() }
/** Switch active database to the one associated with [address]. Serialized via mutex. */
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
val dbName = buildDbName(address)
@ -115,9 +123,11 @@ open class DatabaseManager(
}
// Build/open Room DB off the main thread
val db =
dbCache[dbName]
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) }
if (previousDbName != null && previousDbName != dbName) {
closeCachedDatabase(previousDbName)
}
_currentDb.value = db
_currentAddress.value = address
@ -134,6 +144,21 @@ open class DatabaseManager(
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
/**
* Closes and removes a cached database by name. Safe to call even if the database was already closed or not in the
* cache. Does NOT delete the underlying file the database can be re-opened on next access.
*
* On JVM/Desktop, Room KMP has no auto-close timeout (Android-only API), so idle databases hold open SQLite
* connections (5 per WAL-mode DB) indefinitely until explicitly closed. This method is the primary mechanism for
* releasing those connections when a database is no longer the active target.
*/
private fun closeCachedDatabase(dbName: String) {
val removed = dbCache.remove(dbName) ?: return
runCatching { removed.close() }
.onFailure { Logger.w(it) { "Failed to close cached database ${anonymizeDbName(dbName)}" } }
Logger.d { "Closed inactive database ${anonymizeDbName(dbName)} to free connections" }
}
private val limitedIo = dispatchers.io.limitedParallelism(4)
/** Execute [block] with the current DB instance. */
@ -184,9 +209,8 @@ open class DatabaseManager(
val limit = getCurrentCacheLimit()
val all = listExistingDbNames()
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
val deviceDbs = all.filterNot {
it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME
}
val deviceDbs =
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
if (deviceDbs.size <= limit) return@withLock
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
@ -194,12 +218,12 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
dbCache.remove(name)?.close()
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
}
.onFailure { Logger.w(it) { "Failed to evict database $name" } }
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
.onSuccess { Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } }
.onFailure { Logger.w(it) { "Failed to evict database ${anonymizeDbName(name)}" } }
}
}
@ -219,11 +243,11 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
dbCache.remove(legacy)?.close()
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
.onSuccess { Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } }
.onFailure { Logger.w(it) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } }
}
datastore.edit { it[legacyCleanedKey] = true }
}

View file

@ -17,8 +17,10 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
@ -31,8 +33,10 @@ import java.io.File
/**
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
*
* Shared between `core:database` and `desktop` module to ensure all persistent data is co-located.
*/
private fun desktopDataDir(): String {
fun desktopDataDir(): String {
val override = System.getenv("MESHTASTIC_DATA_DIR")
if (!override.isNullOrBlank()) return override
return System.getProperty("user.home") + "/.meshtastic"
@ -74,5 +78,8 @@ actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
File(dir).mkdirs()
return PreferenceDataStoreFactory.create(produceFile = { File(dir, "$name.preferences_pb") })
return PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
produceFile = { File(dir, "$name.preferences_pb") },
)
}

View file

@ -56,11 +56,15 @@ interface RadioController {
suspend fun favoriteNode(nodeNum: Int)
/**
* Sends our shared contact information (identity and public key) to a remote node.
* Sends our shared contact information (identity and public key) to the firmware's NodeDB.
*
* This ensures the firmware has the correct public key for the destination node before a PKI-encrypted direct
* message is sent. The method suspends until the radio acknowledges the admin packet.
*
* @param nodeNum The destination node number.
* @return `true` if the radio accepted the contact, `false` on timeout or failure.
*/
suspend fun sendSharedContact(nodeNum: Int)
suspend fun sendSharedContact(nodeNum: Int): Boolean
/**
* Updates the local radio configuration.

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model.service
import kotlinx.coroutines.CompletableDeferred
import org.meshtastic.core.model.Node
import org.meshtastic.proto.SharedContact
@ -32,5 +33,17 @@ sealed class ServiceAction {
data class ImportContact(val contact: SharedContact) : ServiceAction()
data class SendContact(val contact: SharedContact) : ServiceAction()
/**
* Sends a shared contact (identity + public key) to the firmware's NodeDB.
*
* The [result] deferred is completed with `true` when the radio acknowledges the admin packet, or `false` on
* timeout/failure. Callers that need to guarantee the contact is stored before sending a subsequent DM should
* `await()` this deferred.
*
* Not a data class: [result] is a [CompletableDeferred] with identity-based equality that would break data class
* equals/hashCode/copy semantics.
*/
class SendContact(val contact: SharedContact) : ServiceAction() {
val result: CompletableDeferred<Boolean> = CompletableDeferred()
}
}

View file

@ -21,8 +21,9 @@ package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
@ -36,7 +37,6 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
@ -84,6 +84,7 @@ internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long {
private const val CCCD_SETTLE_MS = 50L
private val SCAN_TIMEOUT = 5.seconds
private val GATT_CLEANUP_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
@ -157,7 +158,7 @@ class BleRadioInterface(
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
Logger.i { "[$address] Device not found in bonded list, scanning" }
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
@ -169,7 +170,7 @@ class BleRadioInterface(
}
if (d != null) return d
} catch (e: Exception) {
Logger.v(e) { "Scan attempt failed or timed out" }
Logger.v(e) { "[$address] Scan attempt failed or timed out" }
}
if (attempt < SCAN_RETRY_COUNT - 1) {
@ -182,106 +183,107 @@ class BleRadioInterface(
@Suppress("LongMethod")
private fun connect() {
connectionJob = connectionScope.launch {
while (isActive) {
try {
// Allow any pending background disconnects to complete and the Android BLE stack
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
val device = findDevice()
// Ensure the device is bonded before connecting. On Android, the
// firmware may require an encrypted link (pairing mode != NO_PIN).
// Without an explicit bond the GATT connection will fail with
// insufficient-authentication (status 5) or the dreaded status 133.
// On Desktop/JVM this is a no-op since the OS handles pairing during
// the GATT connection when the peripheral requires it.
if (!bluetoothRepository.isBonded(address)) {
Logger.i { "[$address] Device not bonded, initiating bonding..." }
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(device)
Logger.i { "[$address] Bonding successful" }
} catch (e: Exception) {
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
}
}
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with
// NotConnectedException if the previous peripheral wasn't fully cleaned
// up by the OS. A quick retry resolves it.
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
connectionJob =
connectionScope.launch {
while (isActive) {
try {
// Allow any pending background disconnects to complete and the Android BLE stack
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
delay(1500L)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
val connectDelayMs = 1000L
delay(connectDelayMs)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
// Connection succeeded — reset failure counter
consecutiveFailures = 0
isFullyConnected = true
onConnected()
val device = findDevice()
// Use coroutineScope so that the connectionState listener is scoped to this
// iteration only. When the inner scope exits (on disconnect), the listener is
// cancelled automatically before the next reconnect cycle starts a fresh one.
coroutineScope {
bleConnection.connectionState
.onEach { s ->
if (s is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected()
}
// Ensure the device is bonded before connecting. On Android, the
// firmware may require an encrypted link (pairing mode != NO_PIN).
// Without an explicit bond the GATT connection will fail with
// insufficient-authentication (status 5) or the dreaded status 133.
// On Desktop/JVM this is a no-op since the OS handles pairing during
// the GATT connection when the peripheral requires it.
if (!bluetoothRepository.isBonded(address)) {
Logger.i { "[$address] Device not bonded, initiating bonding" }
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(device)
Logger.i { "[$address] Bonding successful" }
} catch (e: Exception) {
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
}
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" } }
.launchIn(this)
}
discoverServicesAndSetupCharacteristics()
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with
// NotConnectedException if the previous peripheral wasn't fully cleaned
// up by the OS. A quick retry resolves it.
Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" }
@Suppress("MagicNumber")
delay(1500L)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
// Connection succeeded — reset failure counter
consecutiveFailures = 0
isFullyConnected = true
onConnected()
// Use coroutineScope so that the connectionState listener is scoped to this
// iteration only. When the inner scope exits (on disconnect), the listener is
// cancelled automatically before the next reconnect cycle starts a fresh one.
coroutineScope {
bleConnection.connectionState
.onEach { s ->
if (s is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected()
}
}
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
.launchIn(this)
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
}
Logger.i { "[$address] BLE connection dropped, preparing to reconnect" }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
consecutiveFailures++
Logger.w(e) {
"[$address] Failed to connect to device after ${failureTime}ms " +
"(consecutive failures: $consecutiveFailures)"
}
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
// onDisconnect signals would reset upstream state machines unnecessarily.
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
// Reduces BLE stack pressure and battery drain when the device is genuinely
// out of range, while still recovering quickly from transient drops.
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
delay(backoffMs)
}
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
consecutiveFailures++
Logger.w(e) {
"[$address] Failed to connect to device after ${failureTime}ms " +
"(consecutive failures: $consecutiveFailures)"
}
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
// onDisconnect signals would reset upstream state machines unnecessarily.
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
// Reduces BLE stack pressure and battery drain when the device is genuinely
// out of range, while still recovering quickly from transient drops.
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
delay(backoffMs)
}
}
}
}
private suspend fun onConnected() {
@ -304,8 +306,8 @@ class BleRadioInterface(
} else {
0
}
Logger.w {
"[$address] BLE disconnected, " +
Logger.i {
"[$address] BLE disconnected - " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
@ -324,7 +326,7 @@ class BleRadioInterface(
// Wire up notifications
radioService.fromRadio
.onEach { packet ->
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
@ -335,7 +337,7 @@ class BleRadioInterface(
radioService.logRadio
.onEach { packet ->
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
@ -393,10 +395,9 @@ class BleRadioInterface(
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
Logger.v {
"[$address] Wrote packet #$packetsSent " +
"to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
}
} catch (e: Exception) {
Logger.w(e) {
@ -422,7 +423,7 @@ class BleRadioInterface(
// Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
// firmware's per-connection duplicate-write filter from silently dropping it.
val nonce = heartbeatNonce.fetchAndAdd(1)
Logger.d { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
}
@ -437,19 +438,18 @@ class BleRadioInterface(
}
// Cancel the connection scope to break the while(isActive) reconnect loop.
connectionScope.cancel("close() called")
// GATT cleanup must run regardless of serviceScope lifecycle. SharedRadioInterfaceService
// cancels serviceScope immediately after calling close(), so launching on serviceScope is
// not reliable — the coroutine may never start. We use withContext(NonCancellable) inside
// a serviceScope.launch to guarantee cleanup completes even if the scope is cancelled
// mid-flight, preventing leaked BluetoothGatt objects (GATT 133 errors).
// GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls
// close() and then immediately cancels serviceScope — a coroutine launched on serviceScope
// may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the
// next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived,
// fire-and-forget, and must outlive any application-managed scope.
// onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly.
serviceScope.launch {
withContext(NonCancellable) {
try {
bleConnection.disconnect()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch {
try {
withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
}
}
@ -457,9 +457,9 @@ class BleRadioInterface(
private fun dispatchPacket(packet: ByteArray) {
packetsReceived++
bytesReceived += packet.size
Logger.d {
"[$address] Dispatching packet to service.handleFromRadio() - " +
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
Logger.v {
"[$address] Dispatching packet #$packetsReceived " +
"(${packet.size} bytes, total RX: $bytesReceived bytes)"
}
service.handleFromRadio(packet)
}

View file

@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@ -34,12 +33,14 @@ import java.io.OutputStream
import java.net.InetAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Shared JVM TCP transport for Meshtastic radios.
*
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff, heartbeat) and uses [StreamFrameCodec]
* for the START1/START2 stream framing protocol.
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the
* START1/START2 stream framing protocol. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class
* only exposes [sendHeartbeat] for external callers.
*
* Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
*/
@ -69,18 +70,24 @@ class TcpTransport(
const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L
const val SOCKET_TIMEOUT_MS = 5_000
const val SOCKET_RETRIES = 18 // 18 * 5s = 90s inactivity before disconnect
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
const val TIMEOUT_LOG_INTERVAL = 5
private const val MILLIS_PER_SECOND = 1_000L
}
private val codec = StreamFrameCodec(onPacketReceived = { listener.onPacketReceived(it) }, logTag = logTag)
private val codec =
StreamFrameCodec(
onPacketReceived = {
packetsReceived++
listener.onPacketReceived(it)
},
logTag = logTag,
)
// TCP socket state
private var socket: Socket? = null
private var outStream: OutputStream? = null
private var connectionJob: Job? = null
private var heartbeatJob: Job? = null
private var currentAddress: String? = null
// Metrics
private var connectionStartTime: Long = 0
@ -101,6 +108,7 @@ class TcpTransport(
*/
fun start(address: String) {
stop()
currentAddress = address
connectionJob = scope.handledLaunch { connectWithRetry(address) }
}
@ -109,6 +117,7 @@ class TcpTransport(
connectionJob?.cancel()
connectionJob = null
disconnectSocket()
currentAddress = null
}
/**
@ -134,14 +143,25 @@ class TcpTransport(
var backoff = MIN_BACKOFF_MILLIS
while (retryCount <= MAX_RECONNECT_RETRIES) {
try {
connectAndRead(address)
} catch (ex: IOException) {
Logger.w { "$logTag: [$address] TCP connection error - ${ex.message}" }
disconnectSocket()
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
Logger.e(ex) { "$logTag: [$address] TCP exception - ${ex.message}" }
disconnectSocket()
val hadData =
try {
connectAndRead(address)
} catch (ex: IOException) {
Logger.w { "$logTag: [$address] TCP connection error" }
disconnectSocket()
false
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
Logger.e(ex) { "$logTag: [$address] TCP exception" }
disconnectSocket()
false
}
// Reset backoff after a connection that successfully exchanged data,
// so transient firmware-side disconnects recover quickly.
if (hadData) {
Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange" }
retryCount = 1
backoff = MIN_BACKOFF_MILLIS
}
val delaySec = backoff / MILLIS_PER_SECOND
@ -152,13 +172,17 @@ class TcpTransport(
}
}
/**
* Connect to the given address, read data until the connection is lost, and return whether any bytes were
* successfully received (used by [connectWithRetry] to decide whether to reset backoff).
*/
@Suppress("NestedBlockDepth")
private suspend fun connectAndRead(address: String) = withContext(dispatchers.io) {
private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) {
val parts = address.split(":", limit = 2)
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: StreamFrameCodec.DEFAULT_TCP_PORT
Logger.i { "$logTag: [$address] Connecting to $host:$port..." }
Logger.i { "$logTag: [$address] Connecting to $host:$port" }
val attemptStart = nowMillis
Socket(InetAddress.getByName(host), port).use { sock ->
@ -181,7 +205,6 @@ class TcpTransport(
// Send wake bytes and signal connected
sendBytesRaw(StreamFrameCodec.WAKE_BYTES)
listener.onConnected()
startHeartbeat(address)
// Read loop
var timeoutCount = 0
@ -189,7 +212,7 @@ class TcpTransport(
try {
val c = input.read()
if (c == -1) {
Logger.w { "$logTag: [$address] EOF after $packetsReceived packets" }
Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" }
break
}
timeoutCount = 0
@ -209,27 +232,25 @@ class TcpTransport(
}
}
}
val hadData = bytesReceived > 0
disconnectSocket()
hadData
}
}
// Guards against recursive disconnects triggered by listener callbacks.
private var isDisconnecting: Boolean = false
private val isDisconnecting = AtomicBoolean(false)
private fun disconnectSocket() {
if (isDisconnecting) return
if (!isDisconnecting.compareAndSet(false, true)) return
isDisconnecting = true
try {
heartbeatJob?.cancel()
heartbeatJob = null
val s = socket
val hadConnection = s != null || outStream != null
if (s != null) {
val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
Logger.i {
"$logTag: Disconnecting - Uptime: ${uptime}ms, " +
"$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " +
"RX: $packetsReceived ($bytesReceived bytes), " +
"TX: $packetsSent ($bytesSent bytes)"
}
@ -247,7 +268,7 @@ class TcpTransport(
listener.onDisconnected()
}
} finally {
isDisconnecting = false
isDisconnecting.set(false)
}
}
@ -259,7 +280,7 @@ class TcpTransport(
val stream =
outStream
?: run {
Logger.w { "$logTag: Cannot send ${p.size} bytes: not connected" }
Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" }
return
}
packetsSent++
@ -267,7 +288,7 @@ class TcpTransport(
try {
stream.write(p)
} catch (ex: IOException) {
Logger.w(ex) { "$logTag: TCP write error: ${ex.message}" }
Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" }
disconnectSocket()
}
}
@ -277,28 +298,13 @@ class TcpTransport(
try {
stream.flush()
} catch (ex: IOException) {
Logger.w(ex) { "$logTag: TCP flush error: ${ex.message}" }
Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" }
disconnectSocket()
}
}
// endregion
// region Heartbeat
private fun startHeartbeat(address: String) {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (true) {
delay(HEARTBEAT_INTERVAL_MILLIS)
Logger.d { "$logTag: [$address] Sending heartbeat" }
sendHeartbeat()
}
}
}
// endregion
private fun resetMetrics() {
packetsReceived = 0
packetsSent = 0

View file

@ -25,12 +25,17 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.meshtastic.core.network.radio.StreamInterface
import org.meshtastic.core.repository.RadioInterfaceService
import java.io.File
/**
* JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet
* framing.
*
* Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read
* loop is started.
*/
class SerialTransport(
class SerialTransport
private constructor(
private val portName: String,
private val baudRate: Int = DEFAULT_BAUD_RATE,
service: RadioInterfaceService,
@ -39,7 +44,7 @@ class SerialTransport(
private var readJob: Job? = null
/** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
fun startConnection(): Boolean {
private fun startConnection(): Boolean {
return try {
val port = SerialPort.getCommPort(portName) ?: return false
port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
@ -48,20 +53,23 @@ class SerialTransport(
serialPort = port
port.setDTR()
port.setRTS()
Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
super.connect() // Sends WAKE_BYTES and signals service.onConnect()
startReadLoop(port)
true
} else {
Logger.w { "[$portName] Serial port openPort() returned false" }
false
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Serial connection failed" }
Logger.w(e) { "[$portName] Serial connection failed" }
false
}
}
@Suppress("CyclomaticComplexMethod")
private fun startReadLoop(port: SerialPort) {
Logger.d { "[$portName] Starting serial read loop" }
readJob =
service.serviceScope.launch(Dispatchers.IO) {
val input = port.inputStream
@ -84,9 +92,9 @@ class SerialTransport(
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (isActive) {
Logger.e(e) { "Serial read IOException: ${e.message}" }
Logger.w(e) { "[$portName] Serial read error" }
} else {
Logger.d { "Serial read interrupted by cancellation: ${e.message}" }
Logger.d { "[$portName] Serial read interrupted by cancellation" }
}
reading = false
}
@ -95,11 +103,12 @@ class SerialTransport(
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
if (isActive) {
Logger.e(e) { "Serial read loop outer error: ${e.message}" }
Logger.w(e) { "[$portName] Serial read loop outer error" }
} else {
Logger.d { "Serial read loop outer interrupted by cancellation: ${e.message}" }
Logger.d { "[$portName] Serial read loop interrupted by cancellation" }
}
} finally {
Logger.d { "[$portName] Serial read loop exiting" }
try {
input.close()
} catch (_: Exception) {
@ -137,6 +146,7 @@ class SerialTransport(
}
override fun close() {
Logger.d { "[$portName] Closing serial transport" }
readJob?.cancel()
readJob = null
closePortResources()
@ -149,10 +159,64 @@ class SerialTransport(
private const val READ_BUFFER_SIZE = 1024
private const val READ_TIMEOUT_MS = 100
/**
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
* disconnect to the [service] and returns the (non-connected) instance.
*/
fun open(portName: String, baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService): SerialTransport {
val transport = SerialTransport(portName, baudRate, service)
if (!transport.startConnection()) {
val errorMessage = diagnoseOpenFailure(portName)
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
service.onDisconnect(isPermanent = true, errorMessage = errorMessage)
}
return transport
}
/**
* Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
* "COM3", "/dev/ttyUSB0").
*/
fun getAvailablePorts(): List<String> = SerialPort.getCommPorts().map { it.systemPortName }
/**
* Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks
* file permissions and suggests the appropriate group fix.
*/
@Suppress("ReturnCount")
private fun diagnoseOpenFailure(portName: String): String {
val osName = System.getProperty("os.name", "").lowercase()
if (!osName.contains("linux")) {
return "Could not open serial port: $portName"
}
// jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0"
val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
val portFile = File(devPath)
if (!portFile.exists()) {
return "Serial port $portName not found. Is the device still connected?"
}
if (!portFile.canRead() || !portFile.canWrite()) {
val group = detectSerialGroup(devPath)
val user = System.getProperty("user.name", "your_user")
return "Permission denied for $devPath. " +
"Run: sudo usermod -aG $group $user — then log out and back in."
}
return "Could not open serial port: $portName"
}
/**
* Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu
* default) if detection fails.
*/
@Suppress("SwallowedException", "TooGenericExceptionCaught")
private fun detectSerialGroup(devPath: String): String = try {
val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
val group = process.inputStream.bufferedReader().readText().trim()
process.waitFor()
group.ifEmpty { "dialout" }
} catch (e: Exception) {
"dialout"
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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 org.meshtastic.proto.MeshPacket
/** Interface for handling admin messages from the mesh (config, metadata, session passkey). */
interface AdminPacketHandler {
/**
* Processes an admin message packet.
*
* @param packet The received mesh packet.
* @param myNodeNum The local node number.
*/
fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int)
}

View file

@ -56,6 +56,21 @@ interface CommandSender {
initFn: () -> AdminMessage,
)
/**
* Sends an admin message and suspends until the radio acknowledges it.
*
* This is used when the caller needs to guarantee a packet has been accepted by the radio before proceeding, such
* as sending a shared contact before the first DM to a node.
*
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
*/
suspend fun sendAdminAwait(
destNum: Int,
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: () -> AdminMessage,
): Boolean
/** Sends our current position to the mesh. */
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)

View file

@ -29,7 +29,7 @@ interface MeshActionHandler {
fun start(scope: CoroutineScope)
/** Processes a service action from the UI. */
fun onServiceAction(action: ServiceAction)
suspend fun onServiceAction(action: ServiceAction)
/** Sets the owner of the local node. */
fun handleSetOwner(u: MeshUser, myNodeNum: Int)

View file

@ -54,8 +54,11 @@ interface NodeManager : NodeIdLookup {
/** Starts the node manager with the given coroutine scope. */
fun start(scope: CoroutineScope)
/** The local node number. */
var myNodeNum: Int?
/** The local node number as a thread-safe [StateFlow]. */
val myNodeNum: StateFlow<Int?>
/** Sets the local node number. */
fun setMyNodeNum(num: Int?)
/** Loads the cached node database from the repository. */
fun loadCachedNodeDB()

View file

@ -32,6 +32,17 @@ interface PacketHandler {
/** Adds a mesh packet to the queue for sending. */
fun sendToRadio(packet: MeshPacket)
/**
* Adds a mesh packet to the queue and suspends until the radio acknowledges it via [QueueStatus].
*
* Unlike [sendToRadio], which is fire-and-forget, this method provides back-pressure so the caller can ensure a
* packet has been accepted by the radio before proceeding. This is critical for operations where ordering matters
* (e.g., sending a shared contact before the first DM).
*
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
*/
suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean
/** Processes queue status updates from the radio. */
fun handleQueueStatus(queueStatus: QueueStatus)

View file

@ -68,6 +68,9 @@ interface RadioInterfaceService {
/** Called by an interface when it has received raw data from the radio. */
fun handleFromRadio(bytes: ByteArray)
/** Flow of user-facing connection error messages (e.g. permission failures). */
val connectionError: SharedFlow<String>
/** The scope in which interface-related coroutines should run. */
val serviceScope: CoroutineScope
}

View file

@ -0,0 +1,36 @@
/*
* 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 telemetry packets from the mesh, including battery notifications. */
interface TelemetryPacketHandler {
/** Starts the handler with the given coroutine scope. */
fun start(scope: CoroutineScope)
/**
* Processes a telemetry packet.
*
* @param packet The received mesh packet.
* @param dataPacket The decoded data packet.
* @param myNodeNum The local node number.
*/
fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
}

View file

@ -130,7 +130,10 @@ class SendMessageUseCaseImpl(
private suspend fun sendSharedContact(node: Node) {
try {
radioController.sendSharedContact(node.num)
val accepted = radioController.sendSharedContact(node.num)
if (!accepted) {
Logger.w { "Shared contact for node ${node.num} was not acknowledged by the radio" }
}
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}

View file

@ -21,9 +21,7 @@ import android.app.Application
import androidx.core.location.LocationCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
@ -37,7 +35,7 @@ import org.meshtastic.proto.Position as ProtoPosition
@Single
class AndroidMeshLocationManager(private val context: Application, private val locationRepository: LocationRepository) :
MeshLocationManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private lateinit var scope: CoroutineScope
private var locationFlow: Job? = null
@SuppressLint("MissingPermission")

View file

@ -54,7 +54,7 @@ class AndroidRadioControllerImpl(
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
}
override suspend fun sendSharedContact(nodeNum: Int) {
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
val nodeDef = nodeRepository.getNode(nodeNum.toString())
val contact =
org.meshtastic.proto.SharedContact(
@ -62,7 +62,9 @@ class AndroidRadioControllerImpl(
user = nodeDef.user,
manually_verified = nodeDef.manuallyVerified,
)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
val action = ServiceAction.SendContact(contact)
serviceRepository.onServiceAction(action)
return action.result.await()
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {

View file

@ -37,6 +37,7 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@ -73,7 +74,7 @@ class MeshService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val myNodeNum: Int
get() = nodeManager.myNodeNum ?: throw RadioNotConnectedException()
get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException()
companion object {
fun actionReceived(portNum: Int): String {
@ -98,11 +99,11 @@ class MeshService : Service() {
try {
super.onCreate()
} catch (e: IllegalStateException) {
// Hilt can throw IllegalStateException in tests if the component is not created.
// Koin can throw IllegalStateException in tests if the component is not created.
// This can happen if the service is started by the system (e.g. after a crash or on boot)
// before the test rule has a chance to create the component.
if (e.message?.contains("HiltAndroidRule") == true) {
Logger.w(e) { "MeshService created before Hilt component was ready in test. Stopping service." }
if (e.message?.contains("HiltAndroidRule") == true || e.message?.contains("Koin") == true) {
Logger.w(e) { "MeshService created before DI component was ready in test, stopping service" }
stopSelf()
return
}
@ -188,7 +189,7 @@ class MeshService : Service() {
object : IMeshService.Stub() {
@Suppress("OVERRIDE_DEPRECATION")
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." }
Logger.d { "Passing through device change to radio service: ${deviceAddr?.anonymize}" }
router.actionHandler.handleUpdateLastAddress(deviceAddr)
radioInterfaceService.setDeviceAddress(deviceAddr)
}
@ -300,7 +301,7 @@ class MeshService : Service() {
}
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
val myNodeNum = nodeManager.myNodeNum
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum != null) {
router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
} else {

View file

@ -61,7 +61,7 @@ class DirectRadioControllerImpl(
get() = router.actionHandler
private val myNodeNum: Int
get() = nodeManager.myNodeNum ?: 0
get() = nodeManager.myNodeNum.value ?: 0
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState
@ -82,11 +82,13 @@ class DirectRadioControllerImpl(
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
}
override suspend fun sendSharedContact(nodeNum: Int) {
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
val nodeDef = nodeRepository.getNode(nodeNum.toString())
val contact =
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
val action = ServiceAction.SendContact(contact)
serviceRepository.onServiceAction(action)
return action.result.await()
}
override suspend fun setLocalConfig(config: Config) {
@ -178,7 +180,7 @@ class DirectRadioControllerImpl(
}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
val myNode = nodeManager.myNodeNum
val myNode = nodeManager.myNodeNum.value
if (myNode != null) {
actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode)
} else {

View file

@ -17,11 +17,13 @@
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
@ -60,6 +62,7 @@ class MeshServiceOrchestrator(
private val takMeshIntegration: TAKMeshIntegration,
private val takPrefs: TakPrefs,
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
private val databaseManager: DatabaseManager,
) {
private var serviceJob: Job? = null
private var takJob: Job? = null
@ -80,7 +83,7 @@ class MeshServiceOrchestrator(
*/
fun start() {
if (isRunning) {
Logger.w { "MeshServiceOrchestrator.start() called while already running" }
Logger.d { "start() called while already running, ignoring" }
return
}
@ -104,22 +107,41 @@ class MeshServiceOrchestrator(
takPrefs.isTakServerEnabled
.onEach { isEnabled ->
if (isEnabled && !takServerManager.isRunning.value) {
Logger.i { "TAK Server enabled by preference, starting integration..." }
Logger.i { "TAK Server enabled by preference, starting integration" }
takMeshIntegration.start(scope)
} else if (!isEnabled && takServerManager.isRunning.value) {
Logger.i { "TAK Server disabled by preference, stopping integration..." }
Logger.i { "TAK Server disabled by preference, stopping integration" }
takMeshIntegration.stop()
}
}
.launchIn(scope)
scope.handledLaunch { radioInterfaceService.connect() }
scope.handledLaunch {
// Ensure the per-device database is active before the radio connects.
// On Android this is handled by MeshUtilApplication.init(); on Desktop (and any
// future KMP host) the orchestrator is the first entry point, so it must initialize
// the database here. Without this, DatabaseManager._currentDb stays null and all
// Room writes via withDb() are silently dropped — causing ourNodeInfo to remain null
// after the handshake completes.
databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress())
Logger.i { "Per-device database initialized, connecting radio" }
radioInterfaceService.connect()
}
radioInterfaceService.receivedData
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) }
.launchIn(scope)
serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(scope)
radioInterfaceService.connectionError
.onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) }
.launchIn(scope)
// Each action is dispatched in its own supervised coroutine so that a failure in one
// action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently
// drop all subsequent service actions for the rest of the session.
serviceRepository.serviceAction
.onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } }
.launchIn(scope)
nodeManager.loadCachedNodeDB()
}

View file

@ -93,7 +93,7 @@ class SharedRadioInterfaceService(
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
override val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
override val serviceScope: CoroutineScope
get() = _serviceScope
@ -142,7 +142,7 @@ class SharedRadioInterfaceService(
}
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
@ -155,7 +155,7 @@ class SharedRadioInterfaceService(
}
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed" } }
.launchIn(processLifecycle.coroutineScope)
}
}
@ -215,7 +215,7 @@ class SharedRadioInterfaceService(
val address = getBondedDeviceAddress()
if (address == null) {
Logger.w { "No valid address to connect to." }
Logger.d { "No valid address to connect to" }
return
}
@ -245,12 +245,13 @@ class SharedRadioInterfaceService(
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = serviceScope.launch {
while (true) {
delay(HEARTBEAT_INTERVAL_MILLIS)
keepAlive()
heartbeatJob =
serviceScope.launch {
while (true) {
delay(HEARTBEAT_INTERVAL_MILLIS)
keepAlive()
}
}
}
}
fun keepAlive(now: Long = nowMillis) {
@ -273,16 +274,18 @@ class SharedRadioInterfaceService(
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
_meshActivity.tryEmit(MeshActivity.Receive)
} catch (t: Throwable) {
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
Logger.e(t) { "handleFromRadio failed while emitting data" }
}
}
override fun onConnect() {
// MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than
// launching a coroutine. The async launch pattern introduced a window where a concurrent
// onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck
// in Connected while the transport was actually disconnected.
if (_connectionState.value != ConnectionState.Connected) {
Logger.d { "Broadcasting connection state change to Connected" }
processLifecycle.coroutineScope.launch(dispatchers.default) {
_connectionState.emit(ConnectionState.Connected)
}
_connectionState.value = ConnectionState.Connected
}
}
@ -293,7 +296,7 @@ class SharedRadioInterfaceService(
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
Logger.d { "Broadcasting connection state change to $newTargetState" }
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newTargetState) }
_connectionState.value = newTargetState
}
}
}

View file

@ -16,17 +16,24 @@
*/
package org.meshtastic.core.service
import co.touchlab.kermit.Severity
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
@ -57,25 +64,35 @@ class MeshServiceOrchestratorTest {
private val commandSender: CommandSender = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val router: MeshRouter = mock(MockMode.autofill)
private val actionHandler: MeshActionHandler = mock(MockMode.autofill)
private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
private val takPrefs: TakPrefs = mock(MockMode.autofill)
private val cotHandler: CoTHandler = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
@Test
fun testStartWiresComponents() {
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
/** Stubs the shared flow dependencies used by every test and returns an orchestrator. */
private fun createOrchestrator(
receivedData: MutableSharedFlow<ByteArray> = MutableSharedFlow(),
connectionError: MutableSharedFlow<String> = MutableSharedFlow(),
serviceAction: MutableSharedFlow<ServiceAction> = MutableSharedFlow(),
takEnabledFlow: MutableStateFlow<Boolean> = MutableStateFlow(false),
takRunningFlow: MutableStateFlow<Boolean> = MutableStateFlow(false),
): MeshServiceOrchestrator {
every { radioInterfaceService.receivedData } returns receivedData
every { radioInterfaceService.connectionError } returns connectionError
every { serviceRepository.serviceAction } returns serviceAction
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns MutableStateFlow(false)
every { takServerManager.isRunning } returns MutableStateFlow(false)
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { router.actionHandler } returns actionHandler
val takMeshIntegration =
TAKMeshIntegration(
@ -87,22 +104,27 @@ class MeshServiceOrchestratorTest {
cotHandler = cotHandler,
)
val orchestrator =
MeshServiceOrchestrator(
radioInterfaceService = radioInterfaceService,
serviceRepository = serviceRepository,
packetHandler = packetHandler,
nodeManager = nodeManager,
messageProcessor = messageProcessor,
commandSender = commandSender,
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
dispatchers = dispatchers,
)
return MeshServiceOrchestrator(
radioInterfaceService = radioInterfaceService,
serviceRepository = serviceRepository,
packetHandler = packetHandler,
nodeManager = nodeManager,
messageProcessor = messageProcessor,
commandSender = commandSender,
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
dispatchers = dispatchers,
databaseManager = databaseManager,
)
}
@Test
fun testStartWiresComponents() {
val orchestrator = createOrchestrator()
assertFalse(orchestrator.isRunning)
orchestrator.start()
@ -121,41 +143,7 @@ class MeshServiceOrchestratorTest {
val takEnabledFlow = MutableStateFlow(false)
val takRunningFlow = MutableStateFlow(false)
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
cotHandler = cotHandler,
)
val orchestrator =
MeshServiceOrchestrator(
radioInterfaceService = radioInterfaceService,
serviceRepository = serviceRepository,
packetHandler = packetHandler,
nodeManager = nodeManager,
messageProcessor = messageProcessor,
commandSender = commandSender,
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
dispatchers = dispatchers,
)
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
orchestrator.start()
@ -172,4 +160,67 @@ class MeshServiceOrchestratorTest {
orchestrator.stop()
}
@Test
fun testStartCallsSwitchActiveDatabase() {
every { radioInterfaceService.getDeviceAddress() } returns "tcp:192.168.1.100"
val orchestrator = createOrchestrator()
orchestrator.start()
verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.100") }
verify { radioInterfaceService.connect() }
orchestrator.stop()
}
@Test
fun testConnectionErrorForwardedToServiceRepository() {
val connectionError = MutableSharedFlow<String>(extraBufferCapacity = 1)
val orchestrator = createOrchestrator(connectionError = connectionError)
orchestrator.start()
// Emit an error into the radio interface's connectionError flow
connectionError.tryEmit("BLE connection lost")
verify { serviceRepository.setErrorMessage("BLE connection lost", Severity.Warn) }
orchestrator.stop()
}
@Test
fun testServiceActionDispatchedToActionHandler() {
val serviceAction = MutableSharedFlow<ServiceAction>(extraBufferCapacity = 1)
val orchestrator = createOrchestrator(serviceAction = serviceAction)
orchestrator.start()
val action = ServiceAction.Favorite(Node(num = 42))
serviceAction.tryEmit(action)
verifySuspend { actionHandler.onServiceAction(action) }
orchestrator.stop()
}
@Test
fun testStartIsIdempotent() {
val orchestrator = createOrchestrator()
orchestrator.start()
assertTrue(orchestrator.isRunning)
// Second call should be a no-op
orchestrator.start()
assertTrue(orchestrator.isRunning)
// Components should only be initialized once
verify(exactly(1)) { serviceNotifications.initChannels() }
verify(exactly(1)) { packetHandler.start(any()) }
verify(exactly(1)) { nodeManager.loadCachedNodeDB() }
orchestrator.stop()
assertFalse(orchestrator.isRunning)
}
}

View file

@ -73,8 +73,9 @@ class FakeRadioController :
favoritedNodes.add(nodeNum)
}
override suspend fun sendSharedContact(nodeNum: Int) {
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
sentSharedContacts.add(nodeNum)
return true
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}

View file

@ -46,6 +46,9 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity
private val _connectionError = MutableSharedFlow<String>()
override val connectionError: SharedFlow<String> = _connectionError
val sentToRadio = mutableListOf<ByteArray>()
var connectCalled = false

View file

@ -61,6 +61,7 @@ import okio.Path.Companion.toPath
import org.jetbrains.skia.Image
import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
@ -248,7 +249,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
},
) {
setSingletonImageLoaderFactory { context ->
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
val cacheDir = desktopDataDir() + "/image_cache_v3"
ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory(httpClient = httpClient))

View file

@ -34,6 +34,7 @@ import okio.Path.Companion.toPath
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
@ -43,16 +44,6 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
/**
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
*/
private fun desktopDataDir(): String {
val override = System.getenv("MESHTASTIC_DATA_DIR")
if (!override.isNullOrBlank()) return override
return System.getProperty("user.home") + "/.meshtastic"
}
/** Creates a file-backed [DataStore]<[Preferences]> at the given path under the data directory. */
private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
@ -90,7 +81,14 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
*/
@Suppress("InjectDispatcher")
fun desktopPlatformModule() = module {
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
// DataStore has no close() API — the in-memory cache is released only when this Job is cancelled
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope))
// -- Build config --
single<BuildConfigProvider> {
@ -109,10 +107,7 @@ fun desktopPlatformModule() = module {
}
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
@Suppress("InjectDispatcher")
private fun desktopPreferencesDataStoreModule() = module {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module {
single<DataStore<Preferences>>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
single<DataStore<Preferences>>(named("HomoglyphEncodingDataStore")) {
createPreferencesDataStore("homoglyph_encoding", scope)
@ -135,9 +130,7 @@ private fun desktopPreferencesDataStoreModule() = module {
}
/** Proto [DataStore] instances (OkioStorage-backed). */
@Suppress("InjectDispatcher")
private fun desktopProtoDataStoreModule() = module {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
val protoDir = desktopDataDir() + "/datastore"
single<DataStore<LocalConfig>>(named("CoreLocalConfigDataStore")) {

View file

@ -51,7 +51,10 @@ class DesktopRadioTransportFactory(
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
}
address.startsWith(InterfaceId.SERIAL.id) -> {
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
SerialTransport.open(
portName = address.removePrefix(InterfaceId.SERIAL.id.toString()),
service = service,
)
}
else -> error("Unsupported transport for address: $address")
}

View file

@ -75,6 +75,7 @@ class NoopRadioInterfaceService : RadioInterfaceService {
override val receivedData = MutableSharedFlow<ByteArray>()
override val meshActivity = MutableSharedFlow<MeshActivity>()
override val connectionError = MutableSharedFlow<String>()
override fun sendToRadio(bytes: ByteArray) {
logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)")

View file

@ -225,6 +225,27 @@ Ordered by impact × effort:
---
## F. JVM/Desktop Database Lifecycle
Room KMP's `setAutoCloseTimeout` API is Android-only. On JVM/Desktop, once a Room database is built, its SQLite connections (5 per WAL-mode DB: 4 readers + 1 writer) remain open indefinitely until explicitly closed via `RoomDatabase.close()`.
### Problem
When a user switches between multiple mesh devices, the previous device's database remained open in the in-memory cache. Each idle database consumed ~32 MB (connection pool + prepared statement caches), leading to unbounded memory growth proportional to the number of devices ever connected in a session.
### Solution
`DatabaseManager.switchActiveDatabase()` now explicitly closes the previously active database via `closeCachedDatabase()` before activating the new one. The closed database is removed from the in-memory cache but its file is preserved, allowing transparent re-opening on next access.
Additional fixes applied:
1. **Init-order bug**: `dbCache` was declared after `currentDb`, causing NPE during `stateIn`'s `initialValue` evaluation. Reordered to ensure `dbCache` is initialized first.
2. **Corruption handlers**: `ReplaceFileCorruptionHandler` added to `createDatabaseDataStore()` on both JVM and Android, preventing DataStore corruption from crashing the app.
3. **`desktopDataDir()` deduplication**: Made public in `core:database/jvmMain` and removed the duplicate from `DesktopPlatformModule`, establishing a single source of truth for the desktop data directory.
4. **DataStore scope consolidation**: Replaced two separate `CoroutineScope` instances with a single shared `dataStoreScope` in `DesktopPlatformModule`.
5. **Coil cache path**: Desktop `Main.kt` updated to use `desktopDataDir()` instead of hardcoded `user.home`.
---
## References
- Current migration status: [`kmp-status.md`](./kmp-status.md)

View file

@ -114,7 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. |
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |

View file

@ -194,6 +194,7 @@ fun ConnectionsScreen(
1 ->
ConnectingDeviceContent(
connectionState = connectionState,
selectedDevice = selectedDevice,
persistedDeviceName = persistedDeviceName,
bleDevices = bleDevices,
@ -328,6 +329,7 @@ private fun ConnectedDeviceContent(
/** Content shown when connecting or a device is selected but node info is not yet available. */
@Composable
private fun ConnectingDeviceContent(
connectionState: ConnectionState,
selectedDevice: String,
persistedDeviceName: String?,
bleDevices: List<DeviceListEntry>,
@ -348,7 +350,12 @@ private fun ConnectingDeviceContent(
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
ConnectingDeviceInfo(
connectionState = connectionState,
deviceName = name,
deviceAddress = address,
onClickDisconnect = onClickDisconnect,
)
}
}

View file

@ -34,18 +34,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@Composable
fun ConnectingDeviceInfo(
connectionState: ConnectionState,
deviceName: String,
deviceAddress: String,
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
val statusText =
if (connectionState.isConnected()) {
stringResource(Res.string.connected)
} else {
stringResource(Res.string.connecting)
}
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -58,7 +67,7 @@ fun ConnectingDeviceInfo(
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(Res.string.connecting),
text = statusText,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)

View file

@ -20,6 +20,7 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import co.touchlab.kermit.Logger
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.TelemetryType
@ -34,7 +35,11 @@ class RefreshLocalStatsAction :
private val nodeManager: NodeManager by inject()
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val myNodeNum = nodeManager.myNodeNum ?: return
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum == null) {
Logger.w { "RefreshLocalStatsAction: myNodeNum is null, skipping telemetry request" }
return
}
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)