Merge branch 'features/lockdown-v2' of https://github.com/meshtastic/Meshtastic-Android into feat/lockdown-mode

# Conflicts:
#	app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
#	core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
#	core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
#	feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
#	feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
This commit is contained in:
James Rich
2026-05-13 08:54:22 -05:00
26 changed files with 764 additions and 2 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -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();
}

View File

@@ -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
}
}

View File

@@ -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).

View File

@@ -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<MeshMessageProcessor>,
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()
}
}

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View File

@@ -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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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<LockdownState>
/** 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<LockdownTokenInfo?>
/** 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<Boolean>
/** Updates the session authorization flag. */
fun setSessionAuthorized(authorized: Boolean)
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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() {}
}

View File

@@ -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()
}
}

View File

@@ -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>(LockdownState.None)
override val lockdownState: StateFlow<LockdownState>
get() = _lockdownState
override fun setLockdownState(state: LockdownState) {
_lockdownState.value = state
}
override fun clearLockdownState() {
_lockdownState.value = LockdownState.None
}
private val _lockdownTokenInfo = MutableStateFlow<LockdownTokenInfo?>(null)
override val lockdownTokenInfo: StateFlow<LockdownTokenInfo?>
get() = _lockdownTokenInfo
override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {
_lockdownTokenInfo.value = info
}
private val _sessionAuthorized = MutableStateFlow(false)
override val sessionAuthorized: StateFlow<Boolean>
get() = _sessionAuthorized
override fun setSessionAuthorized(authorized: Boolean) {
_sessionAuthorized.value = authorized
}
}

View File

@@ -67,6 +67,7 @@ class ConnectionsViewModel(
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val connectionState = serviceRepository.connectionState
val lockdownState = serviceRepository.lockdownState
val myNodeInfo: StateFlow<MyNodeInfo?> = nodeRepository.myNodeInfo

View File

@@ -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<MeshActivity> = radioInterfaceService.meshActivity
@@ -294,4 +310,8 @@ class UIViewModel(
fun onAppIntroCompleted() {
uiPrefs.setAppIntroCompleted(true)
}
companion object {
private const val DEFAULT_BOOT_TTL = 50
}
}

View File

@@ -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() {

View File

@@ -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() },
)
}
}
}

View File

@@ -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" }