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