diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt index 1362de98b..c2e95a5b0 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -23,6 +23,7 @@ package org.meshtastic.core.common.util * - `%s`, `%d` — positional or sequential string/integer * - `%N$s`, `%N$d` — explicit positional string/integer * - `%N$.Nf`, `%.Nf` — float with decimal precision + * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) * - `%%` — literal percent * * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions). @@ -57,7 +58,20 @@ actual fun formatString(pattern: String, vararg args: Any?): String = buildStrin i = startPos // rewind — digits are part of width/precision, not positional index } - // Parse optional flags/width (skip for now — not used in this codebase) + // Parse optional flags (zero-pad) + var zeroPad = false + if (i < pattern.length && pattern[i] == '0') { + zeroPad = true + i++ + } + + // Parse optional width + var width: Int? = null + val widthStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > widthStart) { + width = pattern.substring(widthStart, i).toInt() + } // Parse optional precision (.N) var precision: Int? = null @@ -86,10 +100,24 @@ actual fun formatString(pattern: String, vararg args: Any?): String = buildStrin val places = precision ?: DEFAULT_FLOAT_PRECISION append(NumberFormatter.format(value, places)) } + 'x', + 'X', + -> { + val value = (arg as? Number)?.toLong() ?: 0L + // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. + val masked = if (arg is Int) value and INT_MASK else value + var hex = masked.toString(HEX_RADIX) + if (conversion == 'X') hex = hex.uppercase() + val padChar = if (zeroPad) '0' else ' ' + val padWidth = width ?: 0 + append(hex.padStart(padWidth, padChar)) + } else -> { // Unknown conversion — reproduce original token append('%') if (explicitIndex != null) append("${explicitIndex + 1}$") + if (zeroPad) append('0') + if (width != null) append(width) if (precision != null) append(".$precision") append(conversion) } @@ -98,3 +126,5 @@ actual fun formatString(pattern: String, vararg args: Any?): String = buildStrin } private const val DEFAULT_FLOAT_PRECISION = 6 +private const val HEX_RADIX = 16 +private const val INT_MASK = 0xFFFFFFFFL 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 94b4f629d..c26dc0f5f 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 @@ -98,11 +98,12 @@ class CommandSenderImpl( /** * Resolves the correct channel index for sending a packet to [toNum]. * - * When both the local node and the destination support PKC, returns [DataPacket.PKC_CHANNEL_INDEX] so that - * [buildMeshPacket] enables PKI encryption. Otherwise falls back to the node's heard-on channel (for general - * packets) or the dedicated admin channel (for admin packets). + * PKI encryption ([DataPacket.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption + * is appropriate. Protocol-level requests (traceroute, telemetry, position, nodeinfo, neighborinfo) must NOT use + * PKI because relay nodes need to read and/or modify the inner payload (e.g. traceroute appends each hop's node + * number). These requests fall back to the node's heard-on channel. */ - private fun getChannelIndex(toNum: Int, isAdmin: Boolean = false): Int { + private fun getAdminChannelIndex(toNum: Int): Int { val myNum = nodeManager.myNodeNum.value ?: return 0 val myNode = nodeManager.nodeDBbyNodeNum[myNum] val destNode = nodeManager.nodeDBbyNodeNum[toNum] @@ -110,15 +111,18 @@ class CommandSenderImpl( return when { myNum == toNum -> 0 myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX - isAdmin -> + else -> channelSet.value.settings .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } .coerceAtLeast(0) - else -> destNode?.channel ?: 0 } } - private fun getAdminChannelIndex(toNum: Int): Int = getChannelIndex(toNum, isAdmin = true) + /** + * Returns the heard-on channel for a non-admin request to [toNum]. Does NOT use PKI — protocol-level requests need + * clear inner payloads. + */ + private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0 override fun sendData(p: DataPacket) { if (p.id == 0) p.id = generatePacketId() 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 db598fd51..db6f6dec7 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 @@ -89,6 +89,12 @@ class FromRadioPacketHandlerImpl( fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo) xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket) clientNotification != null -> handleClientNotification(clientNotification) + // Firmware rebooted without a transport-level disconnect (common on serial/TCP). + // Re-handshake immediately rather than waiting for the 30s stall guard. + proto.rebooted != null -> { + Logger.w { "Firmware rebooted (rebooted=${proto.rebooted}), re-initiating handshake" } + router.value.configFlowManager.triggerWantConfig() + } } } 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 14fddde7f..027947453 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 @@ -248,6 +248,11 @@ class MeshActionHandlerImpl( override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { val c = Config.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } + // When targeting the local node, optimistically persist the config so the + // UI reflects changes immediately (matching handleSetConfig behaviour). + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } + } } override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { @@ -310,6 +315,11 @@ class MeshActionHandlerImpl( if (payload != null) { val c = Channel.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } + // When targeting the local node, optimistically persist the channel so + // the UI reflects changes immediately (matching handleSetChannel behaviour). + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } + } } } 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 4c1c60425..f492dcd65 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 @@ -17,6 +17,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import okio.IOException @@ -59,6 +60,9 @@ class MeshConfigFlowManagerImpl( private lateinit var scope: CoroutineScope private val wantConfigDelay = 100L + /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ + private val handshakeGeneration = atomic(0L) + override fun start(scope: CoroutineScope) { this.scope = scope } @@ -203,12 +207,18 @@ class MeshConfigFlowManagerImpl( handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) nodeManager.setMyNodeNum(myInfo.my_node_num) + // Bump the generation so that a pending clear from a prior (interrupted) handshake + // will see a stale snapshot and skip its writes, preventing it from wiping config + // that was saved by this (newer) handshake's incoming packets. + val gen = handshakeGeneration.incrementAndGet() + // 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 { + if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip. radioConfigRepository.clearChannelSet() radioConfigRepository.clearLocalConfig() radioConfigRepository.clearLocalModuleConfig() 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 3fcf157d0..5954b579c 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 @@ -205,6 +205,7 @@ class MeshConnectionManagerImpl( private fun tearDownConnection() { packetHandler.stopPacketQueue() + commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect. locationManager.stop() mqttManager.stop() } @@ -227,8 +228,11 @@ class MeshConnectionManagerImpl( scope.handledLaunch { try { val localConfig = radioConfigRepository.localConfigFlow.first() - val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS - Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } + val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS + // Cap the timeout so routers or power-saving configs (ls_secs=3600) don't + // leave the UI stuck in DeviceSleep for over an hour. + val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS) + Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" } delay(timeout.seconds) Logger.w { "Device timed out, setting disconnected" } onConnectionChanged(ConnectionState.Disconnected) @@ -354,6 +358,12 @@ class MeshConnectionManagerImpl( companion object { private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 + + // Maximum time (in seconds) to wait for a sleeping device before declaring it + // disconnected, regardless of the device's ls_secs configuration. Without this + // cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour. + private const val MAX_SLEEP_TIMEOUT_SECONDS = 300 + private val HANDSHAKE_TIMEOUT = 30.seconds // Shorter window for the retry attempt: if the device genuinely didn't receive the 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 f7191c73b..9fd28ecb4 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 @@ -41,6 +41,7 @@ import org.meshtastic.proto.FromRadio import org.meshtastic.proto.LogRecord import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum +import kotlin.concurrent.Volatile import kotlin.uuid.Uuid /** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */ @@ -59,6 +60,13 @@ class MeshMessageProcessorImpl( private val logUuidByPacketId = mutableMapOf() private val logInsertJobByPacketId = mutableMapOf() + /** + * Epoch-millisecond timestamp of the last local-node `lastHeard` DB write. Used to throttle updates to at most once + * per [LOCAL_NODE_REFRESH_INTERVAL_MS] so that high-frequency FromRadio variants (log records, queue status) don't + * flood the DB. + */ + @Volatile private var lastLocalNodeRefreshMs = 0L + private val earlyMutex = Mutex() private val earlyReceivedPackets = kotlin.collections.ArrayDeque() private val maxEarlyPacketBuffer = 10240 @@ -95,6 +103,9 @@ class MeshMessageProcessorImpl( } private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) { + // Any decoded FromRadio proves the radio link is alive — keep the local node fresh. + refreshLocalNodeLastHeard() + // Audit log every incoming variant logVariant(proto) @@ -253,5 +264,33 @@ class MeshMessageProcessorImpl( } } + /** + * Refreshes the local node's [Node.lastHeard] to prove the radio link is alive. + * + * Without this, [lastHeard] is only set when a [MeshPacket] arrives from another node (see + * [processReceivedMeshPacket]). On a quiet mesh the heartbeat cycle still exchanges data with the firmware (ToRadio + * heartbeat → FromRadio queueStatus every 30 s), but that data never touched [lastHeard], causing the local node to + * appear stale in the UI even though the connection is healthy. + * + * To avoid flooding the DB on high-frequency variants (log records arrive many times per second when debug logging + * is enabled), writes are throttled to at most once per [LOCAL_NODE_REFRESH_INTERVAL_MS]. + */ + private fun refreshLocalNodeLastHeard() { + val now = nowMillis + if (now - lastLocalNodeRefreshMs < LOCAL_NODE_REFRESH_INTERVAL_MS) return + lastLocalNodeRefreshMs = now + + val myNum = nodeManager.myNodeNum.value ?: return + nodeManager.updateNode(myNum, withBroadcast = false) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) } + } + private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) } + + companion object { + /** + * Minimum interval between local-node `lastHeard` DB writes, in milliseconds. Aligned with the heartbeat + * interval (30 s) so that one write per heartbeat cycle keeps the node fresh without unnecessary DB churn. + */ + private const val LOCAL_NODE_REFRESH_INTERVAL_MS = 30_000L + } } 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 2131172e1..1d4d11adc 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 @@ -110,6 +110,7 @@ class PacketHandlerImpl( override fun sendToRadio(packet: MeshPacket) { scope.launch { queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. queuedPackets.add(packet) startPacketQueueLocked() } @@ -123,6 +124,7 @@ class PacketHandlerImpl( val deferred = CompletableDeferred() responseMutex.withLock { queueResponse[packet.id] = deferred } queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. queuedPackets.add(packet) startPacketQueueLocked() } @@ -199,15 +201,18 @@ class PacketHandlerImpl( Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" } } catch (e: TimeoutCancellationException) { Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" } + // Clean up the deferred for this packet. sendToRadioAndAwait callers + // also clean up in their own finally block (idempotent remove). + responseMutex.withLock { queueResponse.remove(packet.id) } } catch (e: CancellationException) { throw e // Preserve structured concurrency cancellation propagation. } catch (e: Exception) { Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" } + responseMutex.withLock { queueResponse.remove(packet.id) } } - // 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) + // Deferred cleanup is now handled in the catch blocks above. + // handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup) + // also remove entries, and these removals are idempotent. } } finally { // Hold queueMutex so that clearing queueJob and the restart decision are diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt index 6f05c9ccf..6e8700311 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.XModemFile import org.meshtastic.core.repository.XModemManager @@ -59,6 +60,8 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage @Volatile private var transferName = "" @Volatile private var expectedSeq = INITIAL_SEQ + + @Volatile private var lastActivityMillis = 0L private val blocks = mutableListOf() override fun setTransferName(name: String) { @@ -66,6 +69,17 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage } override fun handleIncomingXModem(packet: XModem) { + // If blocks have accumulated but no activity for INACTIVITY_TIMEOUT_MS, + // the previous transfer is stale (firmware crash, BLE disconnect, etc.). + if (blocks.isNotEmpty() && lastActivityMillis > 0L) { + val elapsed = nowMillis - lastActivityMillis + if (elapsed > INACTIVITY_TIMEOUT_MS) { + Logger.w { "XModem: inactivity timeout (${elapsed}ms) — resetting stale transfer" } + reset() + } + } + lastActivityMillis = nowMillis + when (packet.control) { XModem.Control.SOH, XModem.Control.STX, @@ -135,6 +149,7 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage expectedSeq = INITIAL_SEQ blocks.clear() transferName = "" + lastActivityMillis = 0L } // CRC-CCITT: polynomial 0x1021, initial value 0x0000 (XModem variant) @@ -157,5 +172,6 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage private const val CTRLZ = 0x1A.toByte() private const val CRC_POLY = 0x1021 private const val BITS_PER_BYTE = 8 + private const val INACTIVITY_TIMEOUT_MS = 30_000L } } 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 d72e5b243..5263254d3 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 @@ -28,6 +28,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.ConnectionState @@ -267,4 +268,46 @@ class MeshConnectionManagerImplTest { verify { mqttManager.start(any(), true, true) } verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } } + + @Test + fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) { + // Router with ls_secs=3600 — previously this created a 3630s timeout. + // With the cap, it should be clamped to 300s. + val config = + LocalConfig( + power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600), + device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), + ) + every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + + manager.start(backgroundScope) + advanceUntilIdle() + + // Transition to Connected then DeviceSleep + radioConnectionState.value = ConnectionState.Connected + advanceUntilIdle() + radioConnectionState.value = ConnectionState.DeviceSleep + advanceUntilIdle() + + assertEquals( + ConnectionState.DeviceSleep, + serviceRepository.connectionState.value, + "Should be in DeviceSleep initially", + ) + + // Advance 300 seconds (the cap) + 1 second to trigger the timeout. + advanceTimeBy(301_000L) + + assertEquals( + ConnectionState.Disconnected, + serviceRepository.connectionState.value, + "Should transition to Disconnected after capped timeout (300s), not the raw 3630s", + ) + } } 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 8bfb1164e..ba5887f95 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 @@ -23,6 +23,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob @@ -135,10 +136,12 @@ open class DatabaseManager( // Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp previousDbName?.let { markLastUsed(it) } - // Now safe to close the previous DB — collectors have switched to the new instance. - if (previousDbName != null && previousDbName != dbName) { - closeCachedDatabase(previousDbName) - } + // Do NOT close the previous DB synchronously here. Even though _currentDb has been + // updated, in-flight `withDb` calls may still hold a reference to the old database + // (captured before the emission). Closing the connection pool while those queries are + // executing causes "Connection pool is closed" crashes. Instead, let LRU eviction + // (enforceCacheLimit) handle cleanup — it only runs on databases that are not the + // active target and have not been used recently. // Defer LRU eviction so switch is not blocked by filesystem work managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = dbName) } @@ -167,11 +170,26 @@ open class DatabaseManager( private val limitedIo = dispatchers.io.limitedParallelism(4) /** Execute [block] with the current DB instance. */ + @Suppress("TooGenericExceptionCaught") override suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) { val db = _currentDb.value ?: return@withContext null val active = buildDbName(_currentAddress.value) markLastUsed(active) - block(db) + try { + block(db) + } catch (e: CancellationException) { + throw e // Preserve structured concurrency cancellation propagation. + } catch (e: Exception) { + // If the connection pool was closed between capturing `db` and executing the query + // (e.g., during a database switch), retry once with the current DB instance. + if (e.message?.contains("Connection pool is closed") == true) { + Logger.w { "withDb: connection pool closed, retrying with current DB" } + val retryDb = _currentDb.value ?: return@withContext null + block(retryDb) + } else { + throw e + } + } } /** Returns true if a database exists for the given device address. */ diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 752619014..e11d10f50 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -289,6 +289,7 @@ interface NodeInfoDao { @Upsert suspend fun doUpsert(node: NodeEntity) + @Transaction suspend fun upsert(node: NodeEntity) { val verifiedNode = getVerifiedNodeForUpsert(node) doUpsert(verifiedNode) diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 092417ad9..16d94f20c 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -57,8 +57,8 @@ constructor( if (nodeNums.isEmpty()) return nodeRepository.deleteNodes(nodeNums) - val packetId = radioController.getPacketId() for (nodeNum in nodeNums) { + val packetId = radioController.getPacketId() radioController.removeByNodenum(packetId, nodeNum) } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt index 2e97cff75..e57c4a446 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt @@ -22,6 +22,8 @@ import org.meshtastic.core.network.repository.SerialConnection import org.meshtastic.core.network.repository.SerialConnectionListener import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import java.util.concurrent.atomic.AtomicReference /** An interface that assumes we are talking to a meshtastic device via USB serial */ @@ -119,7 +121,14 @@ class SerialInterface( } override fun keepAlive() { - Logger.d { "[$address] Serial keepAlive" } + // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with + // a FromRadio queueStatus — proving the serial link is alive. Without this, the + // serial transport has no way to detect a silently dead device (battery depleted, + // firmware crash without the `rebooted` flag). The queueStatus response also feeds + // into MeshMessageProcessorImpl.refreshLocalNodeLastHeard() to keep the local + // node's lastHeard timestamp current. + Logger.d { "[$address] Serial keepAlive — sending heartbeat" } + handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) } override fun sendBytes(p: ByteArray) { 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 7a6a8daa1..78e16edba 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 @@ -65,6 +65,18 @@ private const val CONNECTION_TIMEOUT_MS = 15_000L private const val RECONNECT_FAILURE_THRESHOLD = 3 private const val RECONNECT_BASE_DELAY_MS = 5_000L private const val RECONNECT_MAX_DELAY_MS = 60_000L +private const val RECONNECT_MAX_FAILURES = 10 + +/** + * Minimum milliseconds a BLE connection must stay up before we consider it "stable" and reset + * [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a + * fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is + * never reached, and the app never signals [ConnectionState.DeviceSleep]. + * + * The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine, + * but short enough that normal reconnects after light-sleep still reset the counter promptly. + */ +private const val MIN_STABLE_CONNECTION_MS = 5_000L /** * Returns the reconnect backoff delay in milliseconds for a given consecutive failure count. @@ -181,7 +193,7 @@ class BleRadioInterface( throw RadioNotConnectedException("Device not found at address $address") } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun connect() { connectionJob = connectionScope.launch { @@ -231,8 +243,9 @@ class BleRadioInterface( throw RadioNotConnectedException("Failed to connect to device at address $address") } - // Connection succeeded — reset failure counter - consecutiveFailures = 0 + // Connection succeeded — only reset the failure counter if the + // connection stays up long enough. See MIN_STABLE_CONNECTION_MS. + val gattConnectedAt = nowMillis isFullyConnected = true onConnected() @@ -257,6 +270,39 @@ class BleRadioInterface( } Logger.i { "[$address] BLE connection dropped, preparing to reconnect" } + + // Only reset the failure counter if the connection was stable (lasted + // longer than MIN_STABLE_CONNECTION_MS). A connection that drops within + // seconds typically means the device is at the edge of BLE range or + // powered off — the Android BLE stack may briefly "connect" to a cached + // GATT profile before realising the device is gone. Without this guard, + // the failure counter resets on every brief connect, preventing us from + // ever reaching RECONNECT_FAILURE_THRESHOLD and signalling DeviceSleep. + val connectionUptime = nowMillis - gattConnectedAt + if (connectionUptime >= MIN_STABLE_CONNECTION_MS) { + consecutiveFailures = 0 + } else { + consecutiveFailures++ + Logger.w { + "[$address] Connection lasted only ${connectionUptime}ms " + + "(< ${MIN_STABLE_CONNECTION_MS}ms) — treating as failure " + + "(consecutive failures: $consecutiveFailures)" + } + if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { + Logger.e { "[$address] Giving up after $consecutiveFailures unstable connections" } + service.onDisconnect( + isPermanent = true, + errorMessage = "Device unreachable (unstable connection)", + ) + return@launch + } + if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { + service.onDisconnect( + isPermanent = false, + errorMessage = "Device unreachable (unstable connection)", + ) + } + } } catch (e: kotlinx.coroutines.CancellationException) { Logger.d { "[$address] BLE connection coroutine cancelled" } throw e @@ -268,10 +314,19 @@ class BleRadioInterface( "(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) { + // After exceeding the max failure limit, give up permanently to stop + // draining battery on a device that is genuinely offline. The user + // must manually reconnect from the connections screen. + if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { + Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" } + val (_, msg) = e.toDisconnectReason() + service.onDisconnect(isPermanent = true, errorMessage = msg) + return@launch + } + + // At the failure threshold, signal DeviceSleep so + // MeshConnectionManagerImpl can start its sleep timeout. + if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { handleFailure(e) } @@ -312,10 +367,11 @@ class BleRadioInterface( "Packets RX: $packetsReceived ($bytesReceived bytes), " + "Packets TX: $packetsSent ($bytesSent bytes)" } - // Do NOT call service.onDisconnect() here. The reconnect while-loop handles retries - // internally. Emitting DeviceSleep on every transient disconnect creates competing state - // transitions with MeshConnectionManagerImpl's sleep timeout. Instead, handleFailure() - // is called from the catch block after RECONNECT_FAILURE_THRESHOLD consecutive failures. + // Signal DeviceSleep immediately so the UI reflects the disconnect while the + // reconnect loop continues in the background. The previous approach suppressed + // this signal until RECONNECT_FAILURE_THRESHOLD consecutive failures, leaving the + // UI stuck on "Connected" for 35+ seconds after the device disappeared. + service.onDisconnect(isPermanent = false) } private suspend fun discoverServicesAndSetupCharacteristics() { diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt index 342a4a766..d4fd0dcc1 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt @@ -17,6 +17,8 @@ package org.meshtastic.core.network.radio 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 @@ -124,4 +126,52 @@ class BleRadioInterfaceTest { // Cancel the reconnect loop so runTest can complete. bleInterface.close() } + + /** + * After [RECONNECT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and signal a permanent + * disconnect. This prevents infinite battery drain when the device is genuinely offline. + * + * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw + + * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s + * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing + * variance. + */ + @Test + fun `reconnect loop stops after RECONNECT_MAX_FAILURES with permanent disconnect`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Device") + bluetoothRepository.bond(device) + + connection.connectException = RadioNotConnectedException("simulated failure") + every { service.onDisconnect(any(), any()) } returns Unit + + val bleInterface = + BleRadioInterface( + serviceScope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + service = service, + address = address, + ) + + // Advance enough time for all 10 failures to occur. + advanceTimeBy(400_001L) + + // Should have been called with isPermanent=true at least once (the final call). + verify { service.onDisconnect(isPermanent = true, errorMessage = any()) } + + bleInterface.close() + } + + @Test + fun `computeReconnectBackoffMs returns correct backoff values`() { + assertEquals(5_000L, computeReconnectBackoffMs(0)) + assertEquals(5_000L, computeReconnectBackoffMs(1)) + assertEquals(10_000L, computeReconnectBackoffMs(2)) + assertEquals(20_000L, computeReconnectBackoffMs(3)) + assertEquals(40_000L, computeReconnectBackoffMs(4)) + assertEquals(60_000L, computeReconnectBackoffMs(5)) + assertEquals(60_000L, computeReconnectBackoffMs(10)) + assertEquals(60_000L, computeReconnectBackoffMs(100)) + } } 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 6a8dfa93a..00b00bac2 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,6 +25,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.meshtastic.core.network.radio.StreamInterface import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import java.io.File /** @@ -137,7 +139,11 @@ private constructor( } override fun keepAlive() { - // Not specifically needed for raw serial unless implemented + // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with + // a FromRadio queueStatus — proving the serial link is alive. Without this, the + // serial transport has no way to detect a silently dead device. + Logger.d { "[$portName] Serial keepAlive — sending heartbeat" } + handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) } private fun closePortResources() { 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 309dda937..32f7c5dce 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 @@ -53,6 +53,7 @@ import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory +import kotlin.concurrent.Volatile /** * Shared multiplatform connection orchestrator for Meshtastic radios. @@ -107,8 +108,16 @@ class SharedRadioInterfaceService( private var heartbeatJob: kotlinx.coroutines.Job? = null private var lastHeartbeatMillis = 0L + @Volatile private var lastDataReceivedMillis = 0L + companion object { private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L + + // If we haven't received any data from the radio within this window after sending a + // heartbeat while the connection is nominally "Connected", the connection is likely a + // zombie (BLE stack didn't report disconnect). Two missed heartbeat intervals gives + // the firmware a reasonable window to respond or send telemetry. + private const val LIVENESS_TIMEOUT_MILLIS = HEARTBEAT_INTERVAL_MILLIS * 2 } private val initLock = Mutex() @@ -245,15 +254,36 @@ class SharedRadioInterfaceService( private fun startHeartbeat() { heartbeatJob?.cancel() + lastDataReceivedMillis = nowMillis heartbeatJob = serviceScope.launch { while (true) { delay(HEARTBEAT_INTERVAL_MILLIS) keepAlive() + checkLiveness() } } } + /** + * Detects zombie connections where the BLE stack didn't report a disconnect. + * + * If we believe we're connected but haven't received any data from the radio within [LIVENESS_TIMEOUT_MILLIS], the + * connection is likely dead. Signal a non-permanent disconnect so the reconnect machinery can take over. + */ + private fun checkLiveness() { + if (_connectionState.value != ConnectionState.Connected) return + + val silenceMs = nowMillis - lastDataReceivedMillis + if (silenceMs > LIVENESS_TIMEOUT_MILLIS) { + Logger.w { + "Liveness check failed: no data received for ${silenceMs}ms " + + "(threshold: ${LIVENESS_TIMEOUT_MILLIS}ms). Treating as disconnect." + } + onDisconnect(isPermanent = false, errorMessage = "Connection timeout — no data received") + } + } + fun keepAlive(now: Long = nowMillis) { if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { radioIf?.keepAlive() @@ -271,6 +301,7 @@ class SharedRadioInterfaceService( @Suppress("TooGenericExceptionCaught") override fun handleFromRadio(bytes: ByteArray) { try { + lastDataReceivedMillis = nowMillis processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } _meshActivity.tryEmit(MeshActivity.Receive) } catch (t: Throwable) { @@ -283,6 +314,7 @@ class SharedRadioInterfaceService( // 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. + lastDataReceivedMillis = nowMillis if (_connectionState.value != ConnectionState.Connected) { Logger.d { "Broadcasting connection state change to Connected" } _connectionState.value = ConnectionState.Connected diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 777968a45..90e171e8e 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -121,7 +122,11 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - viewModelScope.launch { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } + // viewModelScope is already cancelled when onCleared() runs, so use a standalone scope + // for fire-and-forget cleanup of temporary firmware files. + kotlinx.coroutines.CoroutineScope(NonCancellable).launch { + tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + } } fun setReleaseType(type: FirmwareReleaseType) { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index bd8f8615b..e6f6645d0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -82,6 +82,7 @@ open class BaseMapViewModel( .getWaypoints() .mapLatest { list -> list + .filter { it.waypoint != null } .associateBy { packet -> packet.waypoint!!.id } .filterValues { val expire = it.waypoint?.expire ?: 0 diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 35b33a9c3..45b3cc2b8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -123,7 +123,7 @@ class NodeDetailViewModel( /** Returns the type-safe navigation route for a direct message to this node. */ fun getDirectMessageRoute(node: Node, ourNode: Node?): String { - val hasPKC = ourNode?.hasPKC == true + val hasPKC = ourNode?.hasPKC == true && node.hasPKC val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel return "${channel}${node.user.id}" } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 59ab4d4cf..8ed442ccd 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -142,7 +142,7 @@ class LogSearchManager { return filteredLogs .flatMapIndexed { logIndex, log -> searchText.split(" ").flatMap { term -> - val escapedTerm = term // Simple regex escape or just use contains + val escapedTerm = Regex.escape(term) val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE) val messageMatches = regex.findAll(log.logMessage).map { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index dadc165dd..d5632a88a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -585,7 +585,12 @@ open class RadioConfigViewModel( val route = radioConfigState.value.route when (result) { - is RadioResponseResult.Error -> sendError(result.message) + is RadioResponseResult.Error -> { + sendError(result.message) + // Abort the AdminRoute flow — do not fire the destructive action + // (reboot/shutdown/factory_reset) if the metadata preflight failed. + return + } is RadioResponseResult.Success -> { if (route.isEmpty()) { val data = packet.decoded!! @@ -705,6 +710,12 @@ open class RadioConfigViewModel( } } + // Routing ACKs (Success) share the same request_id as the upcoming ADMIN_APP response. + // Removing the id here would cause the actual admin response to be silently dropped, + // because processRadioResponseUseCase checks `request_id in requestIds`. + // The Success branch already handles its own id removal when route is empty (set flow). + if (result is RadioResponseResult.Success) return + if (AdminRoute.entries.any { it.name == route }) { sendAdminRequest(destNum) } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 007061d47..167daebbf 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -233,13 +233,15 @@ class RadioConfigViewModelTest { } @Test - fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest { + fun `setResponseStateLoading for REBOOT calls useCase after config response`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success + // AdminRoute first sends a session key config request; the admin action fires + // only after the actual ConfigResponse (not a routing ACK / Success). + every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) viewModel = createViewModel() @@ -247,20 +249,22 @@ class RadioConfigViewModelTest { viewModel.setResponseStateLoading(AdminRoute.REBOOT) - // Emit a packet to trigger processPacketResponse -> sendAdminRequest + // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.reboot(123) } } @Test - fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest { + fun `setResponseStateLoading for FACTORY_RESET calls useCase after config response`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success + // AdminRoute first sends a session key config request; the admin action fires + // only after the actual ConfigResponse (not a routing ACK / Success). + every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) viewModel = createViewModel() @@ -268,7 +272,7 @@ class RadioConfigViewModelTest { viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET) - // Emit a packet to trigger processPacketResponse -> sendAdminRequest + // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.factoryReset(123, any()) } @@ -449,7 +453,6 @@ class RadioConfigViewModelTest { nodeRepository.setNodes(listOf(node)) val packetFlow = MutableSharedFlow() every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success viewModel = createViewModel() @@ -461,13 +464,16 @@ class RadioConfigViewModelTest { packetFlow.emit(MeshPacket()) viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN) - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success + // AdminRoute fires sendAdminRequest after receiving ConfigResponse (session key), + // not after a routing ACK (Success). + every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.shutdown(123) } // NODEDB_RESET everySuspend { adminActionsUseCase.nodedbReset(any(), any(), any()) } returns 42 viewModel.setResponseStateLoading(AdminRoute.NODEDB_RESET) + every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) packetFlow.emit(MeshPacket()) verifySuspend { adminActionsUseCase.nodedbReset(123, any(), any()) } }