From e18f72dbf291f4f9c803a4739d920f5619071800 Mon Sep 17 00:00:00 2001
From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com>
Date: Sun, 30 Nov 2025 04:59:05 -0800
Subject: [PATCH] feat: add infrastructure shutdown safeguards and enhance
shutdown dialog text (#3858)
---
.../composeResources/values/strings.xml | 4 +
.../feature/settings/SettingsScreen.kt | 1 +
.../feature/settings/radio/RadioConfig.kt | 56 +++++----
.../radio/component/DeviceConfigItemList.kt | 3 +-
.../component/ShutdownConfirmationDialog.kt | 114 ++++++++++++++++++
5 files changed, 156 insertions(+), 22 deletions(-)
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index c27c6225e..03d63617a 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -285,6 +285,10 @@
Resend
Shutdown
Shutdown not supported on this device
+ ⚠️ This will SHUTDOWN the node. Physical interaction will be required to turn it back on.
+ ⚠️ This is a critical infrastructure node. Type the node name to confirm:
+ Node: %1$s
+ Type: %1$s
Reboot
Traceroute
Show Introduction
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 984f32ff9..95d7d8fe9 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -244,6 +244,7 @@ fun SettingsScreen(
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
+ node = viewModel.destNode.value,
excludedModulesUnlocked = excludedModulesUnlocked,
isDfuCapable = isDfuCapable,
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index e4decfe80..2383d0b6c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@@ -76,6 +77,7 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
+import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
import org.meshtastic.feature.settings.radio.component.WarningDialog
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -84,6 +86,7 @@ import org.meshtastic.feature.settings.radio.component.WarningDialog
fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
+ node: Node? = null,
excludedModulesUnlocked: Boolean = false,
isDfuCapable: Boolean = false,
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
@@ -158,28 +161,39 @@ fun RadioConfigItemList(
AdminRoute.entries.forEach { route ->
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
- WarningDialog(
- title = "${stringResource(route.title)}?",
- text = {
- if (route == AdminRoute.NODEDB_RESET) {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(text = stringResource(Res.string.preserve_favorites))
- Switch(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- enabled = enabled,
- checked = state.nodeDbResetPreserveFavorites,
- onCheckedChange = onPreserveFavoritesToggle,
- )
+ // Use enhanced confirmation for SHUTDOWN and REBOOT
+ if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
+ ShutdownConfirmationDialog(
+ title = "${stringResource(route.title)}?",
+ node = node,
+ onDismiss = { showDialog = false },
+ isShutdown = route == AdminRoute.SHUTDOWN,
+ onConfirm = { onRouteClick(route) },
+ )
+ } else {
+ WarningDialog(
+ title = "${stringResource(route.title)}?",
+ text = {
+ if (route == AdminRoute.NODEDB_RESET) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = stringResource(Res.string.preserve_favorites))
+ Switch(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ enabled = enabled,
+ checked = state.nodeDbResetPreserveFavorites,
+ onCheckedChange = onPreserveFavoritesToggle,
+ )
+ }
}
- }
- },
- onDismiss = { showDialog = false },
- onConfirm = { onRouteClick(route) },
- )
+ },
+ onDismiss = { showDialog = false },
+ onConfirm = { onRouteClick(route) },
+ )
+ }
}
ListItem(
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
index c984cb284..fe3cb1540 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt
@@ -161,7 +161,8 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
val deviceConfig = state.radioConfig.device
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) }
- val infrastructureRoles = listOf(DeviceConfig.Role.ROUTER, DeviceConfig.Role.REPEATER)
+ val infrastructureRoles =
+ listOf(DeviceConfig.Role.ROUTER, DeviceConfig.Role.ROUTER_LATE, DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
if (selectedRole in infrastructureRoles) {
RouterRoleConfirmationDialog(
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt
new file mode 100644
index 000000000..ad162d7bf
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2025 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.radio.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.cancel
+import org.meshtastic.core.strings.send
+import org.meshtastic.core.strings.shutdown_node_name
+import org.meshtastic.core.strings.shutdown_warning
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.proto.MeshProtos
+
+@Composable
+fun ShutdownConfirmationDialog(
+ title: String,
+ node: Node?,
+ onDismiss: () -> Unit,
+ isShutdown: Boolean = true,
+ icon: ImageVector? = Icons.Rounded.Warning,
+ onConfirm: () -> Unit,
+) {
+ val nodeLongName = node?.user?.longName ?: "Unknown Node"
+
+ AlertDialog(
+ onDismissRequest = {},
+ icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
+ title = { Text(text = title) },
+ text = { ShutdownDialogContent(nodeLongName = nodeLongName, isShutdown = isShutdown) },
+ dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
+ confirmButton = {
+ Button(
+ onClick = {
+ onDismiss()
+ onConfirm()
+ },
+ ) {
+ Text(stringResource(Res.string.send))
+ }
+ },
+ )
+}
+
+@Composable
+private fun ShutdownDialogContent(nodeLongName: String, isShutdown: Boolean) {
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
+ Text(
+ text = stringResource(Res.string.shutdown_node_name, nodeLongName),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+
+ if (isShutdown) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.shutdown_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ShutdownConfirmationDialogPreview() {
+ val mockNode =
+ Node(
+ num = 123,
+ user = MeshProtos.User.newBuilder().setLongName("Rooftop Router Node").setShortName("ROOF").build(),
+ )
+
+ AppTheme { ShutdownConfirmationDialog(title = "Shutdown?", node = mockNode, onDismiss = {}, onConfirm = {}) }
+}