feat(lockdown): runtime enable/disable toggle and DISABLED state

Make lockdown a runtime, user-toggleable setting rather than a one-way lock:

- Thread a `disable` flag through the lockdown send path (CommandSender,
  LockdownCoordinator, MeshActionHandler, RadioController, AIDL, UIViewModel)
  so the app can send LockdownAuth{passphrase, disable=true} to decrypt
  storage and leave lockdown.
- Add LockdownState.Disabled and map LockdownStatus.State.DISABLED; clear the
  stored passphrase and session authorization when a device reports DISABLED
  (or when the user disables it), so we never auto-unlock a disabled device.
- Add a "Lockdown mode" switch to the security settings screen
  (LockdownModeSetting): enable from DISABLED via a set-passphrase dialog with
  a one-time irreversible-SWD warning + explicit confirm; disable from UNLOCKED
  via a passphrase prompt; "Lock now" and session info while unlocked. The
  setting is hidden when the device never reports lockdown_status (non-capable).
- Tests for the disable round-trip and DISABLED mapping; refresh fakes/strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
niccellular
2026-05-28 16:30:37 -04:00
parent 2d6425e078
commit d0857ef278
23 changed files with 595 additions and 54 deletions
+10
View File
@@ -658,12 +658,22 @@ location_sharing
lockdown_backoff
lockdown_boots_remaining
lockdown_confirm_passphrase
lockdown_disable
lockdown_disable_message
lockdown_enable
lockdown_enter_passphrase
lockdown_hide_passphrase
lockdown_hours_until_expiry
lockdown_incorrect_passphrase
lockdown_irreversible_ack
lockdown_irreversible_warning
lockdown_lock_now
lockdown_lock_reason
lockdown_mode
lockdown_mode_setting_up
lockdown_mode_summary_locked
lockdown_mode_summary_off
lockdown_mode_summary_unlocked
lockdown_passphrase
lockdown_passphrases_do_not_match
lockdown_session_boots_remaining
@@ -205,8 +205,8 @@ interface IMeshService {
*/
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
/// Send a lockdown passphrase to authenticate with a TAK-locked device
void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds);
/// Send a lockdown passphrase to authenticate with a locked device, or (disable=true) to turn lockdown off
void sendLockdownUnlock(in String passphrase, in int bootTtl, in int hourTtl, in int maxSessionSeconds, in boolean disable);
/// Send a Lock Now command to the connected TAK-enabled device
void sendLockNow();
@@ -375,7 +375,13 @@ class CommandSenderImpl(
}
}
override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
override fun sendLockdownPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
val validUntilEpoch =
if (hours > 0) {
(nowMillis / MILLIS_PER_SECOND + hours.toLong() * SECONDS_PER_HOUR).toInt()
@@ -388,6 +394,7 @@ class CommandSenderImpl(
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
max_session_seconds = maxSessionSeconds.coerceAtLeast(0),
disable = disable,
)
sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
}
@@ -80,10 +80,28 @@ class LockdownCoordinatorImpl(
LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason)
LockdownStatus.State.UNLOCKED -> handleUnlocked(status)
LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds)
LockdownStatus.State.DISABLED -> handleDisabled()
LockdownStatus.State.STATE_UNSPECIFIED -> Logger.w { "Lockdown: Received STATE_UNSPECIFIED from firmware" }
}
}
@Suppress("TooGenericExceptionCaught")
private fun handleDisabled() {
// Lockdown-capable but currently OFF. Drop any stale stored passphrase so we don't try to auto-unlock later.
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
try {
passphraseStore.clearPassphrase(deviceAddress)
} catch (e: Exception) {
Logger.e(e) { "Lockdown: Failed to clear stored passphrase on DISABLED" }
}
}
resetTransientState()
serviceRepository.setSessionAuthorized(false)
serviceRepository.setLockdownTokenInfo(null)
serviceRepository.setLockdownState(LockdownState.Disabled)
}
private fun handleLockNowAcknowledged() {
Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" }
serviceRepository.setSessionAuthorized(false)
@@ -187,15 +205,36 @@ class LockdownCoordinatorImpl(
}
}
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
pendingMaxSessionSeconds = maxSessionSeconds
@Suppress("TooGenericExceptionCaught")
override fun submitPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
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.
pendingPassphrase = null
val deviceAddress = radioInterfaceService.getDeviceAddress()
if (deviceAddress != null) {
try {
passphraseStore.clearPassphrase(deviceAddress)
} catch (e: Exception) {
Logger.e(e) { "Lockdown: Failed to clear stored passphrase while disabling" }
}
}
} else {
pendingPassphrase = passphrase
pendingBoots = boots
pendingHours = hours
pendingMaxSessionSeconds = maxSessionSeconds
}
serviceRepository.setLockdownState(LockdownState.None)
commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds)
commandSender.sendLockdownPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)
}
override fun lockNow() {
@@ -404,8 +404,14 @@ class MeshActionHandlerImpl(
}
}
override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds)
override fun handleSendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable)
}
override fun handleSendLockNow() {
@@ -32,6 +32,7 @@ import org.meshtastic.proto.LockdownStatus
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -67,12 +68,22 @@ class LockdownCoordinatorImplTest {
var lastPassphrase: String? = null
var lastBoots: Int = 0
var lastHours: Int = 0
var lastMaxSessionSeconds: Int = 0
var lastDisable: Boolean = false
var lockNowCalled = false
override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) {
override fun sendLockdownPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
lastPassphrase = passphrase
lastBoots = boots
lastHours = hours
lastMaxSessionSeconds = maxSessionSeconds
lastDisable = disable
}
override fun sendLockNow() {
@@ -488,5 +499,39 @@ class LockdownCoordinatorImplTest {
assertIs<LockdownState.Locked>(serviceRepo.lockdownState.value)
}
@Test
fun `submitPassphrase with disable forwards disable flag and clears stored passphrase`() {
radioService.setDeviceAddress(testDeviceAddress)
passphraseStore.saved[testDeviceAddress] = StoredPassphrase("original", 50, 0)
coordinator.submitPassphrase("original", boots = 0, hours = 0, disable = true)
assertTrue(commandSender.lastDisable)
assertTrue(passphraseStore.saved.isEmpty())
}
@Test
fun `submitPassphrase without disable does not set disable flag`() {
coordinator.submitPassphrase("p", boots = 5, hours = 0)
assertFalse(commandSender.lastDisable)
}
// endregion
// region DISABLED
@Test
fun `DISABLED sets Disabled state, clears authorization, token, and stored passphrase`() {
radioService.setDeviceAddress(testDeviceAddress)
passphraseStore.saved[testDeviceAddress] = StoredPassphrase("stale", 50, 0)
coordinator.handleLockdownStatus(LockdownStatus(state = LockdownStatus.State.DISABLED))
assertIs<LockdownState.Disabled>(serviceRepo.lockdownState.value)
assertFalse(serviceRepo.sessionAuthorized.value)
assertNull(serviceRepo.lockdownTokenInfo.value)
assertTrue(passphraseStore.saved.isEmpty())
}
// endregion
}
@@ -340,8 +340,17 @@ interface RadioController {
*/
fun setDeviceAddress(address: String)
/** Submits a lockdown passphrase to authenticate with a TAK-locked device. */
suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0)
/**
* Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn
* lockdown OFF.
*/
suspend fun sendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to the connected TAK-enabled device. */
suspend fun sendLockNow()
@@ -32,6 +32,9 @@ sealed class LockdownState {
data object Unlocked : LockdownState()
/** Device is lockdown-capable but lockdown is currently OFF. The toggle shows OFF. */
data object Disabled : LockdownState()
/** Lock Now ACK received — client should disconnect immediately, no dialog. */
data object LockNowAcknowledged : LockdownState()
@@ -84,12 +84,18 @@ interface CommandSender {
/** Requests neighbor info from a specific node. */
fun requestNeighborInfo(requestId: Int, destNum: Int)
/** Sends a lockdown passphrase to authenticate with a TAK-locked device. */
/**
* Sends a lockdown passphrase to authenticate with a locked device.
*
* @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`.
*/
fun sendLockdownPassphrase(
passphrase: String,
boots: Int = 0,
hours: Int = 0,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to immediately lock a TAK-enabled device. */
@@ -42,8 +42,19 @@ interface LockdownCoordinator {
/** Routes an incoming typed [LockdownStatus] from FromRadio. */
fun handleLockdownStatus(status: LockdownStatus)
/** Submits a passphrase to authenticate with the locked device. */
fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0)
/**
* Submits a passphrase to authenticate with the locked device.
*
* @param disable when `true`, turns lockdown OFF (decrypt storage back to plaintext); the device reboots and
* reconnects reporting `DISABLED`.
*/
fun submitPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to the connected device. */
fun lockNow()
@@ -19,16 +19,10 @@ package org.meshtastic.core.repository
/**
* Stored passphrase entry with associated TTL parameters.
*
* @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited.
* Non-zero is firmware-side enforcement: the device revokes auth and reboots
* after this many seconds of uptime even if the boot-count TTL is still valid.
* @param maxSessionSeconds Per-boot uptime cap, in seconds. 0 = unlimited. Non-zero is firmware-side enforcement: the
* device revokes auth and reboots after this many seconds of uptime even if the boot-count TTL is still valid.
*/
data class StoredPassphrase(
val passphrase: String,
val boots: Int,
val hours: Int,
val maxSessionSeconds: Int = 0,
) {
data class StoredPassphrase(val passphrase: String, val boots: Int, val hours: Int, val maxSessionSeconds: Int = 0) {
init {
require(passphrase.isNotEmpty()) { "passphrase must not be empty" }
}
@@ -46,13 +40,7 @@ interface LockdownPassphraseStore {
fun getPassphrase(deviceAddress: String): StoredPassphrase?
/** Saves the passphrase and TTL parameters for the given device address. */
fun savePassphrase(
deviceAddress: String,
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int = 0,
)
fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int = 0)
/** Clears the stored passphrase for the given device address. */
fun clearPassphrase(deviceAddress: String)
@@ -117,8 +117,17 @@ interface MeshActionHandler {
/** Updates the last used device address. */
fun handleUpdateLastAddress(deviceAddr: String?)
/** Submits a lockdown passphrase to authenticate with a TAK-locked device. */
fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int = 0)
/**
* Submits a lockdown passphrase to authenticate with a locked device, or (when [disable] is `true`) to turn
* lockdown OFF.
*/
fun handleSendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
)
/** Sends a Lock Now command to the connected TAK-enabled device. */
fun handleSendLockNow()
@@ -682,12 +682,22 @@
<string name="lockdown_backoff">Try again in %1$d seconds.</string>
<string name="lockdown_boots_remaining">Boots remaining</string>
<string name="lockdown_confirm_passphrase">Confirm passphrase</string>
<string name="lockdown_disable">Disable lockdown</string>
<string name="lockdown_disable_message">Enter your passphrase to turn off lockdown. The device will decrypt its storage and reboot.</string>
<string name="lockdown_enable">Enable lockdown</string>
<string name="lockdown_enter_passphrase">Enter Passphrase</string>
<string name="lockdown_hide_passphrase">Hide</string>
<string name="lockdown_hours_until_expiry">Hours until expiry</string>
<string name="lockdown_incorrect_passphrase">Incorrect passphrase.</string>
<string name="lockdown_irreversible_ack">I understand this cannot be undone</string>
<string name="lockdown_irreversible_warning">Enabling lockdown permanently locks the debug port (SWD) on supported hardware. Recovery then requires a full erase. This cannot be undone.</string>
<string name="lockdown_lock_now">Lock Now</string>
<string name="lockdown_lock_reason">Reason: %1$s</string>
<string name="lockdown_mode">Lockdown mode</string>
<string name="lockdown_mode_setting_up">Setting up…</string>
<string name="lockdown_mode_summary_locked">Active — enter your passphrase to unlock this connection.</string>
<string name="lockdown_mode_summary_off">Encrypt device storage and require a passphrase per connection.</string>
<string name="lockdown_mode_summary_unlocked">Active — storage encrypted, this connection authenticated.</string>
<string name="lockdown_passphrase">Passphrase</string>
<string name="lockdown_passphrases_do_not_match">Passphrases do not match</string>
<string name="lockdown_session_boots_remaining">Session: %1$d reboots remaining</string>
@@ -221,8 +221,14 @@ class AndroidRadioControllerImpl(
context.startForegroundService(intent)
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds)
override suspend fun sendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable)
}
override suspend fun sendLockNow() {
@@ -455,12 +455,14 @@ class MeshService : Service() {
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) = toRemoteExceptions {
router.actionHandler.handleSendLockdownUnlock(
passphrase.orEmpty(),
bootTtl,
hourTtl,
maxSessionSeconds,
disable,
)
}
@@ -126,7 +126,13 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {}
override fun sendLockdownUnlock(
passphrase: String?,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {}
override fun sendLockNow() {}
}
@@ -235,8 +235,14 @@ class DirectRadioControllerImpl(
radioInterfaceService.setDeviceAddress(address)
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {
actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds)
override suspend fun sendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable)
}
override suspend fun sendLockNow() {
@@ -28,6 +28,7 @@ class FakeLockdownCoordinator : LockdownCoordinator {
var lastBoots: Int? = null
var lastHours: Int? = null
var lastMaxSessionSeconds: Int? = null
var lastDisable: Boolean = false
var lockNowCalled = false
override fun onConnect() {
@@ -46,11 +47,18 @@ class FakeLockdownCoordinator : LockdownCoordinator {
lastStatus = status
}
override fun submitPassphrase(passphrase: String, boots: Int, hours: Int, maxSessionSeconds: Int) {
override fun submitPassphrase(
passphrase: String,
boots: Int,
hours: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {
lastPassphrase = passphrase
lastBoots = boots
lastHours = hours
lastMaxSessionSeconds = maxSessionSeconds
lastDisable = disable
}
override fun lockNow() {
@@ -162,7 +162,13 @@ class FakeRadioController :
lastSetDeviceAddress = address
}
override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int, maxSessionSeconds: Int) {}
override suspend fun sendLockdownUnlock(
passphrase: String,
bootTtl: Int,
hourTtl: Int,
maxSessionSeconds: Int,
disable: Boolean,
) {}
override suspend fun sendLockNow() {}
@@ -146,8 +146,11 @@ class UIViewModel(
bootTtl: Int = DEFAULT_BOOT_TTL,
hourTtl: Int = 0,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
) {
viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds) }
viewModelScope.launch {
radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl, maxSessionSeconds, disable)
}
}
fun sendLockNow() {
@@ -0,0 +1,333 @@
/*
* 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.settings.lockdown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.lockdown_boots_remaining
import org.meshtastic.core.resources.lockdown_confirm_passphrase
import org.meshtastic.core.resources.lockdown_disable
import org.meshtastic.core.resources.lockdown_disable_message
import org.meshtastic.core.resources.lockdown_enable
import org.meshtastic.core.resources.lockdown_hide_passphrase
import org.meshtastic.core.resources.lockdown_hours_until_expiry
import org.meshtastic.core.resources.lockdown_irreversible_ack
import org.meshtastic.core.resources.lockdown_irreversible_warning
import org.meshtastic.core.resources.lockdown_lock_now
import org.meshtastic.core.resources.lockdown_mode
import org.meshtastic.core.resources.lockdown_mode_setting_up
import org.meshtastic.core.resources.lockdown_mode_summary_locked
import org.meshtastic.core.resources.lockdown_mode_summary_off
import org.meshtastic.core.resources.lockdown_mode_summary_unlocked
import org.meshtastic.core.resources.lockdown_passphrase
import org.meshtastic.core.resources.lockdown_passphrases_do_not_match
import org.meshtastic.core.resources.lockdown_session_minutes
import org.meshtastic.core.resources.lockdown_session_minutes_help
import org.meshtastic.core.resources.lockdown_set_passphrase
import org.meshtastic.core.resources.lockdown_show_passphrase
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
import org.meshtastic.feature.settings.radio.component.NodeActionButton
/**
* Runtime lockdown-mode toggle for the security settings screen.
*
* The switch and its dialogs are driven entirely by the latest [lockdownState]:
* - [LockdownState.Disabled] / [LockdownState.NeedsProvision] OFF; turning ON opens the set-passphrase dialog with
* 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.Unlocked] ON; turning OFF opens the disable dialog, plus a "Lock now" affordance and session info.
*
* When [lockdownState] is [LockdownState.None] the device is not lockdown-capable (it never sent a `lockdown_status`),
* so nothing is rendered.
*/
@Composable
fun ColumnScope.LockdownModeSetting(
lockdownState: LockdownState,
tokenInfo: LockdownTokenInfo?,
connected: Boolean,
containerColor: Color,
onEnable: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit,
onDisable: (passphrase: String) -> Unit,
onLockNow: () -> Unit,
modifier: Modifier = Modifier,
) {
if (lockdownState is LockdownState.None) return
var showEnableDialog by rememberSaveable { mutableStateOf(false) }
var showDisableDialog by rememberSaveable { mutableStateOf(false) }
val lockdownOn = lockdownState is LockdownState.Locked || lockdownState is LockdownState.Unlocked
val unlocked = lockdownState is LockdownState.Unlocked
// Only DISABLED/NEEDS_PROVISION (turn on) and UNLOCKED (turn off) are actionable from here; LOCKED auth is driven
// by the blocking global dialog.
val toggleEnabled =
connected &&
(lockdownState is LockdownState.Disabled || lockdownState is LockdownState.NeedsProvision || unlocked)
val summary =
when (lockdownState) {
is LockdownState.Unlocked -> stringResource(Res.string.lockdown_mode_summary_unlocked)
is LockdownState.Locked -> stringResource(Res.string.lockdown_mode_summary_locked)
is LockdownState.NeedsProvision -> stringResource(Res.string.lockdown_mode_setting_up)
else -> stringResource(Res.string.lockdown_mode_summary_off)
}
SwitchPreference(
modifier = modifier,
title = stringResource(Res.string.lockdown_mode),
summary = summary,
checked = lockdownOn,
enabled = toggleEnabled,
onCheckedChange = { turnOn -> if (turnOn) showEnableDialog = true else showDisableDialog = true },
containerColor = containerColor,
)
if (unlocked) {
LockdownSessionStatus(tokenInfo = tokenInfo)
NodeActionButton(
modifier = Modifier.padding(horizontal = SPACING_DP.dp),
title = stringResource(Res.string.lockdown_lock_now),
enabled = connected,
onClick = onLockNow,
)
}
if (showEnableDialog) {
EnableLockdownDialog(
onConfirm = { passphrase, boots, hours, sessionMinutes ->
showEnableDialog = false
onEnable(passphrase, boots, hours, sessionMinutes)
},
onDismiss = { showEnableDialog = false },
)
}
if (showDisableDialog) {
DisableLockdownDialog(
onConfirm = { passphrase ->
showDisableDialog = false
onDisable(passphrase)
},
onDismiss = { showDisableDialog = false },
)
}
}
@Suppress("LongMethod")
@Composable
private fun EnableLockdownDialog(
onConfirm: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit,
onDismiss: () -> Unit,
) {
var passphrase by rememberSaveable { mutableStateOf("") }
var confirmPassphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var boots by rememberSaveable { mutableIntStateOf(LockdownPassphraseStore.DEFAULT_BOOTS) }
var hours by rememberSaveable { mutableIntStateOf(0) }
var sessionMinutes by rememberSaveable { mutableIntStateOf(0) }
var acknowledged by rememberSaveable { mutableStateOf(false) }
val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN
val matches = passphrase == confirmPassphrase
val isValid = passphraseValid && matches && acknowledged
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.lockdown_set_passphrase)) },
text = {
Column {
Text(
text = stringResource(Res.string.lockdown_irreversible_warning),
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
PassphraseField(
value = passphrase,
onValueChange = { passphrase = it },
label = stringResource(Res.string.lockdown_passphrase),
passwordVisible = passwordVisible,
onToggleVisibility = { passwordVisible = !passwordVisible },
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
OutlinedTextField(
value = confirmPassphrase,
onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it },
label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
isError = confirmPassphrase.isNotEmpty() && !matches,
supportingText =
if (confirmPassphrase.isNotEmpty() && !matches) {
{ Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) }
} else {
null
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedTextField(
value = boots.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } },
label = { Text(stringResource(Res.string.lockdown_boots_remaining)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(SPACING_DP.dp))
OutlinedTextField(
value = hours.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } },
label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(SPACING_DP.dp))
OutlinedTextField(
value = sessionMinutes.toString(),
onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } },
label = { Text(stringResource(Res.string.lockdown_session_minutes)) },
supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
Text(stringResource(Res.string.lockdown_irreversible_ack))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(passphrase, boots, hours, sessionMinutes) }, enabled = isValid) {
Text(stringResource(Res.string.lockdown_enable))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
@Composable
private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDismiss: () -> Unit) {
var passphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
val isValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.lockdown_disable)) },
text = {
Column {
Text(stringResource(Res.string.lockdown_disable_message))
Spacer(modifier = Modifier.height(SPACING_DP.dp))
PassphraseField(
value = passphrase,
onValueChange = { passphrase = it },
label = stringResource(Res.string.lockdown_passphrase),
passwordVisible = passwordVisible,
onToggleVisibility = { passwordVisible = !passwordVisible },
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(passphrase) }, enabled = isValid) {
Text(stringResource(Res.string.lockdown_disable))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
@Composable
private fun PassphraseField(
value: String,
onValueChange: (String) -> Unit,
label: String,
passwordVisible: Boolean,
onToggleVisibility: () -> Unit,
) {
OutlinedTextField(
value = value,
onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) onValueChange(it) },
label = { Text(label) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = onToggleVisibility) {
Icon(
imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility,
contentDescription =
stringResource(
if (passwordVisible) {
Res.string.lockdown_hide_passphrase
} else {
Res.string.lockdown_show_passphrase
},
),
)
}
},
modifier = Modifier.fillMaxWidth(),
)
}
// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes.
private const val MAX_PASSPHRASE_LEN = 64
private const val MAX_BYTE_VALUE = 255
private const val SPACING_DP = 8
@@ -60,6 +60,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.LocationService
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
@@ -141,11 +142,28 @@ open class RadioConfigViewModel(
val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
val sessionAuthorized = serviceRepository.sessionAuthorized
val lockdownState = serviceRepository.lockdownState
fun sendLockNow() {
safeLaunch(tag = "sendLockNow") { lockdownCoordinator.lockNow() }
}
/**
* Submits a lockdown passphrase: enables lockdown (from DISABLED), authenticates ([disable]=false from LOCKED), or
* turns lockdown off ([disable]=true from UNLOCKED).
*/
fun submitLockdownPassphrase(
passphrase: String,
boots: Int = LockdownPassphraseStore.DEFAULT_BOOTS,
hours: Int = 0,
maxSessionSeconds: Int = 0,
disable: Boolean = false,
) {
safeLaunch(tag = "submitLockdownPassphrase") {
lockdownCoordinator.submitPassphrase(passphrase, boots, hours, maxSessionSeconds, disable)
}
}
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {
@@ -46,7 +46,6 @@ import org.meshtastic.core.resources.config_security_public_key
import org.meshtastic.core.resources.config_security_serial_enabled
import org.meshtastic.core.resources.debug_log_api_enabled
import org.meshtastic.core.resources.direct_message_key
import org.meshtastic.core.resources.lockdown_lock_now
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.managed_mode
import org.meshtastic.core.resources.private_key
@@ -63,7 +62,7 @@ import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.feature.settings.lockdown.LockdownSessionStatus
import org.meshtastic.feature.settings.lockdown.LockdownModeSetting
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
import kotlin.random.Random
@@ -206,16 +205,25 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle()
val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle()
if (authorized) {
LockdownSessionStatus(tokenInfo = tokenInfo)
}
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(Res.string.lockdown_lock_now),
enabled = state.connected && authorized,
onClick = { viewModel.sendLockNow() },
LockdownModeSetting(
lockdownState = lockdownState,
tokenInfo = tokenInfo,
connected = state.connected,
containerColor = CardDefaults.cardColors().containerColor,
onEnable = { passphrase, boots, hours, sessionMinutes ->
viewModel.submitLockdownPassphrase(
passphrase = passphrase,
boots = boots,
hours = hours,
maxSessionSeconds = sessionMinutes * SECONDS_PER_MINUTE,
)
},
onDisable = { passphrase ->
viewModel.submitLockdownPassphrase(passphrase = passphrase, disable = true)
},
onLockNow = { viewModel.sendLockNow() },
)
}
}
@@ -249,3 +257,5 @@ fun PrivateKeyRegenerateDialog(
)
}
}
private const val SECONDS_PER_MINUTE = 60