mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-06 21:15:14 -04:00
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:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user