From ed7c8aa22fccca8a005d64dfe6fe654c075a96a8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 13 May 2026 09:15:52 -0500 Subject: [PATCH] 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 --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 10 + .../settings/lockdown/LockdownDialog.kt | 190 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.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 46409b14e..682e5ec81 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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, 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 new file mode 100644 index 000000000..8193706cf --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -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 . + */ +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