diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt
new file mode 100644
index 0000000000..e613962bdd
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/LockdownUnlockDialog.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.AlertDialog
+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.Modifier
+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.meshtastic.core.model.service.LockdownState
+import org.meshtastic.core.model.service.LockdownTokenInfo
+
+@Suppress("LongMethod")
+@Composable
+fun LockdownUnlockDialog(
+ lockdownState: LockdownState,
+ lockdownTokenInfo: LockdownTokenInfo? = null,
+ onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val shouldShow =
+ when (lockdownState) {
+ is LockdownState.Locked -> true
+ is LockdownState.NeedsProvision -> true
+ is LockdownState.UnlockFailed -> true
+ is LockdownState.UnlockBackoff -> true
+ else -> false
+ }
+ BackHandler(enabled = shouldShow, onBack = onDismiss)
+ if (!shouldShow) return
+
+ var passphrase by rememberSaveable { mutableStateOf("") }
+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
+ val initialBoots = lockdownTokenInfo?.bootsRemaining ?: DEFAULT_BOOTS
+ val initialHours =
+ if ((lockdownTokenInfo?.expiryEpoch ?: 0L) > 0L) {
+ ((lockdownTokenInfo!!.expiryEpoch - System.currentTimeMillis() / 1000) / 3600)
+ .toInt()
+ .coerceAtLeast(0)
+ } else {
+ 0
+ }
+ var boots by rememberSaveable { mutableIntStateOf(initialBoots) }
+ var hours by rememberSaveable { mutableIntStateOf(initialHours) }
+
+ val isProvisioning = lockdownState is LockdownState.NeedsProvision
+ val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase"
+ val inBackoff = lockdownState is LockdownState.UnlockBackoff
+ val isValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN && !inBackoff
+
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text(text = title) },
+ text = {
+ Column {
+ when (lockdownState) {
+ is LockdownState.UnlockFailed -> {
+ Text(text = "Incorrect passphrase.", color = MaterialTheme.colorScheme.error)
+ Spacer(modifier = Modifier.height(SPACING_DP.dp))
+ }
+ is LockdownState.UnlockBackoff -> {
+ Text(
+ text = "Try again in ${lockdownState.backoffSeconds} seconds.",
+ color = MaterialTheme.colorScheme.error,
+ )
+ Spacer(modifier = Modifier.height(SPACING_DP.dp))
+ }
+ is LockdownState.Locked -> {
+ if (lockdownState.lockReason.isNotEmpty()) {
+ Text(text = "Reason: ${lockdownState.lockReason}")
+ Spacer(modifier = Modifier.height(SPACING_DP.dp))
+ }
+ }
+ else -> {}
+ }
+
+ OutlinedTextField(
+ value = passphrase,
+ onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) passphrase = it },
+ label = { Text("Passphrase") },
+ singleLine = true,
+ visualTransformation =
+ if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector =
+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (passwordVisible) "Hide" else "Show",
+ )
+ }
+ },
+ 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("Boot TTL") },
+ 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("Hour TTL") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onSubmit(passphrase, boots, hours) }, enabled = isValid) { Text("Submit") }
+ },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+}
+
+private const val DEFAULT_BOOTS = 50
+private const val MAX_PASSPHRASE_LEN = 64
+private const val MAX_BYTE_VALUE = 255
+private const val SPACING_DP = 8
diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
index f2307dd904..c4b099b813 100644
--- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
+++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
@@ -204,4 +204,10 @@ interface IMeshService {
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
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);
+
+ /// Send a Lock Now command to the connected TAK-enabled device
+ void sendLockNow();
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index 24ababf144..0888a8c1f0 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -49,6 +49,7 @@ import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalStats
+import org.meshtastic.proto.LockdownAuth
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
@@ -56,6 +57,7 @@ import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
+import org.meshtastic.proto.ToRadio
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
@@ -373,6 +375,38 @@ class CommandSenderImpl(
}
}
+ override fun sendLockdownPassphrase(passphrase: String, boots: Int, hours: Int) {
+ val validUntilEpoch =
+ if (hours > 0) (nowMillis / 1000L + hours.toLong() * SECONDS_PER_HOUR).toInt() else 0
+ val lockdownAuth =
+ LockdownAuth(
+ passphrase = passphrase.encodeToByteArray().toByteString(),
+ boots_remaining = boots.coerceAtLeast(0),
+ valid_until_epoch = validUntilEpoch,
+ )
+ sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
+ }
+
+ override fun sendLockNow() {
+ sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true)))
+ }
+
+ private fun sendLockdownAdmin(adminMessage: AdminMessage) {
+ val myNum = nodeManager.myNodeNum ?: return
+ val packet =
+ MeshPacket(
+ to = myNum,
+ id = generatePacketId(),
+ channel = 0,
+ want_ack = true,
+ hop_limit = DEFAULT_HOP_LIMIT,
+ hop_start = DEFAULT_HOP_LIMIT,
+ priority = MeshPacket.Priority.RELIABLE,
+ decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()),
+ )
+ packetHandler.sendToRadio(ToRadio(packet = packet))
+ }
+
fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
@@ -462,5 +496,7 @@ class CommandSenderImpl(
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
+
+ private const val SECONDS_PER_HOUR = 3600
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index 7ea4e92d57..f185f925fb 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -23,6 +23,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
@@ -48,6 +49,7 @@ class FromRadioPacketHandlerImpl(
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
+ private val lockdownCoordinator: LockdownCoordinator,
) : FromRadioPacketHandler {
// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
@@ -69,6 +71,7 @@ class FromRadioPacketHandlerImpl(
val deviceUIConfig = proto.deviceuiConfig
val fileInfo = proto.fileInfo
val xmodemPacket = proto.xmodemPacket
+ val lockdownStatus = proto.lockdown_status
when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
@@ -83,8 +86,10 @@ class FromRadioPacketHandlerImpl(
router.value.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
}
-
- configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)
+ configCompleteId != null -> {
+ router.value.configFlowManager.handleConfigComplete(configCompleteId)
+ lockdownCoordinator.onConfigComplete()
+ }
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
@@ -100,6 +105,8 @@ class FromRadioPacketHandlerImpl(
xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)
+ lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus)
+
clientNotification != null -> handleClientNotification(clientNotification)
// Firmware rebooted without a transport-level disconnect (common on serial/TCP).
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index e16852d251..b9026dac71 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -35,6 +35,7 @@ import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -69,6 +70,7 @@ class MeshActionHandlerImpl(
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy,
private val radioConfigRepository: RadioConfigRepository,
+ private val lockdownCoordinator: LockdownCoordinator,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshActionHandler {
@@ -401,4 +403,12 @@ class MeshActionHandlerImpl(
}
}
}
+
+ override fun handleSendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
+ lockdownCoordinator.submitPassphrase(passphrase, bootTtl, hourTtl)
+ }
+
+ override fun handleSendLockNow() {
+ lockdownCoordinator.lockNow()
+ }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index a62cb5bedc..ad4bb51f07 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -87,6 +88,7 @@ class MeshConnectionManagerImpl(
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
private val heartbeatSender: DataLayerHeartbeatSender,
+ private val lockdownCoordinator: LockdownCoordinator,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
/**
@@ -202,6 +204,7 @@ class MeshConnectionManagerImpl(
}
serviceBroadcasts.broadcastConnection()
connectTimeMsec = nowMillis
+ lockdownCoordinator.onConnect()
// Send a wake-up heartbeat before the config request. The firmware may be in a
// power-saving state where the NimBLE callback context needs warming up. The 100ms
@@ -282,6 +285,7 @@ class MeshConnectionManagerImpl(
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ lockdownCoordinator.onDisconnect()
tearDownConnection()
analytics.track(
@@ -300,6 +304,14 @@ class MeshConnectionManagerImpl(
action()
}
+ override fun clearRadioConfig() {
+ scope.handledLaunch {
+ radioConfigRepository.clearLocalConfig()
+ radioConfigRepository.clearChannelSet()
+ radioConfigRepository.clearLocalModuleConfig()
+ }
+ }
+
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
index 84994e6288..e2c207ccbe 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -339,4 +339,10 @@ interface RadioController {
* @param address The new device identifier.
*/
fun setDeviceAddress(address: String)
+
+ /** Submits a lockdown passphrase to authenticate with a TAK-locked device. */
+ suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int)
+
+ /** Sends a Lock Now command to the connected TAK-enabled device. */
+ suspend fun sendLockNow()
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt
new file mode 100644
index 0000000000..e26c88b5f6
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/LockdownState.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.service
+
+/** Represents the lockdown authentication state for a TAK-locked device. */
+sealed class LockdownState {
+ data object None : LockdownState()
+
+ /**
+ * Device is locked or this client is not yet authorized.
+ *
+ * @param lockReason machine-readable reason from firmware (e.g. "needs_auth",
+ * "token_missing", "token_expired"). Empty string when unknown.
+ */
+ data class Locked(val lockReason: String = "") : LockdownState()
+
+ data object NeedsProvision : LockdownState()
+ data object Unlocked : LockdownState()
+
+ /** Lock Now ACK received — client should disconnect immediately, no dialog. */
+ data object LockNowAcknowledged : LockdownState()
+
+ /** Wrong passphrase — retry immediately. */
+ data object UnlockFailed : LockdownState()
+
+ /** Too many attempts — must wait [backoffSeconds] before retrying. */
+ data class UnlockBackoff(val backoffSeconds: Int) : LockdownState()
+}
+
+/**
+ * Lockdown session token metadata from a successful unlock.
+ *
+ * @param bootsRemaining Number of reboots before the token expires.
+ * @param expiryEpoch Unix epoch seconds; 0 means no time-based expiry.
+ */
+data class LockdownTokenInfo(
+ val bootsRemaining: Int,
+ val expiryEpoch: Long,
+)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
index a6b58bb485..3c01bbd377 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
@@ -83,4 +83,10 @@ 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. */
+ fun sendLockdownPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0)
+
+ /** Sends a Lock Now command to immediately lock a TAK-enabled device. */
+ fun sendLockNow()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt
new file mode 100644
index 0000000000..1f642d2291
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LockdownCoordinator.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.proto.LockdownStatus
+
+/**
+ * Coordinates lockdown (TAK passphrase) authentication for TAK-locked devices.
+ *
+ * Implementations handle the full authentication lifecycle: auto-unlock with a stored
+ * passphrase, manual passphrase submission, lock-now, and session lifecycle hooks.
+ */
+interface LockdownCoordinator {
+ /** Called when a BLE connection is established, before the first config request. */
+ fun onConnect()
+
+ /** Called when a BLE connection is lost. */
+ fun onDisconnect()
+
+ /**
+ * Called on every config_complete_id from the device.
+ * After session is authorized this is a no-op to prevent re-triggering lockdown logic.
+ */
+ fun onConfigComplete()
+
+ /** 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)
+
+ /** Sends a Lock Now command to the connected device. */
+ fun lockNow()
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
index 873e1c76bd..4c6b58af88 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
@@ -116,4 +116,10 @@ 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)
+
+ /** Sends a Lock Now command to the connected TAK-enabled device. */
+ fun handleSendLockNow()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
index 9d898a3333..a7772460f4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
@@ -37,4 +37,7 @@ interface MeshConnectionManager {
/** Updates the current status notification. */
fun updateStatusNotification(telemetry: Telemetry? = null)
+
+ /** Clears the cached radio configuration (local config, channel set, module config). */
+ fun clearRadioConfig()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
index 2a09e95c8b..ac45c63e0d 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
@@ -20,6 +20,8 @@ import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.service.LockdownState
+import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.proto.ClientNotification
@@ -170,4 +172,25 @@ interface ServiceRepository {
* @param action The [ServiceAction] to perform.
*/
suspend fun onServiceAction(action: ServiceAction)
+
+ /** Reactive flow of the current lockdown authentication state. */
+ val lockdownState: StateFlow
+
+ /** Updates the lockdown state. */
+ fun setLockdownState(state: LockdownState)
+
+ /** Resets lockdown state to [LockdownState.None]. */
+ fun clearLockdownState()
+
+ /** Reactive flow of the most recent lockdown session token info. */
+ val lockdownTokenInfo: StateFlow
+
+ /** Sets the lockdown token info from a successful UNLOCKED status. */
+ fun setLockdownTokenInfo(info: LockdownTokenInfo?)
+
+ /** True once the passphrase was accepted for the current BLE connection. */
+ val sessionAuthorized: StateFlow
+
+ /** Updates the session authorization flag. */
+ fun setSessionAuthorized(authorized: Boolean)
}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index 65c10432dc..09b70e4f3d 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -52,6 +52,7 @@ kotlin {
api(projects.core.api)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.security.crypto)
implementation(libs.koin.android)
implementation(libs.koin.androidx.workmanager)
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
index af7cb85c20..041a884449 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
@@ -220,4 +220,12 @@ class AndroidRadioControllerImpl(
val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") }
context.startForegroundService(intent)
}
+
+ override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
+ serviceRepository.meshService?.sendLockdownUnlock(passphrase, bootTtl, hourTtl)
+ }
+
+ override suspend fun sendLockNow() {
+ serviceRepository.meshService?.sendLockNow()
+ }
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt
new file mode 100644
index 0000000000..480e609f02
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownHandlerImpl.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import co.touchlab.kermit.Logger
+import org.koin.core.annotation.Single
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.core.model.service.LockdownState
+import org.meshtastic.core.model.service.LockdownTokenInfo
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.LockdownCoordinator
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.proto.LockdownStatus
+
+@Single(binds = [LockdownCoordinator::class])
+class LockdownHandlerImpl(
+ private val serviceRepository: ServiceRepository,
+ private val commandSender: CommandSender,
+ private val passphraseStore: LockdownPassphraseStore,
+ private val radioInterfaceService: RadioInterfaceService,
+) : LockdownCoordinator, KoinComponent {
+ private val connectionManager: MeshConnectionManager by inject()
+ @Volatile private var wasAutoAttempt = false
+
+ @Volatile private var wasLockNow = false
+
+ @Volatile private var pendingPassphrase: String? = null
+
+ @Volatile private var pendingBoots: Int = LockdownPassphraseStore.DEFAULT_BOOTS
+
+ @Volatile private var pendingHours: Int = 0
+
+ /** Called when the BLE connection is established, before the first config request. */
+ override fun onConnect() {
+ serviceRepository.setSessionAuthorized(false)
+ wasAutoAttempt = false
+ wasLockNow = false
+ pendingPassphrase = null
+ pendingBoots = LockdownPassphraseStore.DEFAULT_BOOTS
+ pendingHours = 0
+ }
+
+ /** Called when the BLE connection is lost. */
+ override fun onDisconnect() {
+ serviceRepository.setSessionAuthorized(false)
+ serviceRepository.setLockdownTokenInfo(null)
+ serviceRepository.setLockdownState(LockdownState.None)
+ wasAutoAttempt = false
+ wasLockNow = false
+ pendingPassphrase = null
+ }
+
+ /**
+ * Called on every config_complete_id. Once [sessionAuthorized] is true (set on UNLOCKED),
+ * this is a no-op — preventing the startConfigOnly config_complete_id from triggering any
+ * further lockdown handling.
+ */
+ override fun onConfigComplete() {
+ if (serviceRepository.sessionAuthorized.value) return
+ }
+
+ /** Routes typed firmware [LockdownStatus] to per-state handlers. */
+ override fun handleLockdownStatus(status: LockdownStatus) {
+ when (status.state) {
+ LockdownStatus.State.NEEDS_PROVISION -> handleNeedsProvision()
+ LockdownStatus.State.LOCKED -> handleLocked(status.lock_reason)
+ LockdownStatus.State.UNLOCKED -> handleUnlocked(status)
+ LockdownStatus.State.UNLOCK_FAILED -> handleUnlockFailed(status.backoff_seconds)
+ LockdownStatus.State.STATE_UNSPECIFIED -> Unit
+ }
+ }
+
+ private fun handleLockNowAcknowledged() {
+ Logger.i { "Lockdown: Lock Now acknowledged — resetting session authorization" }
+ serviceRepository.setSessionAuthorized(false)
+ wasAutoAttempt = false
+ wasLockNow = false
+ pendingPassphrase = null
+ // Purge cached config; fresh config is loaded after successful re-authentication.
+ connectionManager.clearRadioConfig()
+ // Signal the UI to disconnect — no dialog, just drop the connection.
+ serviceRepository.setLockdownState(LockdownState.LockNowAcknowledged)
+ }
+
+ private fun handleLocked(lockReason: String) {
+ if (wasLockNow) {
+ handleLockNowAcknowledged()
+ return
+ }
+ val deviceAddress = radioInterfaceService.getDeviceAddress()
+ if (deviceAddress != null) {
+ val stored = passphraseStore.getPassphrase(deviceAddress)
+ if (stored != null) {
+ Logger.i { "Lockdown: Auto-unlocking (reason=$lockReason) with stored passphrase for $deviceAddress" }
+ wasAutoAttempt = true
+ commandSender.sendLockdownPassphrase(stored.passphrase, stored.boots, stored.hours)
+ return
+ }
+ }
+ serviceRepository.setLockdownState(LockdownState.Locked(lockReason))
+ }
+
+ private fun handleNeedsProvision() {
+ serviceRepository.setLockdownState(LockdownState.NeedsProvision)
+ }
+
+ private fun handleUnlocked(status: LockdownStatus) {
+ val deviceAddress = radioInterfaceService.getDeviceAddress()
+ val passphrase = pendingPassphrase
+ if (deviceAddress != null && passphrase != null) {
+ passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours)
+ Logger.i { "Lockdown: Saved passphrase for $deviceAddress" }
+ }
+ pendingPassphrase = null
+ serviceRepository.setLockdownTokenInfo(
+ LockdownTokenInfo(
+ bootsRemaining = status.boots_remaining,
+ expiryEpoch = status.valid_until_epoch.toLong() and UINT32_MASK,
+ ),
+ )
+ serviceRepository.setLockdownState(LockdownState.Unlocked)
+ // Mark session authorized BEFORE calling startConfigOnly(). When the resulting
+ // config_complete_id arrives, onConfigComplete() will see sessionAuthorized=true and
+ // return immediately — no passphrase re-send, no loop.
+ serviceRepository.setSessionAuthorized(true)
+ connectionManager.startConfigOnly()
+ }
+
+ private fun handleUnlockFailed(backoffSeconds: Int) {
+ pendingPassphrase = null
+ if (wasAutoAttempt) {
+ wasAutoAttempt = false
+ if (backoffSeconds > 0) {
+ Logger.i { "Lockdown: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" }
+ serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
+ } else {
+ val deviceAddress = radioInterfaceService.getDeviceAddress()
+ if (deviceAddress != null) {
+ passphraseStore.clearPassphrase(deviceAddress)
+ Logger.i { "Lockdown: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" }
+ }
+ serviceRepository.setLockdownState(LockdownState.Locked())
+ }
+ return
+ }
+ if (backoffSeconds > 0) {
+ Logger.i { "Lockdown: Unlock failed with backoff of ${backoffSeconds}s" }
+ serviceRepository.setLockdownState(LockdownState.UnlockBackoff(backoffSeconds))
+ } else {
+ serviceRepository.setLockdownState(LockdownState.UnlockFailed)
+ }
+ }
+
+ override fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
+ pendingPassphrase = passphrase
+ pendingBoots = boots
+ pendingHours = hours
+ wasAutoAttempt = false
+ wasLockNow = false
+ serviceRepository.setLockdownState(LockdownState.None) // hide dialog while awaiting response
+ commandSender.sendLockdownPassphrase(passphrase, boots, hours)
+ }
+
+ override fun lockNow() {
+ wasLockNow = true
+ commandSender.sendLockNow()
+ }
+
+ companion object {
+ private const val UINT32_MASK = 0xFFFFFFFFL
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt
new file mode 100644
index 0000000000..cfb7f17cb5
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStore.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import org.koin.core.annotation.Single
+
+data class StoredPassphrase(
+ val passphrase: String,
+ val boots: Int,
+ val hours: Int,
+)
+
+/**
+ * Encrypted per-device storage for lockdown passphrases.
+ *
+ * Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when
+ * available). The key is intentionally NOT gated behind biometric authentication so that
+ * auto-unlock can run in the background without user interaction.
+ */
+@Single
+class LockdownPassphraseStore(app: Application) {
+
+ private val prefs: SharedPreferences by lazy {
+ val masterKey =
+ MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
+ EncryptedSharedPreferences.create(
+ app,
+ PREFS_FILE_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+
+ fun getPassphrase(deviceAddress: String): StoredPassphrase? {
+ val key = sanitizeKey(deviceAddress)
+ val passphrase = prefs.getString("${key}_passphrase", null) ?: return null
+ val boots = prefs.getInt("${key}_boots", DEFAULT_BOOTS)
+ val hours = prefs.getInt("${key}_hours", 0)
+ return StoredPassphrase(passphrase, boots, hours)
+ }
+
+ fun savePassphrase(deviceAddress: String, passphrase: String, boots: Int, hours: Int) {
+ val key = sanitizeKey(deviceAddress)
+ prefs
+ .edit()
+ .putString("${key}_passphrase", passphrase)
+ .putInt("${key}_boots", boots)
+ .putInt("${key}_hours", hours)
+ .apply()
+ }
+
+ fun clearPassphrase(deviceAddress: String) {
+ val key = sanitizeKey(deviceAddress)
+ prefs.edit().remove("${key}_passphrase").remove("${key}_boots").remove("${key}_hours").apply()
+ }
+
+ private fun sanitizeKey(address: String): String = address.replace(":", "_")
+
+ companion object {
+ private const val PREFS_FILE_NAME = "lockdown_passphrase_store"
+ const val DEFAULT_BOOTS = 50
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 0f4bc60b7d..0c660949f0 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -401,5 +401,14 @@ class MeshService : Service() {
toRemoteExceptions {
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
}
+
+ override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) =
+ toRemoteExceptions {
+ router.actionHandler.handleSendLockdownUnlock(passphrase.orEmpty(), bootTtl, hourTtl)
+ }
+
+ override fun sendLockNow() = toRemoteExceptions {
+ router.actionHandler.handleSendLockNow()
+ }
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt
index 3549aff6e1..e88b451051 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt
@@ -125,4 +125,8 @@ open class FakeIMeshService : IMeshService.Stub() {
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
+
+ override fun sendLockdownUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
+
+ override fun sendLockNow() {}
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
index a4c95d8cd5..86b7a0398d 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt
@@ -234,4 +234,12 @@ class DirectRadioControllerImpl(
actionHandler.handleUpdateLastAddress(address)
radioInterfaceService.setDeviceAddress(address)
}
+
+ override suspend fun sendLockdownUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
+ actionHandler.handleSendLockdownUnlock(passphrase, bootTtl, hourTtl)
+ }
+
+ override suspend fun sendLockNow() {
+ actionHandler.handleSendLockNow()
+ }
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
index 5ad5c2d003..34aa85b2db 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
@@ -26,6 +26,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.service.LockdownState
+import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
@@ -125,4 +127,32 @@ open class ServiceRepositoryImpl : ServiceRepository {
override suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.send(action)
}
+
+ private val _lockdownState = MutableStateFlow(LockdownState.None)
+ override val lockdownState: StateFlow
+ get() = _lockdownState
+
+ override fun setLockdownState(state: LockdownState) {
+ _lockdownState.value = state
+ }
+
+ override fun clearLockdownState() {
+ _lockdownState.value = LockdownState.None
+ }
+
+ private val _lockdownTokenInfo = MutableStateFlow(null)
+ override val lockdownTokenInfo: StateFlow
+ get() = _lockdownTokenInfo
+
+ override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {
+ _lockdownTokenInfo.value = info
+ }
+
+ private val _sessionAuthorized = MutableStateFlow(false)
+ override val sessionAuthorized: StateFlow
+ get() = _sessionAuthorized
+
+ override fun setSessionAuthorized(authorized: Boolean) {
+ _sessionAuthorized.value = authorized
+ }
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
index f4d15d3d9c..c9f5c138ff 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
@@ -67,6 +67,7 @@ class ConnectionsViewModel(
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val connectionState = serviceRepository.connectionState
+ val lockdownState = serviceRepository.lockdownState
val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index e0d895226b..109f37f359 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
@@ -136,6 +137,21 @@ class UIViewModel(
notificationManager.cancel(notification.toString().hashCode())
}
+ val lockdownState = serviceRepository.lockdownState
+ val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
+
+ fun sendLockdownUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) {
+ viewModelScope.launch { radioController.sendLockdownUnlock(passphrase, bootTtl, hourTtl) }
+ }
+
+ fun sendLockNow() {
+ viewModelScope.launch { radioController.sendLockNow() }
+ }
+
+ fun clearLockdownState() {
+ serviceRepository.clearLockdownState()
+ }
+
/** Emits events for mesh network send/receive activity. */
val meshActivity: Flow = radioInterfaceService.meshActivity
@@ -294,4 +310,8 @@ class UIViewModel(
fun onAppIntroCompleted() {
uiPrefs.setAppIntroCompleted(true)
}
+
+ companion object {
+ private const val DEFAULT_BOOT_TTL = 50
+ }
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 991a27d97b..a4d678832c 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -39,6 +39,7 @@ import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
@@ -136,7 +137,13 @@ open class RadioConfigViewModel(
private val locationService: LocationService,
private val fileService: FileService,
private val mqttManager: MqttManager,
+ private val lockdownCoordinator: LockdownCoordinator,
) : ViewModel() {
+
+ fun sendLockNow() {
+ viewModelScope.launch { lockdownCoordinator.lockNow() }
+ }
+
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
index 3c1c505dca..efdd60fda2 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
@@ -195,6 +195,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
}
}
item {
+ val lockdownTokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle()
TitledCard(title = stringResource(Res.string.administration)) {
SwitchPreference(
title = stringResource(Res.string.managed_mode),
@@ -212,6 +213,24 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
+ HorizontalDivider()
+ val lockNowTitle = lockdownTokenInfo?.let { info ->
+ val parts = mutableListOf("boots: ${info.bootsRemaining}")
+ if (info.expiryEpoch > 0L) {
+ val dateText = java.text.DateFormat.getDateTimeInstance(
+ java.text.DateFormat.SHORT,
+ java.text.DateFormat.SHORT,
+ ).format(java.util.Date(info.expiryEpoch * 1000L))
+ parts += "until: $dateText"
+ }
+ "Lock Now (${parts.joinToString(", ")})"
+ } ?: "Lock Now"
+ NodeActionButton(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ title = lockNowTitle,
+ enabled = state.connected,
+ onClick = { viewModel.sendLockNow() },
+ )
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e8f8d93fd9..e27ec7f016 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -105,6 +105,7 @@ androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:view
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
+androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }