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)