mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-30 17:47:55 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user