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
This commit is contained in:
James Rich
2026-05-13 09:19:29 -05:00
parent ed7c8aa22f
commit 585666bb81
5 changed files with 98 additions and 2 deletions

View File

@@ -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(

View File

@@ -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(),

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -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() }
}

View File

@@ -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() },
)
}