mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
fix(connections): scope region warnings to the active connection (#7015)
This commit is contained in:
1 parent
0595852ad7
commit
e2d033ae6f
23 files changed
+809
-116
No files matched your search
+6
-8
@@ -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) {
|
||||
|
||||
+37
-16
@@ -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() {
|
||||
|
||||
+3
-1
@@ -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())
|
||||
|
||||
+29
@@ -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
|
||||
|
||||
+84
-6
@@ -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
|
||||
|
||||
+10
@@ -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 =
|
||||
|
||||
+43
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
}
|
||||
+9
-1
@@ -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.
|
||||
*
|
||||
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+26
-19
@@ -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()
|
||||
|
||||
+103
@@ -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 {
|
||||
|
||||
+86
-31
@@ -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.
|
||||
|
||||
+2
-5
@@ -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,
|
||||
|
||||
+142
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+135
@@ -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()
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-8
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user