mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-03 14:06:34 +02:00
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:
Generated
+10
@@ -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();
|
||||
|
||||
+8
-1
@@ -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))
|
||||
}
|
||||
|
||||
+45
-6
@@ -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() {
|
||||
|
||||
+8
-2
@@ -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() {
|
||||
|
||||
+46
-1
@@ -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()
|
||||
|
||||
|
||||
+7
-1
@@ -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. */
|
||||
|
||||
+13
-2
@@ -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()
|
||||
|
||||
+4
-16
@@ -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)
|
||||
|
||||
+11
-2
@@ -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>
|
||||
|
||||
+8
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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() {}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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() {
|
||||
|
||||
+9
-1
@@ -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() {
|
||||
|
||||
+7
-1
@@ -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() {
|
||||
|
||||
+333
@@ -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
|
||||
+18
@@ -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() {
|
||||
|
||||
+21
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user