From 585666bb81a122c577861925fabbfeb6871e165c Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:19:29 -0500 Subject: [PATCH] feat(lockdown): Lock Now auto-disconnect, session status, provision confirm - T027/T028: Auto-disconnect on LockNowAcknowledged state in app shell - T020/T021: Confirm passphrase field in provision mode with mismatch validation - T035/T036: LockdownSessionStatus composable showing boots remaining and expiry - Wire session status and Lock Now button enabled state based on sessionAuthorized - Expose lockdownTokenInfo and sessionAuthorized from RadioConfigViewModel --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 7 +++ .../settings/lockdown/LockdownDialog.kt | 20 +++++- .../lockdown/LockdownSessionStatus.kt | 62 +++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 3 + .../radio/component/SecurityConfigScreen.kt | 8 ++- 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 682e5ec81..5ed7f4484 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -32,6 +32,7 @@ import co.touchlab.kermit.Logger import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.service.LockdownState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack @@ -76,6 +77,12 @@ fun MainScreen() { }, onDisconnect = { viewModel.setDeviceAddress("n") }, ) + // Auto-disconnect when firmware acknowledges Lock Now + LaunchedEffect(lockdownState) { + if (lockdownState is LockdownState.LockNowAcknowledged) { + viewModel.setDeviceAddress("n") + } + } MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { MeshtasticNavigationSuite( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 8193706cf..2079d5a30 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -74,6 +74,7 @@ fun LockdownDialog( if (!shouldShow) return var passphrase by rememberSaveable { mutableStateOf("") } + var confirmPassphrase by rememberSaveable { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } var boots by rememberSaveable { mutableIntStateOf(DEFAULT_BOOTS) } var hours by rememberSaveable { mutableIntStateOf(0) } @@ -81,7 +82,9 @@ fun LockdownDialog( val isProvisioning = lockdownState is LockdownState.NeedsProvision val title = if (isProvisioning) "Set Passphrase" else "Enter Passphrase" val inBackoff = lockdownState is LockdownState.UnlockBackoff - val isValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN && !inBackoff + val passphraseValid = passphrase.isNotEmpty() && passphrase.length <= MAX_PASSPHRASE_LEN + val confirmValid = !isProvisioning || passphrase == confirmPassphrase + val isValid = passphraseValid && confirmValid && !inBackoff AlertDialog( onDismissRequest = {}, // Non-dismissable @@ -138,6 +141,21 @@ fun LockdownDialog( ) if (isProvisioning) { + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = confirmPassphrase, + onValueChange = { if (it.length <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, + label = { Text("Confirm passphrase") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase, + supportingText = if (confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase) { + { Text("Passphrases do not match") } + } else { + null + }, + modifier = Modifier.fillMaxWidth(), + ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt new file mode 100644 index 000000000..5b05511a6 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.lockdown + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.model.service.LockdownTokenInfo + +/** + * Displays lockdown session token status: remaining boots and expiry information. + * Visible only when the session is unlocked and token info is available. + */ +@Composable +fun LockdownSessionStatus( + tokenInfo: LockdownTokenInfo?, + modifier: Modifier = Modifier, +) { + if (tokenInfo == null) return + + Column(modifier = modifier.padding(horizontal = PADDING_DP.dp, vertical = PADDING_VERTICAL_DP.dp)) { + Text( + text = "Session: ${tokenInfo.bootsRemaining} reboots remaining", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (tokenInfo.expiryEpoch > 0L) { + Text( + text = "Expires at epoch ${tokenInfo.expiryEpoch}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "No time limit", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private const val PADDING_DP = 8 +private const val PADDING_VERTICAL_DP = 4 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index a4d678832..972170dea 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -140,6 +140,9 @@ open class RadioConfigViewModel( private val lockdownCoordinator: LockdownCoordinator, ) : ViewModel() { + val lockdownTokenInfo = serviceRepository.lockdownTokenInfo + val sessionAuthorized = serviceRepository.sessionAuthorized + fun sendLockNow() { viewModelScope.launch { lockdownCoordinator.lockNow() } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 860c781be..0f5d94497 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -63,6 +63,7 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Warning +import org.meshtastic.feature.settings.lockdown.LockdownSessionStatus import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config import kotlin.random.Random @@ -214,10 +215,15 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un ) HorizontalDivider() // TODO(lockdown): Re-implement Lock Now button with KMP-compatible UI (Phase 5, T025-T026) + val tokenInfo by viewModel.lockdownTokenInfo.collectAsStateWithLifecycle() + val authorized by viewModel.sessionAuthorized.collectAsStateWithLifecycle() + if (authorized) { + LockdownSessionStatus(tokenInfo = tokenInfo) + } NodeActionButton( modifier = Modifier.padding(horizontal = 8.dp), title = "Lock Now", - enabled = state.connected, + enabled = state.connected && authorized, onClick = { viewModel.sendLockNow() }, ) }