From 6af3ad6f0c43e7e073140639ef004990c808e4d2 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 4 Apr 2026 13:07:44 -0500
Subject: [PATCH] =?UTF-8?q?refactor(service):=20harden=20KMP=20service=20l?=
=?UTF-8?q?ayer=20=E2=80=94=20database=20init,=20connection=20reliability,?=
=?UTF-8?q?=20handler=20decomposition=20(#4992)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../meshtastic/core/common/util/Exceptions.kt | 13 +-
.../core/common/util/ExceptionsTest.kt | 147 +++++
.../data/manager/AdminPacketHandlerImpl.kt | 86 +++
.../core/data/manager/CommandSenderImpl.kt | 28 +-
.../manager/FromRadioPacketHandlerImpl.kt | 1 -
.../data/manager/MeshActionHandlerImpl.kt | 27 +-
.../data/manager/MeshConfigFlowManagerImpl.kt | 235 ++++---
.../data/manager/MeshConfigHandlerImpl.kt | 43 +-
.../data/manager/MeshConnectionManagerImpl.kt | 52 +-
.../core/data/manager/MeshDataHandlerImpl.kt | 187 +-----
.../data/manager/MeshMessageProcessorImpl.kt | 20 +-
.../core/data/manager/MqttManagerImpl.kt | 4 +-
.../data/manager/NeighborInfoHandlerImpl.kt | 6 +-
.../core/data/manager/NodeManagerImpl.kt | 21 +-
.../core/data/manager/PacketHandlerImpl.kt | 125 ++--
.../manager/StoreForwardPacketHandlerImpl.kt | 17 +-
.../manager/TelemetryPacketHandlerImpl.kt | 170 +++++
.../data/manager/TracerouteHandlerImpl.kt | 4 +-
.../manager/AdminPacketHandlerImplTest.kt | 224 +++++++
.../data/manager/MeshActionHandlerImplTest.kt | 583 ++++++++++++++++++
.../manager/MeshConfigFlowManagerImplTest.kt | 377 +++++++++++
.../data/manager/MeshConfigHandlerImplTest.kt | 230 +++++++
.../manager/MeshConnectionManagerImplTest.kt | 2 +-
.../core/data/manager/MeshDataHandlerTest.kt | 59 +-
.../manager/MeshMessageProcessorImplTest.kt | 355 +++++++++++
.../core/data/manager/NodeManagerImplTest.kt | 2 +-
.../StoreForwardPacketHandlerImplTest.kt | 341 ++++++++++
.../manager/TelemetryPacketHandlerImplTest.kt | 204 ++++++
.../core/database/DatabaseBuilder.kt | 8 +-
.../core/database/DatabaseManager.kt | 64 +-
.../core/database/DatabaseBuilder.kt | 11 +-
.../meshtastic/core/model/RadioController.kt | 8 +-
.../core/model/service/ServiceAction.kt | 15 +-
.../core/network/radio/BleRadioInterface.kt | 236 +++----
.../core/network/transport/TcpTransport.kt | 94 +--
.../core/network/SerialTransport.kt | 78 ++-
.../core/repository/AdminPacketHandler.kt | 30 +
.../core/repository/CommandSender.kt | 15 +
.../core/repository/MeshActionHandler.kt | 2 +-
.../meshtastic/core/repository/NodeManager.kt | 7 +-
.../core/repository/PacketHandler.kt | 11 +
.../core/repository/RadioInterfaceService.kt | 3 +
.../core/repository/TelemetryPacketHandler.kt | 36 ++
.../repository/usecase/SendMessageUseCase.kt | 5 +-
.../service/AndroidMeshLocationManager.kt | 4 +-
.../service/AndroidRadioControllerImpl.kt | 6 +-
.../meshtastic/core/service/MeshService.kt | 13 +-
.../core/service/DirectRadioControllerImpl.kt | 10 +-
.../core/service/MeshServiceOrchestrator.kt | 34 +-
.../service/SharedRadioInterfaceService.kt | 31 +-
.../service/MeshServiceOrchestratorTest.kt | 165 +++--
.../core/testing/FakeRadioController.kt | 3 +-
.../core/testing/FakeRadioInterfaceService.kt | 3 +
.../kotlin/org/meshtastic/desktop/Main.kt | 3 +-
.../desktop/di/DesktopPlatformModule.kt | 29 +-
.../radio/DesktopRadioTransportFactory.kt | 5 +-
.../org/meshtastic/desktop/stub/NoopStubs.kt | 1 +
docs/decisions/architecture-review-2026-03.md | 21 +
docs/kmp-status.md | 2 +-
.../connections/ui/ConnectionsScreen.kt | 9 +-
.../ui/components/ConnectingDeviceInfo.kt | 11 +-
.../feature/widget/RefreshLocalStatsAction.kt | 7 +-
62 files changed, 3808 insertions(+), 735 deletions(-)
create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ExceptionsTest.kt
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
index c0a728312..ccd565286 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
@@ -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.
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ExceptionsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ExceptionsTest.kt
new file mode 100644
index 000000000..744cba347
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ExceptionsTest.kt
@@ -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 .
+ */
+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? = 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? = 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") }
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt
new file mode 100644
index 000000000..d4e0cdca2
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt
@@ -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 .
+ */
+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,
+ private val configFlowManager: Lazy,
+ 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" }
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index ff3600ee5..3a0459241 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -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,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index 9a84026fa..db598fd51 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -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)
}
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index b6af57415..e628bb72e 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -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,
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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index 2e880bb3b..4c1c60425 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -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()
- 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 1→2 transition. [nodes] accumulates [NodeInfo] packets until
+ * `config_complete_id` arrives.
+ */
+ data class ReceivingNodeInfo(
+ val myNodeInfo: SharedMyNodeInfo,
+ val nodes: MutableList = 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
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
index 25a3814fc..06d973204 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
@@ -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"
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index bd0cafa4c..3fcf157d0 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -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.
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 81d2db232..22c8436f8 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -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,
- private val configFlowManager: Lazy,
- private val commandSender: CommandSender,
- private val connectionManager: Lazy,
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()
+ 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
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 3c0644cb6..f7191c73b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -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,
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()
@@ -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()
- val list = earlyReceivedPackets.toList()
- earlyReceivedPackets.clear()
- list
- }
+ val packets =
+ earlyMutex.withLock {
+ if (earlyReceivedPackets.isEmpty()) return@withLock emptyList()
+ 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) }
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index 9b2a0c5e4..969b67a2f 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -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) {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 1b971ec3a..4019e5a9b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -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())
@@ -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" }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 803ded5af..cb380e49b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -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())
private val _nodeDBbyID = atomic(persistentMapOf())
@@ -84,7 +82,11 @@ class NodeManagerImpl(
allowNodeDbWrites.value = allowed
}
- override var myNodeNum: Int? = null
+ override val myNodeNum = MutableStateFlow(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()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf().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)")
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index 3b4715029..2131172e1 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -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()
+ // 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>()
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()
+ 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 {
- val deferred = CompletableDeferred()
- 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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
index 3644c9c22..4f71879ce 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
@@ -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,
) : 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" +
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
new file mode 100644
index 000000000..205dd30e2
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
@@ -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 .
+ */
+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,
+ private val notificationManager: NotificationManager,
+) : TelemetryPacketHandler {
+ private lateinit var scope: CoroutineScope
+
+ private val batteryMutex = Mutex()
+ private val batteryPercentCooldowns = mutableMapOf()
+
+ 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
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
index d7eb38982..5e8d954f6 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
@@ -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())
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt
new file mode 100644
index 000000000..b416bca85
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val configHandler = mock(MockMode.autofill)
+ private val configFlowManager = mock(MockMode.autofill)
+ private val commandSender = mock(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) }
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
new file mode 100644
index 000000000..6ac094e48
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val commandSender = mock(MockMode.autofill)
+ private val packetRepository = mock(MockMode.autofill)
+ private val serviceBroadcasts = mock(MockMode.autofill)
+ private val dataHandler = mock(MockMode.autofill)
+ private val analytics = mock(MockMode.autofill)
+ private val meshPrefs = mock(MockMode.autofill)
+ private val databaseManager = mock(MockMode.autofill)
+ private val notificationManager = mock(MockMode.autofill)
+ private val messageProcessor = mock(MockMode.autofill)
+ private val radioConfigRepository = mock(MockMode.autofill)
+
+ private val myNodeNumFlow = MutableStateFlow(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,
+ )
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
new file mode 100644
index 000000000..9580d5363
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val connectionManager = mock(MockMode.autofill)
+ private val nodeRepository = mock(MockMode.autofill)
+ private val radioConfigRepository = mock(MockMode.autofill)
+ private val serviceRepository = mock(MockMode.autofill)
+ private val serviceBroadcasts = mock(MockMode.autofill)
+ private val analytics = mock(MockMode.autofill)
+ private val commandSender = mock(MockMode.autofill)
+ private val packetHandler = mock(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()) } 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) }
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
new file mode 100644
index 000000000..b71942d0e
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val serviceRepository = mock(MockMode.autofill)
+ private val nodeManager = mock(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(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(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) }
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index 9b0b50490..d72e5b243 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -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
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index e1a502dd8..5f738b439 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -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(
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
new file mode 100644
index 000000000..3090cf49e
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val serviceRepository = mock(MockMode.autofill)
+ private val meshLogRepository = mock(MockMode.autofill)
+ private val router = mock(MockMode.autofill)
+ private val fromRadioDispatcher = mock(MockMode.autofill)
+ private val dataHandler = mock(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(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()) }
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
index 531f77e7a..4b73798a0 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
@@ -188,7 +188,7 @@ class NodeManagerImplTest {
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertTrue(nodeManager.nodeDBbyID.isEmpty())
- assertNull(nodeManager.myNodeNum)
+ assertNull(nodeManager.myNodeNum.value)
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
new file mode 100644
index 000000000..e465aaa63
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val packetRepository = mock(MockMode.autofill)
+ private val serviceBroadcasts = mock(MockMode.autofill)
+ private val historyManager = mock(MockMode.autofill)
+ private val dataHandler = mock(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(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()) }
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
new file mode 100644
index 000000000..8f295a2b6
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
@@ -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 .
+ */
+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(MockMode.autofill)
+ private val connectionManager = mock(MockMode.autofill)
+ private val notificationManager = mock(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
+ }
+}
diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
index 3ae42a1c8..4dc8c3904 100644
--- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
+++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
@@ -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 =
- PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
+actual fun createDatabaseDataStore(name: String): DataStore = PreferenceDataStoreFactory.create(
+ corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
+ produceFile = { ContextServices.app.preferencesDataStoreFile(name) },
+)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 7b6360cd2..160fd21ce 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -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 =
datastore.data
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
@@ -81,26 +80,35 @@ open class DatabaseManager(
}
}
+ private val dbCache = mutableMapOf()
+
private val _currentDb = MutableStateFlow(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 =
_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(null)
val currentAddress: StateFlow = _currentAddress
- private val dbCache = mutableMapOf() // 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 }
}
diff --git a/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
index 512d8bbf5..b10e63b9c 100644
--- a/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
+++ b/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
@@ -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 {
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") },
+ )
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
index e021c0aa9..54797eb75 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -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.
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt
index a64822f44..f325f44c8 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt
@@ -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 = CompletableDeferred()
+ }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt
index 987779864..7a6a8daa1 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt
@@ -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)
}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt
index e4861f0e5..553d9a49a 100644
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt
+++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt
@@ -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
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
index 7e504f893..6a8dfa93a 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -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 = 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"
+ }
}
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt
new file mode 100644
index 000000000..4cca57f1e
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
index cd0641abb..2b897baa9 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
@@ -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)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
index d55bbe2dd..ac92e8287 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
@@ -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)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
index 15baf651e..a0d115391 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
@@ -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
+
+ /** Sets the local node number. */
+ fun setMyNodeNum(num: Int?)
/** Loads the cached node database from the repository. */
fun loadCachedNodeDB()
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
index 5b6d78528..686840f40 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
@@ -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)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
index 001d919c5..2788a7f07 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
@@ -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
+
/** The scope in which interface-related coroutines should run. */
val serviceScope: CoroutineScope
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt
new file mode 100644
index 000000000..a53cd8b8a
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index c8c6e3681..be8cd95c5 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -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" }
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
index 7ea07ba9c..210c0015e 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
@@ -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")
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
index 6ffec44a4..216d8fb37 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
@@ -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) {
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 99e3743b6..c8b7fdfab 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -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 {
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
index acda9d4fb..0f645c6e3 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
@@ -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
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 {
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
index 851e59a4f..7e9832b54 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
@@ -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()
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index ac4f2526b..309dda937 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -93,7 +93,7 @@ class SharedRadioInterfaceService(
override val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
- val connectionError: SharedFlow = _connectionError.asSharedFlow()
+ override val connectionError: SharedFlow = _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
}
}
}
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index e245f1b0d..611454d05 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -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 = MutableSharedFlow(),
+ connectionError: MutableSharedFlow = MutableSharedFlow(),
+ serviceAction: MutableSharedFlow = MutableSharedFlow(),
+ takEnabledFlow: MutableStateFlow = MutableStateFlow(false),
+ takRunningFlow: MutableStateFlow = 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(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(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)
+ }
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index d40942bd7..bf83be372 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -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) {}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
index dcb6410d5..e1a26c6c3 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
@@ -46,6 +46,9 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
private val _meshActivity = MutableSharedFlow()
override val meshActivity: SharedFlow = _meshActivity
+ private val _connectionError = MutableSharedFlow()
+ override val connectionError: SharedFlow = _connectionError
+
val sentToRadio = mutableListOf()
var connectCalled = false
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 1fe8ada5f..bc0d3a144 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -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) = 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))
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
index 6b966f959..e2fe40da4 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
@@ -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 {
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 {
@@ -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>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
single>(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>(named("CoreLocalConfigDataStore")) {
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
index c1f562818..484e2294e 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
@@ -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")
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
index ac3c23303..adaea22f0 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
@@ -75,6 +75,7 @@ class NoopRadioInterfaceService : RadioInterfaceService {
override val receivedData = MutableSharedFlow()
override val meshActivity = MutableSharedFlow()
+ override val connectionError = MutableSharedFlow()
override fun sendToRadio(bytes: ByteArray) {
logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)")
diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md
index 27cd8d5e4..ce5becbb2 100644
--- a/docs/decisions/architecture-review-2026-03.md
+++ b/docs/decisions/architecture-review-2026-03.md
@@ -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)
diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index 8174e4db2..c5362e479 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -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 |
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 2c7f661eb..631a5da9d 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -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,
@@ -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,
+ )
}
}
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt
index 487a471da..9907e01c0 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt
@@ -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,
)
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
index c11cd1071..8d86c07e9 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
@@ -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)