mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-31 18:18:06 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user