fix(connections): scope region warnings to the active connection (#7015)

This commit is contained in:
Jeremiah K authored and GitHub committed 2026-09-03 19:31:17 +00:00
1 parent 0595852ad7
commit e2d033ae6f
23 files changed
+809 -116

No files matched your search

@@ -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) {
@@ -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() {
@@ -201,11 +201,13 @@ class PacketHandlerImpl(
private val sendAckTimeoutJobs = mutableMapOf<PersistedStatusTarget, Job>()
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())
@@ -280,6 +280,35 @@ class CommandSenderImplTest {
assertFalse(result.accepted)
}
// --- lockdown direct dispatch ---
@Test
fun sendLockdownPassphrase_returnsDirectTransportAdmission() {
every { packetHandler.trySendToRadio(any<ToRadio>()) } returns true
assertTrue(commandSender.sendLockdownPassphrase("secret", boots = 3, hours = 4, maxSessionSeconds = 5))
every { packetHandler.trySendToRadio(any<ToRadio>()) } 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<ToRadio>()) }
}
@Test
fun sendLockNow_returnsDirectTransportAdmission() {
every { packetHandler.trySendToRadio(any<ToRadio>()) } returns true
assertTrue(commandSender.sendLockNow())
every { packetHandler.trySendToRadio(any<ToRadio>()) } returns false
assertFalse(commandSender.sendLockNow())
}
// --- sendAdminImmediate ---
@Test
@@ -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<LockdownState.AwaitingResponse>(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<LockdownState.Locked>(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<LockdownState.Locked>(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<LockdownState.Locked>(stateDuringDispatch)
assertIs<LockdownState.AwaitingResponse>(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<LockdownState.Locked>(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<LockdownState.Locked>(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<LockdownState.AwaitingResponse>(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<LockdownState.Unlocked>(serviceRepo.lockdownState.value)
}
@Test
@@ -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 =
@@ -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
}
}
/**
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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
}
@@ -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
}
@@ -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.
*
@@ -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
}
}
@@ -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
}
}
@@ -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<MyNodeInfo?> = 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<Boolean> =
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<ConnectionStatus> =
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
// —
@@ -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()
@@ -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<ConnectionIdentity?>(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 {
@@ -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.
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
),
)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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
@@ -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
@@ -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