diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8511eb515..06408144f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index a6018c8d1..f96c61588 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = serviceRepository.takLockState + val takTokenInfo: StateFlow = 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. diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt index 7b4ba2c07..085f2cac0 100644 --- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index 10c91871d..19dabf4ca 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -59,6 +59,7 @@ constructor( private val databaseManager: DatabaseManager, private val serviceNotifications: MeshServiceNotifications, private val messageProcessor: Lazy, + 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) { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index 2a3361b3e..bf45cd76a 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -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() } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt index 54cd877c0..1f945e0de 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -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, ) { 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)) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt index b61bb6e02..3673bd4cb 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt @@ -36,6 +36,7 @@ constructor( val configFlowManager: MeshConfigFlowManager, val mqttManager: MeshMqttManager, val actionHandler: MeshActionHandler, + val takLockHandler: TakLockHandler, ) { fun start(scope: CoroutineScope) { dataHandler.start(scope) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index f46108cd7..c753b24f8 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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() + } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/TakLockHandler.kt b/app/src/main/java/com/geeksville/mesh/service/TakLockHandler.kt new file mode 100644 index 000000000..692aeaeb6 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/TakLockHandler.kt @@ -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 . + */ +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, +) { + @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: → 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:" (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" + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/TakPassphraseStore.kt b/app/src/main/java/com/geeksville/mesh/service/TakPassphraseStore.kt new file mode 100644 index 000000000..09592007f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/TakPassphraseStore.kt @@ -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 . + */ +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 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 3f198e0e0..f2a9c43ae 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/TakUnlockDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/TakUnlockDialog.kt new file mode 100644 index 000000000..3a38022fe --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/TakUnlockDialog.kt @@ -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 . + */ +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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index adc8b06df..de4a9f25c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt index e17e52204..42b5e07cd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt @@ -48,6 +48,8 @@ constructor( val connectionState = serviceRepository.connectionState + val takLockState = serviceRepository.takLockState + val myNodeInfo: StateFlow = nodeRepository.myNodeInfo val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 7fd3883a2..0f7532822 100644 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -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(); } diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index e2d1873a6..bc63a57f9 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit e2d1873a6fef7b4b63525cdd014790298d80bef8 +Subproject commit bc63a57f9e5dba8a7c90ee0bd4a9840862d61f6d diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 995b68f0b..f4c46a8dd 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -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 = MutableStateFlow(TakLockState.None) + val takLockState: StateFlow + 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 = MutableStateFlow(null) + val takTokenInfo: StateFlow + 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 = MutableStateFlow(false) + val sessionAuthorized: StateFlow + get() = _sessionAuthorized + + fun setSessionAuthorized(authorized: Boolean) { + _sessionAuthorized.value = authorized + } + + // Retry management private val _retryEvents = MutableStateFlow(null) val retryEvents: StateFlow diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt index 0c49b60f4..2cb32bca8 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt @@ -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() {} } diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt index 1a22b8919..0727b978b 100644 --- a/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt +++ b/core/service/src/test/kotlin/org/meshtastic/core/service/FakeIMeshService.kt @@ -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() {} } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 316a12743..5191a1975 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 9ed773068..13085a7a4 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -99,6 +99,9 @@ constructor( val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) + val sessionAuthorized: StateFlow = + serviceRepository.sessionAuthorized.stateInWhileSubscribed(initialValue = false) + val meshService: IMeshService? get() = serviceRepository.meshService diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 24c9e77f0..f8d74d4b0 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -147,6 +147,12 @@ constructor( private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _radioConfigState + fun sendTakLockNow() { + meshService?.sendTakLockNow() + } + + val takTokenInfo = serviceRepository.takTokenInfo + fun setPreserveFavorites(preserveFavorites: Boolean) { viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 71931f30c..c8d9eccba 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -108,7 +108,7 @@ private fun ChannelConfigScreen( enabled: Boolean, onPositiveClicked: (List) -> 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)) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index dd4cbbc86..8cc6771b7 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -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)) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt index df141d109..f4c040965 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt @@ -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), + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c5cd1125..5bb65cca2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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