From e2d033ae6ffaedd5d30647fede70aef0900f604a Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:31:17 +0000 Subject: [PATCH] fix(connections): scope region warnings to the active connection (#7015) --- .../core/data/manager/CommandSenderImpl.kt | 14 +- .../data/manager/LockdownCoordinatorImpl.kt | 53 +++++-- .../core/data/manager/PacketHandlerImpl.kt | 4 +- .../data/manager/CommandSenderImplTest.kt | 29 ++++ .../manager/LockdownCoordinatorImplTest.kt | 90 ++++++++++- .../data/manager/PacketHandlerImplTest.kt | 10 ++ .../core/model/service/LockdownState.kt | 45 +++++- .../core/model/service/LockdownStateTest.kt | 51 +++++++ .../core/repository/CommandSender.kt | 12 +- .../core/repository/LockdownCoordinator.kt | 8 +- .../core/repository/PacketHandler.kt | 10 +- .../core/testing/FakeCommandSender.kt | 14 +- .../core/testing/FakeLockdownCoordinator.kt | 8 +- .../core/ui/viewmodel/ConnectionsViewModel.kt | 45 +++--- .../core/ui/viewmodel/UIViewModel.kt | 8 +- .../ui/viewmodel/ConnectionsViewModelTest.kt | 103 +++++++++++++ .../connections/ui/ConnectionsScreen.kt | 117 +++++++++++---- .../ui/components/ConnectingDeviceInfo.kt | 7 +- .../ui/ConnectionsScreenWarningPolicyTest.kt | 142 ++++++++++++++++++ .../ui/ConfigurationWarningCardsTest.kt | 135 +++++++++++++++++ .../settings/lockdown/LockdownDialog.kt | 8 +- .../settings/lockdown/LockdownModeSetting.kt | 2 + .../settings/radio/RadioConfigViewModel.kt | 10 +- 23 files changed, 809 insertions(+), 116 deletions(-) create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/service/LockdownStateTest.kt create mode 100644 feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreenWarningPolicyTest.kt create mode 100644 feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/ui/ConfigurationWarningCardsTest.kt 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 7b6f124881..38b1a8d5bd 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 @@ -448,7 +448,7 @@ class CommandSenderImpl( hours: Int, maxSessionSeconds: Int, disable: Boolean, - ) { + ): Boolean { val validUntilEpoch = if (hours > 0) { (nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt() @@ -463,15 +463,13 @@ class CommandSenderImpl( max_session_seconds = maxSessionSeconds.coerceAtLeast(0), disable = disable, ) - sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) + return sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth)) } - override fun sendLockNow() { - sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true))) - } + override fun sendLockNow(): Boolean = sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true))) - private fun sendLockdownAdmin(adminMessage: AdminMessage) { - val myNum = nodeManager.myNodeNum.value ?: return + private fun sendLockdownAdmin(adminMessage: AdminMessage): Boolean { + val myNum = nodeManager.myNodeNum.value ?: return false val packet = MeshPacket( to = myNum, @@ -483,7 +481,7 @@ class CommandSenderImpl( priority = MeshPacket.Priority.RELIABLE, decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()), ) - packetHandler.sendToRadio(ToRadio(packet = packet)) + return packetHandler.trySendToRadio(ToRadio(packet = packet)) } fun resolveNodeNum(address: NodeAddress): Int = when (address) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt index 91b433f8a6..dda577cc2d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImpl.kt @@ -127,14 +127,22 @@ class LockdownCoordinatorImpl( } if (stored != null) { Logger.i { "Lockdown: Auto-unlocking with stored passphrase" } - wasAutoAttempt = true - commandSender.sendLockdownPassphrase( - stored.passphrase, - stored.boots, - stored.hours, - stored.maxSessionSeconds, - ) - return + // A fresh LOCKED status ends any previous auto-attempt outcome. Only an admitted replay arms failure + // handling for the new attempt. + wasAutoAttempt = false + val dispatched = + commandSender.sendLockdownPassphrase( + stored.passphrase, + stored.boots, + stored.hours, + stored.maxSessionSeconds, + ) + if (dispatched) { + wasAutoAttempt = true + serviceRepository.setLockdownState(LockdownState.AwaitingResponse) + return + } + Logger.w { "Lockdown: Auto-unlock command was not accepted by the active transport" } } } serviceRepository.setLockdownState(LockdownState.Locked(lockReason)) @@ -212,13 +220,21 @@ class LockdownCoordinatorImpl( hours: Int, maxSessionSeconds: Int, disable: Boolean, - ) { + ): Boolean { + if (!commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)) { + Logger.w { "Lockdown: Passphrase command was not accepted by the active transport" } + return false + } + wasAutoAttempt = false wasLockNow = false if (disable) { - // Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Drop any - // stored passphrase now so a later reconnect doesn't auto-unlock a device the user just disabled. + // Turning lockdown OFF: the device will reboot to DISABLED, so there is nothing to re-save. Clear the + // stored passphrase only after transport admission succeeds so a rejected request remains retryable. pendingPassphrase = null + pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS + pendingHours = 0 + pendingMaxSessionSeconds = 0 val deviceAddress = radioInterfaceService.getDeviceAddress() if (deviceAddress != null) { try { @@ -233,13 +249,18 @@ class LockdownCoordinatorImpl( pendingHours = hours pendingMaxSessionSeconds = maxSessionSeconds } - serviceRepository.setLockdownState(LockdownState.None) - commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) + serviceRepository.setLockdownState(LockdownState.AwaitingResponse) + return true } - override fun lockNow() { - wasLockNow = true - commandSender.sendLockNow() + override fun lockNow(): Boolean { + val dispatched = commandSender.sendLockNow() + if (dispatched) { + wasLockNow = true + } else { + Logger.w { "Lockdown: Lock Now command was not accepted by the active transport" } + } + return dispatched } private fun resetTransientState() { 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 55347d6664..2ea640a00f 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 @@ -201,11 +201,13 @@ class PacketHandlerImpl( private val sendAckTimeoutJobs = mutableMapOf() override fun sendToRadio(p: ToRadio) { - if (!dispatchToRadio(p)) { + if (!trySendToRadio(p)) { Logger.w { "sendToRadio dropped: no active transport accepted outbound command" } } } + override fun trySendToRadio(p: ToRadio): Boolean = dispatchToRadio(p) + private fun dispatchToRadio(p: ToRadio): Boolean { Logger.d { "Sending to radio ${p.toPIIString()}" } val dispatched = radioInterfaceService.trySendToRadio(p.encode()) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt index 277a9054c6..e0644d620d 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt @@ -280,6 +280,35 @@ class CommandSenderImplTest { assertFalse(result.accepted) } + // --- lockdown direct dispatch --- + + @Test + fun sendLockdownPassphrase_returnsDirectTransportAdmission() { + every { packetHandler.trySendToRadio(any()) } returns true + + assertTrue(commandSender.sendLockdownPassphrase("secret", boots = 3, hours = 4, maxSessionSeconds = 5)) + + every { packetHandler.trySendToRadio(any()) } returns false + assertFalse(commandSender.sendLockdownPassphrase("secret", boots = 3, hours = 4, maxSessionSeconds = 5)) + } + + @Test + fun sendLockdownPassphrase_returnsFalseWhenLocalNodeIdentityIsUnavailable() { + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + assertFalse(commandSender.sendLockdownPassphrase("secret")) + verify(exactly(0)) { packetHandler.trySendToRadio(any()) } + } + + @Test + fun sendLockNow_returnsDirectTransportAdmission() { + every { packetHandler.trySendToRadio(any()) } returns true + assertTrue(commandSender.sendLockNow()) + + every { packetHandler.trySendToRadio(any()) } returns false + assertFalse(commandSender.sendLockNow()) + } + // --- sendAdminImmediate --- @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index 52eb52c2c0..2edddf8aad 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -191,9 +191,11 @@ class LockdownCoordinatorImplTest { // region LOCKED — auto-replay @Test - fun `LOCKED with stored passphrase triggers auto-unlock`() { + fun `LOCKED with stored passphrase enters AwaitingResponse only after dispatch admission`() { radioService.setDeviceAddress(testDeviceAddress) passphraseStore.saved[testDeviceAddress] = StoredPassphrase("secret", 10, 24) + var stateDuringDispatch: LockdownState? = null + commandSender.onLockdownPassphraseDispatchAttempt = { stateDuringDispatch = serviceRepo.lockdownState.value } coordinator.handleLockdownStatus( LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), @@ -202,6 +204,32 @@ class LockdownCoordinatorImplTest { assertEquals("secret", commandSender.lastPassphrase) assertEquals(10, commandSender.lastBoots) assertEquals(24, commandSender.lastHours) + assertFalse(stateDuringDispatch is LockdownState.AwaitingResponse) + assertIs(serviceRepo.lockdownState.value) + } + + @Test + fun `LOCKED with stored passphrase stays retryable when dispatch is rejected`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("secret", 10, 24) + + // Establish a previous successful auto-unlock so a stale auto-attempt marker would be observable below. + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED)) + commandSender.lockdownPassphraseDispatchAccepted = false + + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals("needs_auth", state.lockReason) + assertEquals("secret", passphraseStore.saved[testDeviceAddress]?.passphrase) + + // A later failure must not be treated as an auto-attempt that never left the app. + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCK_FAILED)) + assertEquals("secret", passphraseStore.saved[testDeviceAddress]?.passphrase) } @Test @@ -414,6 +442,19 @@ class LockdownCoordinatorImplTest { assertTrue(connectionManager.clearRadioConfigCalled) } + @Test + fun `rejected lockNow does not arm the acknowledgement transition`() { + commandSender.lockNowDispatchAccepted = false + + assertFalse(coordinator.lockNow()) + coordinator.handleLockdownStatus( + LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "needs_auth"), + ) + + assertIs(serviceRepo.lockdownState.value) + assertFalse(connectionManager.clearRadioConfigCalled) + } + @Test fun `lockNow flag resets after onConnect`() { coordinator.lockNow() @@ -433,29 +474,66 @@ class LockdownCoordinatorImplTest { // region submitPassphrase @Test - fun `submitPassphrase sends command and clears lockNow flag`() { + fun `submitPassphrase enters AwaitingResponse only after dispatch admission and clears lockNow flag`() { coordinator.lockNow() - coordinator.submitPassphrase("test", boots = 5, hours = 12) + serviceRepo.setLockdownState(LockdownState.Locked("needs_auth")) + var stateDuringDispatch: LockdownState? = null + commandSender.onLockdownPassphraseDispatchAttempt = { stateDuringDispatch = serviceRepo.lockdownState.value } + + assertTrue(coordinator.submitPassphrase("test", boots = 5, hours = 12)) assertEquals("test", commandSender.lastPassphrase) assertEquals(5, commandSender.lastBoots) assertEquals(12, commandSender.lastHours) + assertIs(stateDuringDispatch) + assertIs(serviceRepo.lockdownState.value) - // Subsequent LOCKED should not trigger LockNowAcknowledged + // Subsequent LOCKED should not trigger LockNowAcknowledged. radioService.setDeviceAddress(testDeviceAddress) coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.LOCKED)) assertIs(serviceRepo.lockdownState.value) } @Test - fun `submitPassphrase with disable forwards disable flag and clears stored passphrase`() { + fun `rejected submitPassphrase preserves retryable state and does not stage credentials`() { + radioService.setDeviceAddress(testDeviceAddress) + serviceRepo.setLockdownState(LockdownState.Locked("needs_auth")) + commandSender.lockdownPassphraseDispatchAccepted = false + + assertFalse(coordinator.submitPassphrase("not-sent", boots = 5, hours = 12)) + + val state = serviceRepo.lockdownState.value + assertIs(state) + assertEquals("needs_auth", state.lockReason) + + // A later status must not persist credentials from a command that never left the app. + coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.UNLOCKED)) + assertNull(passphraseStore.saved[testDeviceAddress]) + } + + @Test + fun `submitPassphrase with disable clears stored passphrase only after dispatch admission`() { radioService.setDeviceAddress(testDeviceAddress) passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0) - coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true) + assertTrue(coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true)) assertTrue(commandSender.lastDisable) assertTrue(passphraseStore.saved.isEmpty()) + assertIs(serviceRepo.lockdownState.value) + } + + @Test + fun `rejected disable preserves stored passphrase and unlocked state`() { + radioService.setDeviceAddress(testDeviceAddress) + passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0) + serviceRepo.setLockdownState(LockdownState.Unlocked) + commandSender.lockdownPassphraseDispatchAccepted = false + + assertFalse(coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true)) + + assertEquals("original", passphraseStore.saved[testDeviceAddress]?.passphrase) + assertIs(serviceRepo.lockdownState.value) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index c8b74a396d..4a5ef0d87c 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -141,6 +141,16 @@ class PacketHandlerImplTest { verify { radioInterfaceService.trySendToRadio(any()) } } + @Test + fun `trySendToRadio reports direct transport admission`() { + val toRadio = ToRadio(packet = MeshPacket(id = 124)) + + assertTrue(handler.trySendToRadio(toRadio)) + + every { radioInterfaceService.trySendToRadio(any()) } returns false + assertFalse(handler.trySendToRadio(toRadio)) + } + @Test fun `sendToRadio updates status using the full outgoing packet identity`() = runTest(testDispatcher) { val packet = diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt index 55d62a5a47..fec9bd469c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt @@ -16,10 +16,26 @@ */ package org.meshtastic.core.model.service -/** Represents the lockdown authentication state for a firmware-locked device. */ +/** + * Lockdown session state for the connected device. + * + * [None] means the current connection has no concrete runtime lockdown state. This is expected for pre-2.8 firmware, + * which does not implement the runtime lockdown handshake, and for newer builds that do not include runtime lockdown + * support. [allowsConfigWrites] summarizes only whether lockdown state withholds normal admin configuration writes; + * managed-device and other client policy remain separate. + */ sealed class LockdownState { data object None : LockdownState() + /** + * A manual or automatic passphrase command was admitted to the active transport and is waiting for the firmware's + * next lockdown status. + * + * Write eligibility remains unresolved until that response arrives. A rejected dispatch leaves the previous + * retryable state in place instead of entering this state. + */ + data object AwaitingResponse : LockdownState() + /** * Device is locked or this client is not yet authorized. * @@ -32,7 +48,7 @@ sealed class LockdownState { data object Unlocked : LockdownState() - /** Device is lockdown-capable but lockdown is currently OFF. The toggle shows OFF. */ + /** Lockdown-capable firmware explicitly reported that lockdown is disabled. */ data object Disabled : LockdownState() /** Lock Now ACK received — client should disconnect immediately, no dialog. */ @@ -47,6 +63,31 @@ sealed class LockdownState { require(backoffSeconds > 0) { "backoffSeconds must be positive" } } } + + /** + * True when the current lockdown state does not withhold normal configuration writes. + * + * No received lockdown status ([None]), explicitly disabled lockdown ([Disabled]), and an authenticated lockdown + * session ([Unlocked]) do not withhold writes on lockdown grounds. [AwaitingResponse] keeps write eligibility + * unresolved until firmware reports the next state; locked, provisioning, and authentication-failure states + * withhold admin access. Managed-device policy is intentionally evaluated separately by the presentation. + */ + val allowsConfigWrites: Boolean + get() = + when (this) { + is None, + is Disabled, + is Unlocked, + -> true + + is AwaitingResponse, + is Locked, + is NeedsProvision, + is LockNowAcknowledged, + is UnlockFailed, + is UnlockBackoff, + -> false + } } /** diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/service/LockdownStateTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/service/LockdownStateTest.kt new file mode 100644 index 0000000000..960a3f2c1a --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/service/LockdownStateTest.kt @@ -0,0 +1,51 @@ +/* + * 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.model.service + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LockdownStateTest { + + @Test + fun `absence of lockdown status allows config writes`() { + // Pre-2.8 firmware and newer builds without runtime lockdown support do not participate in the lockdown-status + // handshake, so None does not withhold configuration writes on lockdown grounds. + assertTrue(LockdownState.None.allowsConfigWrites) + } + + @Test + fun `lockdown capable but disabled devices allow config writes`() { + assertTrue(LockdownState.Disabled.allowsConfigWrites) + } + + @Test + fun `unlocked devices allow config writes`() { + assertTrue(LockdownState.Unlocked.allowsConfigWrites) + } + + @Test + fun `pending locked and not-yet-authorized states withhold config writes`() { + assertFalse(LockdownState.AwaitingResponse.allowsConfigWrites) + assertFalse(LockdownState.Locked("needs_auth").allowsConfigWrites) + assertFalse(LockdownState.NeedsProvision.allowsConfigWrites) + assertFalse(LockdownState.LockNowAcknowledged.allowsConfigWrites) + assertFalse(LockdownState.UnlockFailed.allowsConfigWrites) + assertFalse(LockdownState.UnlockBackoff(backoffSeconds = 10).allowsConfigWrites) + } +} 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 a5eeae3c41..3080701143 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 @@ -151,6 +151,8 @@ interface CommandSender { * * @param disable when `true`, instructs the device to decrypt storage back to plaintext and leave lockdown (the off * switch). The device reboots and reconnects reporting `DISABLED`. + * @return `true` when the active transport accepted the command for asynchronous handoff, or `false` when the + * command could not be dispatched. Firmware acceptance is reported separately through `LockdownStatus`. */ fun sendLockdownPassphrase( passphrase: String, @@ -158,8 +160,12 @@ interface CommandSender { hours: Int = 0, maxSessionSeconds: Int = 0, disable: Boolean = false, - ) + ): Boolean - /** Sends a Lock Now command to immediately lock a locked-firmware device. */ - fun sendLockNow() + /** + * Sends a Lock Now command to immediately lock a locked-firmware device. + * + * @return `true` when the active transport accepted the command for asynchronous handoff. + */ + fun sendLockNow(): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt index 0240ebc1d7..fdf5aa16a6 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt @@ -47,6 +47,8 @@ interface LockdownCoordinator { * * @param disable when `true`, turns lockdown OFF (decrypt storage back to plaintext); the device reboots and * reconnects reporting `DISABLED`. + * @return `true` when the command was admitted to the active transport. A `false` result leaves the current + * retryable lockdown state in place so the caller can surface the failure and retry. */ fun submitPassphrase( passphrase: String, @@ -54,8 +56,8 @@ interface LockdownCoordinator { hours: Int, maxSessionSeconds: Int = 0, disable: Boolean = false, - ) + ): Boolean - /** Sends a Lock Now command to the connected device. */ - fun lockNow() + /** @return `true` when the Lock Now command was admitted to the active transport. */ + fun lockNow(): Boolean } 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 a913948078..2ebfe74940 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 @@ -22,9 +22,17 @@ import org.meshtastic.proto.ToRadio /** Interface for handling the transmission of packets to the radio and managing the packet queue. */ interface PacketHandler { - /** Sends a command/packet directly to the radio. */ + /** Sends a command/packet directly to the radio, logging when no active transport accepts it. */ fun sendToRadio(p: ToRadio) + /** + * Attempts a direct command/packet dispatch to the active transport. + * + * @return `true` when the transport accepted the bytes for asynchronous handoff, or `false` when no active + * transport accepted them. This is admission only; it does not confirm firmware processing. + */ + fun trySendToRadio(p: ToRadio): Boolean + /** * Adds a mesh packet to the queue for sending. * diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt index f82ca6c6ce..7f0fd81576 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt @@ -78,6 +78,10 @@ class FakeCommandSender : var lastDisable: Boolean = false private set + var lockdownPassphraseDispatchAccepted: Boolean = true + var lockNowDispatchAccepted: Boolean = true + var onLockdownPassphraseDispatchAttempt: (() -> Unit)? = null + var lockNowCalled: Boolean = false private set @@ -97,6 +101,9 @@ class FakeCommandSender : lastHours = 0 lastMaxSessionSeconds = 0 lastDisable = false + lockdownPassphraseDispatchAccepted = true + lockNowDispatchAccepted = true + onLockdownPassphraseDispatchAttempt = null lockNowCalled = false awaitedAdminResult = AwaitedSendResult(AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 0) sendDataFailure = null @@ -208,17 +215,20 @@ class FakeCommandSender : hours: Int, maxSessionSeconds: Int, disable: Boolean, - ) { + ): Boolean { failCommandIfConfigured() lastPassphrase = passphrase lastBoots = boots lastHours = hours lastMaxSessionSeconds = maxSessionSeconds lastDisable = disable + onLockdownPassphraseDispatchAttempt?.invoke() + return lockdownPassphraseDispatchAccepted } - override fun sendLockNow() { + override fun sendLockNow(): Boolean { failCommandIfConfigured() lockNowCalled = true + return lockNowDispatchAccepted } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt index fbec8c9d5d..8ea3e0ade1 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeLockdownCoordinator.kt @@ -29,6 +29,8 @@ class FakeLockdownCoordinator : LockdownCoordinator { var lastHours: Int? = null var lastMaxSessionSeconds: Int? = null var lastDisable: Boolean = false + var submitAccepted: Boolean = true + var lockNowAccepted: Boolean = true var lockNowCalled = false override fun onConnect() { @@ -53,15 +55,17 @@ class FakeLockdownCoordinator : LockdownCoordinator { hours: Int, maxSessionSeconds: Int, disable: Boolean, - ) { + ): Boolean { lastPassphrase = passphrase lastBoots = boots lastHours = hours lastMaxSessionSeconds = maxSessionSeconds lastDisable = disable + return submitAccepted } - override fun lockNow() { + override fun lockNow(): Boolean { lockNowCalled = true + return lockNowAccepted } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index 462cf3530e..f63288d9c1 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.FirmwareReleaseRepository +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRestartTracker import org.meshtastic.core.repository.Notification @@ -61,9 +62,10 @@ import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig /** - * Derived, UI-friendly summary of the device connection state. Combines [ServiceRepository.connectionState] with - * "region unset" and the [ServiceRepository.RECONNECTING_PROGRESS_TEXT] handshake-recovery signal to surface cases - * (MUST_SET_REGION, RECONNECTING) that otherwise need separate boolean flags in the UI layer. + * Derived, UI-friendly summary of the device connection lifecycle. Combines [ServiceRepository.connectionState] with + * the [ServiceRepository.RECONNECTING_PROGRESS_TEXT] handshake-recovery signal and expected-restart state. + * Configuration health is modeled separately so region, lockdown, and managed-mode policy cannot leak into the + * connection label. */ enum class ConnectionStatus { /** No device has been selected or we are otherwise disconnected. */ @@ -85,14 +87,11 @@ enum class ConnectionStatus { */ RESTARTING, - /** Connected with node info available. */ + /** Transport connected. */ CONNECTED, /** Connected but the device is in deep sleep. */ CONNECTED_SLEEPING, - - /** Connected and active, but LoRa region is UNSET — user action required. */ - MUST_SET_REGION, } @KoinViewModel @@ -100,6 +99,7 @@ class ConnectionsViewModel( radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, + nodeManager: NodeManager, nodeRestartTracker: NodeRestartTracker, private val uiPrefs: UiPrefs, private val deviceHardwareRepository: DeviceHardwareRepository, @@ -115,7 +115,6 @@ class ConnectionsViewModel( val connectionState = serviceRepository.connectionState val lockdownState = serviceRepository.lockdownState - val sessionAuthorized = serviceRepository.sessionAuthorized val myNodeInfo: StateFlow = nodeRepository.myNodeInfo @@ -154,9 +153,19 @@ class ConnectionsViewModel( .stateInWhileSubscribed(initialValue = false) /** - * Single source of truth for the UI's "connection status" pill/banner. Derived from [connectionState], - * [ServiceRepository.connectionProgress], and [regionUnset]; kept here rather than in the composable so the mapping - * is observable and testable. + * Whether [ourNodeInfo] belongs to the active transport session rather than cached state from an earlier session. + * Warning presentation uses this boundary so a non-null repository node alone cannot make configuration actionable. + */ + val activeNodeInfoReady: StateFlow = + combine(nodeManager.connectionIdentity, nodeRepository.ourNodeInfo) { connectionIdentity, ourNode -> + connectionIdentity != null && ourNode?.num == connectionIdentity.nodeNum + } + .distinctUntilChanged() + .stateInWhileSubscribed(initialValue = false) + + /** + * Single source of truth for the UI's connection lifecycle label. Kept independent from configuration state so + * region, lockdown, and managed-mode changes cannot rewrite a transient connection label. * * The [ConnectionStatus.RECONNECTING] case is signalled by the WiFi/TCP handshake watchdog writing * [ServiceRepository.RECONNECTING_PROGRESS_TEXT] to [ServiceRepository.connectionProgress] immediately before its @@ -164,15 +173,13 @@ class ConnectionsViewModel( * [ServiceRepository.RECONNECTING_PROGRESS_TEXT] for the cross-track contract. */ val connectionStatus: StateFlow = - combine( - connectionState, - regionUnset, - serviceRepository.connectionProgress, - nodeRestartTracker.restartExpected, - ) { state, unset, progress, restartExpected -> + combine(connectionState, serviceRepository.connectionProgress, nodeRestartTracker.restartExpected) { + state, + progress, + restartExpected, + -> when (state) { - is ConnectionState.Connected -> - if (unset) ConnectionStatus.MUST_SET_REGION else ConnectionStatus.CONNECTED + is ConnectionState.Connected -> ConnectionStatus.CONNECTED // While an expected node restart is in flight, the drop and the reconnect attempts are the restart // — diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 1e6204bef3..7798c7ec0d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -204,13 +204,9 @@ class UIViewModel( hourTtl: Int = 0, maxSessionSeconds: Int = 0, disable: Boolean = false, - ) { - lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) - } + ): Boolean = lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable) - fun sendLockNow() { - lockdownCoordinator.lockNow() - } + fun sendLockNow(): Boolean = lockdownCoordinator.lockNow() fun clearLockdownState() { serviceRepository.clearLockdownState() diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt index b39a8bae72..17204b14fe 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt @@ -36,6 +36,8 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.FirmwareUpdateDestination +import org.meshtastic.core.repository.ConnectionIdentity +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRestartTracker import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -72,6 +74,8 @@ class ConnectionsViewModelTest { private val serviceRepository = FakeServiceRepository() private val nodeRestartTracker = NodeRestartTracker(CoroutineScope(SupervisorJob())) private val nodeRepository = FakeNodeRepository() + private val connectionIdentity = MutableStateFlow(null) + private val nodeManager: NodeManager = mock(MockMode.autofill) private val uiPrefs = FakeUiPrefs() private val deviceHardwareRepository = FakeDeviceHardwareRepository() private val firmwareReleaseRepository = FakeFirmwareReleaseRepository() @@ -98,6 +102,8 @@ class ConnectionsViewModelTest { notificationsCanBeScheduled = true every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) + every { nodeManager.connectionIdentity } returns connectionIdentity + connectionIdentity.value = null uiPrefs.hasShownNotPairedWarning.value = false uiPrefs.firmwareUpdateNotificationKeys.value = emptySet() @@ -109,6 +115,7 @@ class ConnectionsViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, nodeRepository = nodeRepository, + nodeManager = nodeManager, nodeRestartTracker = nodeRestartTracker, uiPrefs = uiPrefs, deviceHardwareRepository = deviceHardwareRepository, @@ -155,6 +162,102 @@ class ConnectionsViewModelTest { assertEquals(true, uiPrefs.hasShownNotPairedWarning.value) } + @Test + fun `connection status stays lifecycle-only when region is unset`() = runTest { + val configFlow = + MutableStateFlow(LocalConfig(lora = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.UNSET))) + every { radioConfigRepository.localConfigFlow } returns configFlow + val vm = newViewModel() + + vm.connectionStatus.test { + assertEquals(ConnectionStatus.NOT_CONNECTED, awaitItem()) + + serviceRepository.setConnectionState(ConnectionState.Connected) + assertEquals(ConnectionStatus.CONNECTED, awaitItem()) + + vm.activeNodeInfoReady.test { + assertEquals(false, awaitItem()) + + nodeRepository.setOurNode( + org.meshtastic.core.model.Node(num = 2, user = User(hw_model = HardwareModel.TBEAM)), + ) + connectionIdentity.value = + ConnectionIdentity(sessionGeneration = 2, address = "test", nodeNum = 2, deviceId = null) + assertEquals(true, awaitItem()) + + // Region and node readiness are configuration-warning inputs, not connection-lifecycle states. + assertEquals(ConnectionStatus.CONNECTED, vm.connectionStatus.value) + cancelAndIgnoreRemainingEvents() + } + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `same-node reconnect readiness does not rewrite the connecting lifecycle`() = runTest { + val vm = newViewModel() + + vm.activeNodeInfoReady.test { + assertEquals(false, awaitItem()) + + // A cached node whose num matches the fresh session identity can become warning-ready before Connected. + nodeRepository.setOurNode( + org.meshtastic.core.model.Node(num = 7, user = User(hw_model = HardwareModel.TBEAM)), + ) + connectionIdentity.value = + ConnectionIdentity(sessionGeneration = 3, address = "test", nodeNum = 7, deviceId = null) + advanceUntilIdle() + assertEquals(true, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + vm.connectionStatus.test { + assertEquals(ConnectionStatus.NOT_CONNECTED, awaitItem()) + + serviceRepository.setConnectionState(ConnectionState.Connecting) + assertEquals(ConnectionStatus.CONNECTING, awaitItem()) + + serviceRepository.setConnectionState(ConnectionState.Connected) + assertEquals(ConnectionStatus.CONNECTED, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `active node readiness is withdrawn when the identity is cleared or replaced`() = runTest { + val vm = newViewModel() + + vm.activeNodeInfoReady.test { + assertEquals(false, awaitItem()) + + nodeRepository.setOurNode( + org.meshtastic.core.model.Node(num = 7, user = User(hw_model = HardwareModel.TBEAM)), + ) + connectionIdentity.value = + ConnectionIdentity(sessionGeneration = 3, address = "first", nodeNum = 7, deviceId = null) + assertEquals(true, awaitItem()) + + connectionIdentity.value = null + assertEquals(false, awaitItem()) + + connectionIdentity.value = + ConnectionIdentity(sessionGeneration = 4, address = "second", nodeNum = 8, deviceId = null) + advanceUntilIdle() + assertEquals(false, vm.activeNodeInfoReady.value) + + nodeRepository.setOurNode( + org.meshtastic.core.model.Node(num = 8, user = User(hw_model = HardwareModel.TBEAM)), + ) + assertEquals(true, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `Disconnected with Reconnecting progress maps to RECONNECTING`() = runTest { viewModel.connectionStatus.test { 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 bd4e00493e..8f60ddee24 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 @@ -61,6 +61,7 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.FirmwareUpdateDestination import org.meshtastic.core.model.FirmwareUpdateNotice import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.model.service.LockdownState import org.meshtastic.core.navigation.FirmwareRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute @@ -148,6 +149,67 @@ import org.meshtastic.feature.connections.ui.components.TransportSelector */ private val CardMinHeight = 100.dp +/** Whether the connected card's config warning cards (region, transmit) may render for this connection. */ +internal fun canShowConfigWarnings( + connectedWithNode: Boolean, + activeNodeInfoReady: Boolean, + lockdownState: LockdownState, + isManaged: Boolean, + isPhysicalDevice: Boolean, +): Boolean = + connectedWithNode && activeNodeInfoReady && lockdownState.allowsConfigWrites && !isManaged && isPhysicalDevice + +/** Applies connection policy and renders the actionable configuration-health cards it admits. */ +@Composable +internal fun ConfigurationWarningCards( + connectedWithNode: Boolean, + activeNodeInfoReady: Boolean, + lockdownState: LockdownState, + isManaged: Boolean, + isPhysicalDevice: Boolean, + regionUnset: Boolean, + txDisabled: Boolean, + onConfigNavigate: (Route) -> Unit, +) { + val showWarnings = + canShowConfigWarnings( + connectedWithNode = connectedWithNode, + activeNodeInfoReady = activeNodeInfoReady, + lockdownState = lockdownState, + isManaged = isManaged, + isPhysicalDevice = isPhysicalDevice, + ) + + Column { + if (showWarnings && regionUnset) { + Spacer(modifier = Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingIcon = MeshtasticIcons.Language, + text = stringResource(Res.string.set_your_region), + // Navigate straight to the LoRa screen: it re-reads the route on entry and renders from the + // connect-time snapshot meanwhile, so pre-fetching behind a progress dialog here bought nothing and + // could strand the user on an empty dialog when the read completed before the dialog observed it. + onClick = { onConfigNavigate(SettingsRoute.LoRa) }, + ) + } + } + + // An unset region already disables transmit and has its own card, so do not blame one root cause twice. + if (showWarnings && txDisabled && !regionUnset) { + Spacer(modifier = Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingIcon = MeshtasticIcons.CellTower, + text = stringResource(Res.string.transmit_disabled), + supportingText = stringResource(Res.string.transmit_disabled_summary), + onClick = { onConfigNavigate(SettingsRoute.LoRa) }, + ) + } + } + } +} + /** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */ @OptIn(ExperimentalMaterial3Api::class) @Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder") @@ -166,7 +228,9 @@ fun ConnectionsScreen( val firmwareUpdateNotice by connectionsViewModel.firmwareUpdateNotice.collectAsStateWithLifecycle() val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle() val txDisabled by connectionsViewModel.txDisabled.collectAsStateWithLifecycle() - val sessionAuthorized by connectionsViewModel.sessionAuthorized.collectAsStateWithLifecycle() + val activeNodeInfoReady by connectionsViewModel.activeNodeInfoReady.collectAsStateWithLifecycle() + val lockdownState by connectionsViewModel.lockdownState.collectAsStateWithLifecycle() + val localConfig by connectionsViewModel.localConfig.collectAsStateWithLifecycle() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle() @@ -504,36 +568,27 @@ fun ConnectionsScreen( val isPhysicalDevice = selectedDevice != InterfaceId.MOCK.id.toString() && selectedDevice != InterfaceId.REPLAY.id.toString() - val canShowConfigWarnings = - uiState == ConnectionUiState.CONNECTED_WITH_NODE && sessionAuthorized && isPhysicalDevice - if (canShowConfigWarnings && regionUnset) { - Spacer(modifier = Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - ListItem( - leadingIcon = MeshtasticIcons.Language, - text = stringResource(Res.string.set_your_region), - // Navigate straight to the LoRa screen: it re-reads the route on entry and - // renders from the connect-time snapshot meanwhile, so pre-fetching behind a - // progress dialog here bought nothing and could strand the user on an empty - // dialog when the read completed before the dialog observed it. - onClick = { onConfigNavigate(SettingsRoute.LoRa) }, - ) - } - } - - // Transmit-disabled notice. Suppressed while the region is unset: that already disables - // transmit and has its own card above, so showing both would blame one root cause twice. - if (canShowConfigWarnings && txDisabled && !regionUnset) { - Spacer(modifier = Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - ListItem( - leadingIcon = MeshtasticIcons.CellTower, - text = stringResource(Res.string.transmit_disabled), - supportingText = stringResource(Res.string.transmit_disabled_summary), - onClick = { onConfigNavigate(SettingsRoute.LoRa) }, - ) - } - } + val isManaged = localConfig.security?.is_managed == true + // Gate on LockdownState rather than sessionAuthorized. Pre-2.8 firmware and newer builds that + // do not include runtime lockdown support never enter that authentication flow, while an + // explicit DISABLED state also leaves sessionAuthorized false. None, Disabled, and Unlocked do + // not withhold config writes on lockdown grounds; AwaitingResponse stays non-actionable until + // firmware reports the result. Managed mode is a separate client policy: match the existing + // settings behavior and suppress warnings only when SecurityConfig explicitly marks the device + // managed, so an incomplete first-run config stream does not hide the region warning. + // Node readiness binds the warnings to the active transport session. Stage 1 clears cached + // config before accepting the fresh stream, while a cached node can survive a database switch; + // do not attribute post-handshake config state to a node the new session has not identified. + ConfigurationWarningCards( + connectedWithNode = uiState == ConnectionUiState.CONNECTED_WITH_NODE, + activeNodeInfoReady = activeNodeInfoReady, + lockdownState = lockdownState, + isManaged = isManaged, + isPhysicalDevice = isPhysicalDevice, + regionUnset = regionUnset, + txDisabled = txDisabled, + onConfigNavigate = onConfigNavigate, + ) // Transport selector sits between the connection card and device list; it controls only the // visible discovery pane, not the globally selected/connected device shown above. 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 9a9c3f5f26..479cbbb019 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 @@ -38,7 +38,6 @@ import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connected_sleeping import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.disconnect -import org.meshtastic.core.resources.must_set_region import org.meshtastic.core.resources.node_restarting import org.meshtastic.core.resources.not_connected import org.meshtastic.core.resources.reconnecting @@ -61,7 +60,6 @@ fun ConnectingDeviceInfo( val statusLabel = when (connectionStatus) { ConnectionStatus.CONNECTED -> stringResource(Res.string.connected) - ConnectionStatus.MUST_SET_REGION -> stringResource(Res.string.must_set_region) ConnectionStatus.CONNECTING -> connectionProgress ?: stringResource(Res.string.connecting) ConnectionStatus.RECONNECTING -> stringResource(Res.string.reconnecting) ConnectionStatus.RESTARTING -> stringResource(Res.string.node_restarting) @@ -69,13 +67,12 @@ fun ConnectingDeviceInfo( ConnectionStatus.NOT_CONNECTED -> stringResource(Res.string.not_connected) } - // This card also renders when the transport is already CONNECTED but node info hasn't arrived yet - // (or the region needs setting), so only the not-yet-established states get "Stop Connecting". + // This card can also render while the transport is already CONNECTED but node info has not arrived yet, so only + // the not-yet-established lifecycle states get "Stop Connecting". val disconnectLabel = when (connectionStatus) { ConnectionStatus.CONNECTED, ConnectionStatus.CONNECTED_SLEEPING, - ConnectionStatus.MUST_SET_REGION, -> stringResource(Res.string.disconnect) ConnectionStatus.CONNECTING, diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreenWarningPolicyTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreenWarningPolicyTest.kt new file mode 100644 index 0000000000..600a7c1419 --- /dev/null +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreenWarningPolicyTest.kt @@ -0,0 +1,142 @@ +/* + * 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.feature.connections.ui + +import org.meshtastic.core.model.service.LockdownState +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectionsScreenWarningPolicyTest { + + @Test + fun `config warnings wait for the active node handshake`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = false, + lockdownState = LockdownState.Unlocked, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings show once the active node is ready and policy allows writes`() { + assertTrue( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.Unlocked, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings stay hidden while config writes are locked`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.Locked("needs_auth"), + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings stay hidden while lockdown response is pending`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.AwaitingResponse, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings stay hidden for managed devices after lockdown unlock`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.Unlocked, + isManaged = true, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings show when lockdown is absent and managed mode allows local config`() { + assertTrue( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.None, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings show when lockdown is explicitly disabled`() { + assertTrue( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.Disabled, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings stay hidden without a connected node`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = false, + activeNodeInfoReady = true, + lockdownState = LockdownState.Unlocked, + isManaged = false, + isPhysicalDevice = true, + ), + ) + } + + @Test + fun `config warnings stay hidden for virtual devices`() { + assertFalse( + canShowConfigWarnings( + connectedWithNode = true, + activeNodeInfoReady = true, + lockdownState = LockdownState.Unlocked, + isManaged = false, + isPhysicalDevice = false, + ), + ) + } +} diff --git a/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/ui/ConfigurationWarningCardsTest.kt b/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/ui/ConfigurationWarningCardsTest.kt new file mode 100644 index 0000000000..5a3066f806 --- /dev/null +++ b/feature/connections/src/jvmTest/kotlin/org/meshtastic/feature/connections/ui/ConfigurationWarningCardsTest.kt @@ -0,0 +1,135 @@ +/* + * 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.feature.connections.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.model.service.LockdownState +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.set_your_region +import org.meshtastic.core.resources.transmit_disabled +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class ConfigurationWarningCardsTest { + + @Test + fun `region card renders when connected policy allows local writes`() = runComposeUiTest { + setWarningCards( + lockdownState = LockdownState.Unlocked, + isManaged = false, + regionUnset = true, + txDisabled = true, + ) + + onNodeWithText(getString(Res.string.set_your_region)).assertIsDisplayed() + onNodeWithText(getString(Res.string.transmit_disabled)).assertDoesNotExist() + } + + @Test + fun `transmit card renders only when region is already configured`() = runComposeUiTest { + setWarningCards( + lockdownState = LockdownState.Disabled, + isManaged = false, + regionUnset = false, + txDisabled = true, + ) + + onNodeWithText(getString(Res.string.set_your_region)).assertDoesNotExist() + onNodeWithText(getString(Res.string.transmit_disabled)).assertIsDisplayed() + } + + @Test + fun `managed policy suppresses configuration warning cards even after lockdown unlock`() = runComposeUiTest { + setWarningCards(lockdownState = LockdownState.Unlocked, isManaged = true, regionUnset = true, txDisabled = true) + + onNodeWithText(getString(Res.string.set_your_region)).assertDoesNotExist() + onNodeWithText(getString(Res.string.transmit_disabled)).assertDoesNotExist() + } + + @Test + fun `pending lockdown response suppresses configuration warning cards`() = runComposeUiTest { + setWarningCards( + lockdownState = LockdownState.AwaitingResponse, + isManaged = false, + regionUnset = true, + txDisabled = true, + ) + + assertNoWarningCards() + } + + @Test + fun `pending node readiness suppresses configuration warning cards`() = runComposeUiTest { + setWarningCards( + lockdownState = LockdownState.None, + isManaged = false, + regionUnset = true, + txDisabled = true, + activeNodeInfoReady = false, + ) + + assertNoWarningCards() + } + + @Test + fun `virtual device suppresses configuration warning cards`() = runComposeUiTest { + setWarningCards( + lockdownState = LockdownState.None, + isManaged = false, + regionUnset = true, + txDisabled = true, + isPhysicalDevice = false, + ) + + assertNoWarningCards() + } + + private fun ComposeUiTest.setWarningCards( + lockdownState: LockdownState, + isManaged: Boolean, + regionUnset: Boolean, + txDisabled: Boolean, + activeNodeInfoReady: Boolean = true, + isPhysicalDevice: Boolean = true, + ) { + setContent { + MaterialTheme { + ConfigurationWarningCards( + connectedWithNode = true, + activeNodeInfoReady = activeNodeInfoReady, + lockdownState = lockdownState, + isManaged = isManaged, + isPhysicalDevice = isPhysicalDevice, + regionUnset = regionUnset, + txDisabled = txDisabled, + onConfigNavigate = {}, + ) + } + } + } + + private fun ComposeUiTest.assertNoWarningCards() { + onNodeWithText(getString(Res.string.set_your_region)).assertDoesNotExist() + onNodeWithText(getString(Res.string.transmit_disabled)).assertDoesNotExist() + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 6a83edf108..79bf25320a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -69,9 +69,11 @@ import org.meshtastic.core.ui.icon.VisibilityOff /** * Non-dismissable lockdown authentication dialog. * - * Shown when the connected device requires passphrase authentication. The dialog blocks all interaction with the app - * until the user either authenticates successfully or disconnects. Back gestures are suppressed to prevent dismissing - * the dialog and bypassing authentication. + * Shown while the connected device requires passphrase input or retry. The dialog blocks app interaction while shown. + * After a passphrase command is admitted to the active transport, [LockdownState.AwaitingResponse] hides it until + * firmware reports the next status. A rejected dispatch leaves the retryable state and dialog in place; a locked, + * provisioning, or failed response shows it again, while successful authentication leaves it dismissed. Back gestures + * are suppressed whenever the dialog is visible. */ @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt index 6ae5813099..5ad2b28bb8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt @@ -88,6 +88,8 @@ import org.meshtastic.feature.settings.radio.component.NodeActionButton * the one-time irreversible warning. * - [LockdownState.Locked] → ON (locked); authentication is handled by the global lockdown dialog, so the switch is * read-only here. + * - [LockdownState.AwaitingResponse] → temporarily non-actionable after transport admission while firmware processes a + * lockdown command. * - [LockdownState.Unlocked] → ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info. * * Visibility is gated on [supported] — the firmware-version capability from `Capabilities.supportsLockdown` (lockdown 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 99a9c92b05..c7d8f3045c 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 @@ -194,9 +194,7 @@ open class RadioConfigViewModel( val sessionAuthorized = serviceRepository.sessionAuthorized val lockdownState = serviceRepository.lockdownState - fun sendLockNow() { - safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() } - } + fun sendLockNow(): Boolean = lockdownCoordinator.lockNow() /** * Submits a lockdown passphrase: enables lockdown (from DISABLED), authenticates ([disable]=false from LOCKED), or @@ -208,11 +206,7 @@ open class RadioConfigViewModel( hours: Int = 0, maxSessionSeconds: Int = 0, disable: Boolean = false, - ) { - safeLaunch(tag = "submitLockdownPassphrase") { - lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) - } - } + ): Boolean = lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable) val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed