mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
feature: Add TAK passphrase lock/unlock support
Implement the client-side TAK passphrase authentication flow for
devices running TAK-locked firmware.
Key components:
- TakPassphraseStore: per-device passphrase persistence using
EncryptedSharedPreferences (Android Keystore AES-256-GCM), with
boot and hour TTL fields stored alongside the passphrase
- TakLockHandler: orchestrates the full lock/unlock lifecycle —
auto-unlock on reconnect using stored credentials, passphrase
submission, token info parsing, and backoff/failure handling
- MeshCommandSender: sendTakPassphrase() and sendTakLockNow() build
plain local packets that bypass PKC signing and session_passkey;
hour TTL is encoded as an absolute Unix epoch as required by firmware
- ServiceRepository: TakLockState sealed class (None, Locked,
NeedsProvision, Unlocked, LockNowAcknowledged, UnlockFailed,
UnlockBackoff), TakTokenInfo (boots remaining + expiry epoch), and
sessionAuthorized flag
- TakUnlockDialog: Compose dialog for passphrase entry, shown on
Locked and NeedsProvision states; onDismissRequest is a no-op to
prevent race conditions with firmware response timing; cancel
disconnects the user and navigates to the Connections tab
- Lock Now (Security settings): immediately disconnects the client
after informing firmware, purges cached config, navigates away
without showing a passphrase dialog
- ConnectionsScreen: suppress "region unset" prompt while the device
is TAK-locked, since pre-auth config is zeroed/redacted and would
lead the user to a blank LoRa settings screen
- AIDL: sendTakUnlock() and sendTakLockNow() wired through
MeshService → MeshActionHandler → TakLockHandler
- Security settings: "Lock Now (TAK)" button and token info display
showing boots remaining and expiry date
This commit is contained in:
@@ -243,6 +243,7 @@ dependencies {
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.kermit)
|
||||
|
||||
implementation(libs.nordic.client.android)
|
||||
|
||||
@@ -58,6 +58,8 @@ import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.service.TakLockState
|
||||
import org.meshtastic.core.service.TakTokenInfo
|
||||
import org.meshtastic.core.service.TracerouteResponse
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.client_notification
|
||||
@@ -69,6 +71,9 @@ import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val DEFAULT_BOOT_TTL = 50
|
||||
|
||||
|
||||
// Given a human name, strip out the first letter of the first three words and return that as the
|
||||
// initials for
|
||||
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
|
||||
@@ -127,6 +132,21 @@ constructor(
|
||||
meshServiceNotifications.clearClientNotification(notification)
|
||||
}
|
||||
|
||||
val takLockState: StateFlow<TakLockState> = serviceRepository.takLockState
|
||||
val takTokenInfo: StateFlow<TakTokenInfo?> = serviceRepository.takTokenInfo
|
||||
|
||||
fun sendTakUnlock(passphrase: String, bootTtl: Int = DEFAULT_BOOT_TTL, hourTtl: Int = 0) {
|
||||
serviceRepository.meshService?.sendTakUnlock(passphrase, bootTtl, hourTtl)
|
||||
}
|
||||
|
||||
fun sendTakLockNow() {
|
||||
serviceRepository.meshService?.sendTakLockNow()
|
||||
}
|
||||
|
||||
fun clearTakLockState() {
|
||||
serviceRepository.clearTakLockState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
|
||||
* even if they are the same.
|
||||
|
||||
@@ -57,15 +57,23 @@ constructor(
|
||||
router.configFlowManager.handleNodeInfo(nodeInfo)
|
||||
serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})")
|
||||
}
|
||||
configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
|
||||
configCompleteId != null -> {
|
||||
router.configFlowManager.handleConfigComplete(configCompleteId)
|
||||
router.takLockHandler.onConfigComplete()
|
||||
}
|
||||
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
|
||||
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
|
||||
config != null -> router.configHandler.handleDeviceConfig(config)
|
||||
moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
|
||||
channel != null -> router.configHandler.handleChannel(channel)
|
||||
clientNotification != null -> {
|
||||
serviceRepository.setClientNotification(clientNotification)
|
||||
serviceNotifications.showClientNotification(clientNotification)
|
||||
val msg = clientNotification.message
|
||||
if (msg.startsWith("TAK_")) {
|
||||
router.takLockHandler.handleTakNotification(msg)
|
||||
} else {
|
||||
serviceRepository.setClientNotification(clientNotification)
|
||||
serviceNotifications.showClientNotification(clientNotification)
|
||||
}
|
||||
packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
|
||||
}
|
||||
// Logging-only variants are handled by MeshMessageProcessor before dispatching here
|
||||
|
||||
@@ -59,6 +59,7 @@ constructor(
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
private val takLockHandler: TakLockHandler,
|
||||
) {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -330,6 +331,14 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSendTakUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) {
|
||||
takLockHandler.submitPassphrase(passphrase, bootTtl, hourTtl)
|
||||
}
|
||||
|
||||
fun handleSendTakLockNow() {
|
||||
takLockHandler.lockNow()
|
||||
}
|
||||
|
||||
fun handleUpdateLastAddress(deviceAddr: String?) {
|
||||
val currentAddr = meshPrefs.deviceAddress
|
||||
if (deviceAddr != currentAddr) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Constants
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
@@ -44,6 +45,7 @@ import org.meshtastic.proto.Neighbor
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -467,6 +469,68 @@ constructor(
|
||||
),
|
||||
)
|
||||
|
||||
fun sendTakPassphrase(passphrase: String, boots: Int = 0, hours: Int = 0) {
|
||||
val myNum = nodeManager?.myNodeNum ?: return
|
||||
// The firmware expects slot 2 as an absolute Unix epoch (seconds), not a duration.
|
||||
// Convert hours duration → absolute epoch; 0 hours means no time-based expiry (until=0).
|
||||
val adminKeyList = if (boots > 0 || hours > 0) {
|
||||
val untilEpoch = if (hours > 0) System.currentTimeMillis() / 1000L + hours.toLong() * 3600L else 0L
|
||||
val untilBytes = ByteArray(INT_BYTE_SIZE)
|
||||
untilBytes[0] = (untilEpoch and BYTE_MASK.toLong()).toByte()
|
||||
untilBytes[1] = ((untilEpoch shr BYTE_BITS) and BYTE_MASK.toLong()).toByte()
|
||||
untilBytes[2] = ((untilEpoch shr (BYTE_BITS * 2)) and BYTE_MASK.toLong()).toByte()
|
||||
untilBytes[3] = ((untilEpoch shr (BYTE_BITS * 3)) and BYTE_MASK.toLong()).toByte()
|
||||
listOf(
|
||||
ByteString.EMPTY, // slot 0 unused
|
||||
ByteString.of(boots.coerceIn(1, MAX_BYTE_VALUE).toByte()), // slot 1: boots u8
|
||||
untilBytes.toByteString(), // slot 2: until epoch LE u32
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val securityConfig = Config.SecurityConfig(
|
||||
private_key = passphrase.encodeToByteArray().toByteString(),
|
||||
admin_key = adminKeyList,
|
||||
)
|
||||
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
|
||||
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 sendTakLockNow() {
|
||||
val myNum = nodeManager?.myNodeNum ?: return
|
||||
val securityConfig = Config.SecurityConfig(
|
||||
private_key = ByteString.of(TAK_LOCK_BYTE),
|
||||
)
|
||||
val adminMessage = AdminMessage(set_config = Config(security = securityConfig))
|
||||
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))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PACKET_ID_MASK = 0xffffffffL
|
||||
private const val PACKET_ID_SHIFT_BITS = 32
|
||||
@@ -478,5 +542,12 @@ constructor(
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
private const val DEFAULT_HOP_LIMIT = 3
|
||||
|
||||
private const val MAX_BYTE_VALUE = 255
|
||||
private const val INT_BYTE_SIZE = 4
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val BYTE_BITS = 8
|
||||
@Suppress("MagicNumber")
|
||||
private val TAK_LOCK_BYTE = 0xFF.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import dagger.Lazy
|
||||
import org.meshtastic.core.analytics.DataPair
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
@@ -69,6 +70,7 @@ constructor(
|
||||
private val commandSender: MeshCommandSender,
|
||||
private val nodeManager: MeshNodeManager,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val takLockHandler: Lazy<TakLockHandler>,
|
||||
) {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var sleepTimeout: Job? = null
|
||||
@@ -142,6 +144,7 @@ constructor(
|
||||
Logger.d { "Starting connect" }
|
||||
connectTimeMsec = System.currentTimeMillis()
|
||||
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
|
||||
takLockHandler.get().onConnect()
|
||||
startConfigOnly()
|
||||
}
|
||||
|
||||
@@ -180,6 +183,7 @@ constructor(
|
||||
|
||||
private fun handleDisconnected() {
|
||||
connectionStateHolder.setState(ConnectionState.Disconnected)
|
||||
takLockHandler.get().onDisconnect()
|
||||
packetHandler.stopPacketQueue()
|
||||
locationManager.stop()
|
||||
mqttManager.stop()
|
||||
@@ -194,6 +198,14 @@ constructor(
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
}
|
||||
|
||||
fun clearRadioConfig() {
|
||||
scope.handledLaunch {
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
radioConfigRepository.clearChannelSet()
|
||||
radioConfigRepository.clearLocalModuleConfig()
|
||||
}
|
||||
}
|
||||
|
||||
fun startConfigOnly() {
|
||||
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ constructor(
|
||||
val configFlowManager: MeshConfigFlowManager,
|
||||
val mqttManager: MeshMqttManager,
|
||||
val actionHandler: MeshActionHandler,
|
||||
val takLockHandler: TakLockHandler,
|
||||
) {
|
||||
fun start(scope: CoroutineScope) {
|
||||
dataHandler.start(scope)
|
||||
|
||||
@@ -378,5 +378,13 @@ class MeshService : Service() {
|
||||
toRemoteExceptions {
|
||||
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
|
||||
}
|
||||
|
||||
override fun sendTakUnlock(passphrase: String, bootTtl: Int, hourTtl: Int) = toRemoteExceptions {
|
||||
router.actionHandler.handleSendTakUnlock(passphrase, bootTtl, hourTtl)
|
||||
}
|
||||
|
||||
override fun sendTakLockNow() = toRemoteExceptions {
|
||||
router.actionHandler.handleSendTakLockNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
204
app/src/main/java/com/geeksville/mesh/service/TakLockHandler.kt
Normal file
204
app/src/main/java/com/geeksville/mesh/service/TakLockHandler.kt
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import dagger.Lazy
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.service.TakLockState
|
||||
import org.meshtastic.core.service.TakTokenInfo
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TakLockHandler @Inject constructor(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val commandSender: MeshCommandSender,
|
||||
private val passphraseStore: TakPassphraseStore,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
) {
|
||||
@Volatile private var wasAutoAttempt = false
|
||||
|
||||
@Volatile private var pendingPassphrase: String? = null
|
||||
@Volatile private var pendingBoots: Int = TakPassphraseStore.DEFAULT_BOOTS
|
||||
@Volatile private var pendingHours: Int = 0
|
||||
|
||||
/** Called when the BLE connection is established, before the first config request. */
|
||||
fun onConnect() {
|
||||
serviceRepository.setSessionAuthorized(false)
|
||||
wasAutoAttempt = false
|
||||
pendingPassphrase = null
|
||||
pendingBoots = TakPassphraseStore.DEFAULT_BOOTS
|
||||
pendingHours = 0
|
||||
}
|
||||
|
||||
/** Called when the BLE connection is lost. */
|
||||
fun onDisconnect() {
|
||||
serviceRepository.setSessionAuthorized(false)
|
||||
serviceRepository.setTakTokenInfo(null)
|
||||
serviceRepository.setTakLockState(TakLockState.None)
|
||||
wasAutoAttempt = false
|
||||
pendingPassphrase = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every config_complete_id. Once [sessionAuthorized] is true (set on TAK_UNLOCKED),
|
||||
* this is a no-op — preventing the startConfigOnly config_complete_id from triggering any
|
||||
* further TAK handling. The dialog state is driven entirely by clientNotifications.
|
||||
*/
|
||||
fun onConfigComplete() {
|
||||
// Session already authenticated — this config_complete_id is from the startConfigOnly()
|
||||
// issued after TAK_UNLOCKED. Nothing to do.
|
||||
if (serviceRepository.sessionAuthorized.value) return
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes incoming TAK clientNotification messages:
|
||||
* - TAK_NEEDS_PROVISION → device has no passphrase → show "Set Passphrase" dialog
|
||||
* - TAK_LOCKED:<reason> → device is locked → auto-unlock with stored passphrase or show dialog
|
||||
* - TAK_UNLOCKED → accepted; save passphrase, authorize session, re-sync config
|
||||
* - TAK_UNLOCK_FAILED → wrong passphrase; clear stored or increment retry counter
|
||||
*/
|
||||
fun handleTakNotification(message: String?) {
|
||||
when {
|
||||
message == TAK_NEEDS_PROVISION -> handleNeedsProvision()
|
||||
// Exact "TAK_LOCKED" = Lock Now was acknowledged by the device → re-lock the session.
|
||||
// "TAK_LOCKED:<reason>" (with colon) = connect-time lock → try auto-unlock or show dialog.
|
||||
message == TAK_LOCKED_ACK -> handleLockNowAcknowledged()
|
||||
message != null && message.startsWith(TAK_LOCKED_WITH_REASON_PREFIX) -> handleLocked()
|
||||
message != null && message.startsWith(TAK_UNLOCKED_PREFIX) -> handleUnlocked(message)
|
||||
message != null && message.startsWith(TAK_UNLOCK_FAILED_PREFIX) -> handleUnlockFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLockNowAcknowledged() {
|
||||
Logger.i { "TAK: Lock Now acknowledged — resetting session authorization" }
|
||||
serviceRepository.setSessionAuthorized(false)
|
||||
// Do NOT clear takTokenInfo here — keep it so the dialog pre-fills with the last-known
|
||||
// TTL values. It is refreshed by the next TAK_UNLOCKED response.
|
||||
wasAutoAttempt = false
|
||||
pendingPassphrase = null
|
||||
// Immediately purge the cached config — it's stale from the authenticated session.
|
||||
// The fresh config is loaded in handleUnlocked() after successful re-authentication.
|
||||
connectionManager.get().clearRadioConfig()
|
||||
// Signal the UI to disconnect — no dialog, just drop the connection.
|
||||
serviceRepository.setTakLockState(TakLockState.LockNowAcknowledged)
|
||||
}
|
||||
|
||||
private fun handleLocked() {
|
||||
val deviceAddress = radioInterfaceService.getDeviceAddress()
|
||||
if (deviceAddress != null) {
|
||||
val stored = passphraseStore.getPassphrase(deviceAddress)
|
||||
if (stored != null) {
|
||||
Logger.i { "TAK: Auto-unlocking (TAK_LOCKED) with stored passphrase for $deviceAddress" }
|
||||
wasAutoAttempt = true
|
||||
commandSender.sendTakPassphrase(stored.passphrase, stored.boots, stored.hours)
|
||||
return
|
||||
}
|
||||
}
|
||||
serviceRepository.setTakLockState(TakLockState.Locked)
|
||||
}
|
||||
|
||||
private fun handleNeedsProvision() {
|
||||
serviceRepository.setTakLockState(TakLockState.NeedsProvision)
|
||||
}
|
||||
|
||||
private fun handleUnlocked(message: String) {
|
||||
val deviceAddress = radioInterfaceService.getDeviceAddress()
|
||||
val passphrase = pendingPassphrase
|
||||
if (deviceAddress != null && passphrase != null) {
|
||||
passphraseStore.savePassphrase(deviceAddress, passphrase, pendingBoots, pendingHours)
|
||||
Logger.i { "TAK: Saved passphrase for $deviceAddress" }
|
||||
}
|
||||
pendingPassphrase = null
|
||||
serviceRepository.setTakTokenInfo(parseTokenInfo(message))
|
||||
serviceRepository.setTakLockState(TakLockState.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.get().startConfigOnly()
|
||||
}
|
||||
|
||||
/** Parses boots= and until= fields from TAK_UNLOCKED:boots=N:until=EPOCH: */
|
||||
private fun parseTokenInfo(message: String): TakTokenInfo? {
|
||||
var boots = -1
|
||||
var until = 0L
|
||||
for (segment in message.split(":")) {
|
||||
when {
|
||||
segment.startsWith("boots=") -> boots = segment.removePrefix("boots=").toIntOrNull() ?: -1
|
||||
segment.startsWith("until=") -> until = segment.removePrefix("until=").toLongOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
return if (boots >= 0) TakTokenInfo(boots, until) else null
|
||||
}
|
||||
|
||||
private fun handleUnlockFailed(message: String) {
|
||||
pendingPassphrase = null
|
||||
// Parse backoff=N first — applies to both auto and manual attempts.
|
||||
val backoffSeconds = message.split(":").firstNotNullOfOrNull { segment ->
|
||||
if (segment.startsWith("backoff=")) segment.removePrefix("backoff=").toIntOrNull() else null
|
||||
}
|
||||
if (wasAutoAttempt) {
|
||||
wasAutoAttempt = false
|
||||
if (backoffSeconds != null && backoffSeconds > 0) {
|
||||
// Rate-limited — stored passphrase may still be correct; keep it and show countdown.
|
||||
Logger.i { "TAK: Auto-unlock rate-limited (backoff=${backoffSeconds}s)" }
|
||||
serviceRepository.setTakLockState(TakLockState.UnlockBackoff(backoffSeconds))
|
||||
} else {
|
||||
// Wrong passphrase — clear stored passphrase.
|
||||
val deviceAddress = radioInterfaceService.getDeviceAddress()
|
||||
if (deviceAddress != null) {
|
||||
passphraseStore.clearPassphrase(deviceAddress)
|
||||
Logger.i { "TAK: Auto-unlock failed (wrong passphrase), cleared stored passphrase for $deviceAddress" }
|
||||
}
|
||||
serviceRepository.setTakLockState(TakLockState.Locked)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Manual attempt.
|
||||
if (backoffSeconds != null && backoffSeconds > 0) {
|
||||
Logger.i { "TAK: Unlock failed with backoff of ${backoffSeconds}s" }
|
||||
serviceRepository.setTakLockState(TakLockState.UnlockBackoff(backoffSeconds))
|
||||
} else {
|
||||
serviceRepository.setTakLockState(TakLockState.UnlockFailed)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitPassphrase(passphrase: String, boots: Int, hours: Int) {
|
||||
pendingPassphrase = passphrase
|
||||
pendingBoots = boots
|
||||
pendingHours = hours
|
||||
wasAutoAttempt = false
|
||||
serviceRepository.setTakLockState(TakLockState.None) // hide dialog while awaiting response
|
||||
commandSender.sendTakPassphrase(passphrase, boots, hours)
|
||||
}
|
||||
|
||||
fun lockNow() {
|
||||
commandSender.sendTakLockNow()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAK_LOCKED_ACK = "TAK_LOCKED" // exact: Lock Now ACK
|
||||
private const val TAK_LOCKED_WITH_REASON_PREFIX = "TAK_LOCKED:" // connect-time lock
|
||||
private const val TAK_NEEDS_PROVISION = "TAK_NEEDS_PROVISION"
|
||||
private const val TAK_UNLOCKED_PREFIX = "TAK_UNLOCKED"
|
||||
private const val TAK_UNLOCK_FAILED_PREFIX = "TAK_UNLOCK_FAILED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.service
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class StoredPassphrase(
|
||||
val passphrase: String,
|
||||
val boots: Int,
|
||||
val hours: Int,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class TakPassphraseStore @Inject constructor(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 = "tak_passphrase_store"
|
||||
const val DEFAULT_BOOTS = 50
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.TakLockState
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.app_too_old
|
||||
import org.meshtastic.core.strings.bottom_nav_settings
|
||||
@@ -217,8 +218,37 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
|
||||
}
|
||||
}
|
||||
|
||||
val takLockState by uIViewModel.takLockState.collectAsStateWithLifecycle()
|
||||
val takTokenInfo by uIViewModel.takTokenInfo.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(takLockState) {
|
||||
if (takLockState is TakLockState.LockNowAcknowledged) {
|
||||
uIViewModel.clearTakLockState()
|
||||
scanModel.disconnect()
|
||||
navController.navigate(TopLevelDestination.Connections.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
TakUnlockDialog(
|
||||
takLockState = takLockState,
|
||||
takTokenInfo = takTokenInfo,
|
||||
onSubmit = { pass, boots, hours -> uIViewModel.sendTakUnlock(pass, boots, hours) },
|
||||
onDismiss = {
|
||||
uIViewModel.clearTakLockState()
|
||||
scanModel.disconnect()
|
||||
navController.navigate(TopLevelDestination.Connections.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val clientNotification by uIViewModel.clientNotification.collectAsStateWithLifecycle()
|
||||
clientNotification?.let { notification ->
|
||||
if (notification.message?.startsWith("TAK_") == true) return@let
|
||||
var message = notification.message
|
||||
val compromisedKeys =
|
||||
if (notification.low_entropy_key != null || notification.duplicated_public_key != null) {
|
||||
|
||||
182
app/src/main/java/com/geeksville/mesh/ui/TakUnlockDialog.kt
Normal file
182
app/src/main/java/com/geeksville/mesh/ui/TakUnlockDialog.kt
Normal file
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.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.service.TakLockState
|
||||
import org.meshtastic.core.service.TakTokenInfo
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun TakUnlockDialog(
|
||||
takLockState: TakLockState,
|
||||
takTokenInfo: TakTokenInfo? = null,
|
||||
onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val shouldShow = when (takLockState) {
|
||||
is TakLockState.Locked -> true
|
||||
is TakLockState.NeedsProvision -> true
|
||||
is TakLockState.UnlockFailed -> true
|
||||
is TakLockState.UnlockBackoff -> true
|
||||
else -> false
|
||||
}
|
||||
BackHandler(enabled = shouldShow, onBack = onDismiss)
|
||||
if (!shouldShow) return
|
||||
|
||||
var passphrase by rememberSaveable { mutableStateOf("") }
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
// Pre-fill from most recent TAK_UNLOCKED token info when available.
|
||||
val initialBoots = takTokenInfo?.bootsRemaining ?: DEFAULT_BOOTS
|
||||
val initialHours = if ((takTokenInfo?.expiryEpoch ?: 0L) > 0L) {
|
||||
((takTokenInfo!!.expiryEpoch - System.currentTimeMillis() / 1000) / 3600)
|
||||
.toInt().coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
var boots by rememberSaveable { mutableIntStateOf(initialBoots) }
|
||||
var hours by rememberSaveable { mutableIntStateOf(initialHours) }
|
||||
|
||||
val isProvisioning = takLockState is TakLockState.NeedsProvision
|
||||
val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase"
|
||||
val isValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column {
|
||||
when (takLockState) {
|
||||
is TakLockState.UnlockFailed -> {
|
||||
Text(
|
||||
text = "Incorrect passphrase.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_DP.dp))
|
||||
}
|
||||
is TakLockState.UnlockBackoff -> {
|
||||
Text(
|
||||
text = "Try again in ${takLockState.backoffSeconds} seconds.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
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(),
|
||||
)
|
||||
|
||||
// Boot/Hour TTL fields always shown — operator can renew the token window on every unlock
|
||||
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
|
||||
@@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.TakLockState
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.connected
|
||||
import org.meshtastic.core.strings.connected_device
|
||||
@@ -125,7 +126,10 @@ fun ConnectionsScreen(
|
||||
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
||||
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
|
||||
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
|
||||
val takLockState by connectionsViewModel.takLockState.collectAsStateWithLifecycle()
|
||||
// A TAK-locked device sends zeroed config before auth — suppress region-unset until authorized.
|
||||
val isTakAuthorized = takLockState == TakLockState.None || takLockState == TakLockState.Unlocked
|
||||
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET && isTakAuthorized
|
||||
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -48,6 +48,8 @@ constructor(
|
||||
|
||||
val connectionState = serviceRepository.connectionState
|
||||
|
||||
val takLockState = serviceRepository.takLockState
|
||||
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
|
||||
|
||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
@@ -189,4 +189,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 TAK unlock passphrase to the device
|
||||
void sendTakUnlock(in String passphrase, in int bootTtl, in int hourTtl);
|
||||
|
||||
/// Lock the device with TAK lock immediately
|
||||
void sendTakLockNow();
|
||||
}
|
||||
|
||||
Submodule core/proto/src/main/proto updated: e2d1873a6f...bc63a57f9e
@@ -28,9 +28,34 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed class TakLockState {
|
||||
data object None : TakLockState()
|
||||
data object Locked : TakLockState()
|
||||
data object NeedsProvision : TakLockState()
|
||||
data object Unlocked : TakLockState()
|
||||
/** Lock Now ACK received — client should disconnect immediately, no dialog. */
|
||||
data object LockNowAcknowledged : TakLockState()
|
||||
/** Wrong passphrase — retry immediately. */
|
||||
data object UnlockFailed : TakLockState()
|
||||
/** Too many attempts — must wait [backoffSeconds] before retrying. */
|
||||
data class UnlockBackoff(val backoffSeconds: Int) : TakLockState()
|
||||
}
|
||||
|
||||
/**
|
||||
* TAK session token metadata parsed from the TAK_UNLOCKED:boots=N:until=EPOCH: notification.
|
||||
*
|
||||
* @param bootsRemaining Number of reboots before the token expires.
|
||||
* @param expiryEpoch Unix epoch seconds; 0 means no time-based expiry.
|
||||
*/
|
||||
data class TakTokenInfo(
|
||||
val bootsRemaining: Int,
|
||||
val expiryEpoch: Long,
|
||||
)
|
||||
|
||||
sealed class RetryEvent {
|
||||
abstract val packetId: Int
|
||||
abstract val attemptNumber: Int
|
||||
@@ -159,6 +184,38 @@ class ServiceRepository @Inject constructor() {
|
||||
_serviceAction.send(action)
|
||||
}
|
||||
|
||||
// TAK lock state
|
||||
private val _takLockState: MutableStateFlow<TakLockState> = MutableStateFlow(TakLockState.None)
|
||||
val takLockState: StateFlow<TakLockState>
|
||||
get() = _takLockState
|
||||
|
||||
fun setTakLockState(state: TakLockState) {
|
||||
_takLockState.value = state
|
||||
}
|
||||
|
||||
fun clearTakLockState() {
|
||||
_takLockState.value = TakLockState.None
|
||||
}
|
||||
|
||||
// TAK token info (boots remaining + expiry) from the most recent TAK_UNLOCKED notification
|
||||
private val _takTokenInfo: MutableStateFlow<TakTokenInfo?> = MutableStateFlow(null)
|
||||
val takTokenInfo: StateFlow<TakTokenInfo?>
|
||||
get() = _takTokenInfo
|
||||
|
||||
fun setTakTokenInfo(info: TakTokenInfo?) {
|
||||
_takTokenInfo.value = info
|
||||
}
|
||||
|
||||
// True once TAK passphrase is accepted for this BLE connection; false on disconnect.
|
||||
private val _sessionAuthorized: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val sessionAuthorized: StateFlow<Boolean>
|
||||
get() = _sessionAuthorized
|
||||
|
||||
fun setSessionAuthorized(authorized: Boolean) {
|
||||
_sessionAuthorized.value = authorized
|
||||
}
|
||||
|
||||
|
||||
// Retry management
|
||||
private val _retryEvents = MutableStateFlow<RetryEvent?>(null)
|
||||
val retryEvents: StateFlow<RetryEvent?>
|
||||
|
||||
@@ -120,4 +120,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 sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
|
||||
|
||||
override fun sendTakLockNow() {}
|
||||
}
|
||||
|
||||
@@ -117,4 +117,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 sendTakUnlock(passphrase: String?, bootTtl: Int, hourTtl: Int) {}
|
||||
|
||||
override fun sendTakLockNow() {}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
|
||||
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val sessionAuthorized by settingsViewModel.sessionAuthorized.collectAsStateWithLifecycle()
|
||||
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
|
||||
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
|
||||
@@ -247,7 +248,7 @@ fun SettingsScreen(
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
|
||||
RadioConfigItemList(
|
||||
state = state,
|
||||
isManaged = localConfig.security?.is_managed ?: false,
|
||||
isManaged = (localConfig.security?.is_managed ?: false) && !sessionAuthorized,
|
||||
node = destNode,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
isOtaCapable = isOtaCapable,
|
||||
|
||||
@@ -99,6 +99,9 @@ constructor(
|
||||
val localConfig: StateFlow<LocalConfig> =
|
||||
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
|
||||
|
||||
val sessionAuthorized: StateFlow<Boolean> =
|
||||
serviceRepository.sessionAuthorized.stateInWhileSubscribed(initialValue = false)
|
||||
|
||||
val meshService: IMeshService?
|
||||
get() = serviceRepository.meshService
|
||||
|
||||
|
||||
@@ -147,6 +147,12 @@ constructor(
|
||||
private val _radioConfigState = MutableStateFlow(RadioConfigState())
|
||||
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
|
||||
|
||||
fun sendTakLockNow() {
|
||||
meshService?.sendTakLockNow()
|
||||
}
|
||||
|
||||
val takTokenInfo = serviceRepository.takTokenInfo
|
||||
|
||||
fun setPreserveFavorites(preserveFavorites: Boolean) {
|
||||
viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } }
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ private fun ChannelConfigScreen(
|
||||
enabled: Boolean,
|
||||
onPositiveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
val primarySettings = settingsList.getOrNull(0) ?: return
|
||||
val primarySettings = settingsList.getOrNull(0) ?: ChannelSettings()
|
||||
val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) }
|
||||
val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) }
|
||||
val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) }
|
||||
|
||||
@@ -62,13 +62,14 @@ import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.util.hopLimits
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
@Composable
|
||||
fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val loraConfig = state.radioConfig.lora ?: Config.LoRaConfig()
|
||||
val primarySettings = state.channelList.getOrNull(0) ?: return
|
||||
val primarySettings = state.channelList.getOrNull(0) ?: ChannelSettings()
|
||||
val formState = rememberConfigState(initialValue = loraConfig)
|
||||
|
||||
val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) }
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -41,6 +42,9 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -83,6 +87,7 @@ import java.security.SecureRandom
|
||||
@Composable
|
||||
fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val takTokenInfo by viewModel.takTokenInfo.collectAsStateWithLifecycle(initialValue = null)
|
||||
val node by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
|
||||
val formState = rememberConfigState(initialValue = securityConfig)
|
||||
@@ -255,6 +260,31 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
|
||||
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
|
||||
containerColor = CardDefaults.cardColors().containerColor,
|
||||
)
|
||||
HorizontalDivider()
|
||||
NodeActionButton(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
title = "Lock Now",
|
||||
enabled = state.connected,
|
||||
icon = Icons.TwoTone.Warning,
|
||||
onClick = { viewModel.sendTakLockNow() },
|
||||
)
|
||||
takTokenInfo?.let { token ->
|
||||
HorizontalDivider()
|
||||
val expiryMs = token.expiryEpoch * 1000L
|
||||
val expiryText = when {
|
||||
expiryMs <= 0L -> "no time limit"
|
||||
expiryMs <= System.currentTimeMillis() -> "expired"
|
||||
else -> {
|
||||
val fmt = SimpleDateFormat("MMM d yyyy", Locale.getDefault())
|
||||
"expires ${fmt.format(Date(expiryMs))}"
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Token: ${token.bootsRemaining} boots remaining, $expiryText",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "ro
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" }
|
||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
|
||||
|
||||
# AndroidX Compose
|
||||
|
||||
Reference in New Issue
Block a user