feat(lockdown): add non-dismissable LockdownDialog and app shell integration

Phase 3 (User Story 1 - Unlock a Locked Node):
- T014: Create LockdownDialog composable in feature/settings/lockdown/
  - Non-dismissable AlertDialog (onDismissRequest = {})
  - Passphrase field with visibility toggle
  - Provision mode with boots/hours TTL fields
  - Error display for UnlockFailed and UnlockBackoff states
  - Disconnect button instead of Cancel
- T017: Integrate dialog in Main.kt app shell
  - Observe lockdownState from UIViewModel
  - Submit triggers sendLockdownUnlock
  - Disconnect triggers setDeviceAddress("n") to drop connection
This commit is contained in:
James Rich
2026-05-13 09:15:52 -05:00
parent d3ae49781b
commit ed7c8aa22f
2 changed files with 200 additions and 0 deletions

View File

@@ -47,6 +47,7 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.lockdown.LockdownDialog
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
@@ -67,6 +68,15 @@ fun MainScreen() {
AndroidAppVersionCheck(viewModel)
val lockdownState by viewModel.lockdownState.collectAsStateWithLifecycle()
LockdownDialog(
lockdownState = lockdownState,
onSubmit = { passphrase, boots, hours ->
viewModel.sendLockdownUnlock(passphrase, boots, hours)
},
onDisconnect = { viewModel.setDeviceAddress("n") },
)
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
MeshtasticNavigationSuite(
multiBackstack = multiBackstack,

View File

@@ -0,0 +1,190 @@
/*
* 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.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.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.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
/**
* Non-dismissable lockdown authentication dialog.
*
* Shown when the connected device requires passphrase authentication. The dialog blocks
* all interaction with the app until the user either authenticates successfully or
* disconnects. Back gestures are intercepted and treated as disconnect.
*/
@Suppress("LongMethod")
@Composable
fun LockdownDialog(
lockdownState: LockdownState,
onSubmit: (passphrase: String, boots: Int, hours: Int) -> Unit,
onDisconnect: () -> Unit,
) {
val shouldShow = when (lockdownState) {
is LockdownState.Locked -> true
is LockdownState.NeedsProvision -> true
is LockdownState.UnlockFailed -> true
is LockdownState.UnlockBackoff -> true
else -> false
}
if (!shouldShow) return
var passphrase by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var boots by rememberSaveable { mutableIntStateOf(DEFAULT_BOOTS) }
var hours by rememberSaveable { mutableIntStateOf(0) }
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
AlertDialog(
onDismissRequest = {}, // Non-dismissable
title = { Text(text = title) },
text = {
Column {
when (lockdownState) {
is LockdownState.UnlockFailed -> {
Text(
text = "Incorrect passphrase.",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
}
is LockdownState.UnlockBackoff -> {
Text(
text = "Try again in ${lockdownState.backoffSeconds} seconds.",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(SPACING_DP.dp))
}
is LockdownState.Locked -> {
if (lockdownState.lockReason.isNotEmpty()) {
Text(text = "Reason: ${lockdownState.lockReason}")
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) {
MeshtasticIcons.VisibilityOff
} else {
MeshtasticIcons.Visibility
},
contentDescription = if (passwordVisible) "Hide" else "Show",
)
}
},
modifier = Modifier.fillMaxWidth(),
)
if (isProvisioning) {
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("Boots remaining") },
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("Hours until expiry") },
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 = onDisconnect) {
Text(stringResource(Res.string.disconnect))
}
},
)
}
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