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 = {}) } +}