From 98636223245690f67d4e62983a96966be1737407 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 11 Aug 2026 19:26:32 -0500 Subject: [PATCH 1/3] feat(settings): warn before enabling licensed (ham) mode Firmware 2.8.0 signs licensed-mode traffic and derives NodeNum from the identity key, so enabling ham mode can migrate the node's identity once. Stage the toggle behind a confirmation that explains authenticated-but- plaintext operation and the migration, gated on DeviceMetadata.has_xeddsa. Reference: meshtastic/design#122 Co-Authored-By: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 5 + .../composeResources/values/strings.xml | 5 + .../radio/component/LicensedModeSetting.kt | 96 +++++++++++ .../radio/component/UserConfigItemList.kt | 8 +- .../component/LicensedModeSettingTest.kt | 156 ++++++++++++++++++ 5 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 65f96685b63..5d3841e6345 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -861,8 +861,13 @@ led_heartbeat led_state legacy_admin_channel library_count +### LICENSED ### licensed_amateur_radio licensed_amateur_radio_text +licensed_mode_enable_confirm +licensed_mode_enable_title +licensed_mode_enable_warning +licensed_mode_enable_warning_signed ### LOAD ### load load_15_min diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 01f21d1dc8c..93af93ad50f 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -888,8 +888,13 @@ LED state Legacy Admin channel %1$d libraries + Licensed amateur radio (Ham) Enabling this option disables encryption and is not compatible with the default Meshtastic network. + Enable licensed mode + Enable licensed (Ham) mode? + Licensed mode removes channel encryption keys and disables the admin channel. All traffic is sent as plaintext that anyone can read, and it is not compatible with the default Meshtastic network. You are responsible for meeting the requirements of your amateur radio license and local regulations. + Licensed mode removes channel encryption keys and disables the admin channel. All traffic is sent as plaintext that anyone can read, but it is digitally signed so other nodes can verify it came from you. Your node number may change one time to match your identity key, so favorites, message history, remote admin references, and other nodes may initially treat this node as new. You are responsible for meeting the requirements of your amateur radio license and local regulations. Load Load 15m diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt new file mode 100644 index 00000000000..df2a046e7ff --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 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.radio.component + +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.platform.testTag +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.licensed_amateur_radio +import org.meshtastic.core.resources.licensed_amateur_radio_text +import org.meshtastic.core.resources.licensed_mode_enable_confirm +import org.meshtastic.core.resources.licensed_mode_enable_title +import org.meshtastic.core.resources.licensed_mode_enable_warning +import org.meshtastic.core.resources.licensed_mode_enable_warning_signed +import org.meshtastic.core.ui.component.MeshtasticResourceDialog +import org.meshtastic.core.ui.component.SwitchPreference + +internal const val LICENSED_MODE_SWITCH_TEST_TAG = "licensed_mode_switch" + +/** + * The licensed (ham) mode toggle. Enabling is staged behind a confirmation dialog: on firmware that signs licensed + * traffic (design#122, `has_xeddsa`), the copy explains authenticated-but-plaintext operation and the one-time node + * number migration; otherwise it carries the legacy plaintext/compatibility warning. Disabling applies immediately. + */ +@Composable +internal fun LicensedModeSetting( + checked: Boolean, + enabled: Boolean, + signingSupported: Boolean?, + onCheckedChange: (Boolean) -> Unit, +) { + var showEnableConfirmation by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(enabled) { + if (!enabled) { + showEnableConfirmation = false + } + } + + if (showEnableConfirmation) { + MeshtasticResourceDialog( + titleRes = Res.string.licensed_mode_enable_title, + messageRes = + if (signingSupported == true) { + Res.string.licensed_mode_enable_warning_signed + } else { + Res.string.licensed_mode_enable_warning + }, + confirmTextRes = Res.string.licensed_mode_enable_confirm, + onConfirm = { + showEnableConfirmation = false + if (enabled) { + onCheckedChange(true) + } + }, + onDismiss = { showEnableConfirmation = false }, + ) + } + + SwitchPreference( + title = stringResource(Res.string.licensed_amateur_radio), + summary = stringResource(Res.string.licensed_amateur_radio_text), + checked = checked, + enabled = enabled, + modifier = Modifier.testTag(LICENSED_MODE_SWITCH_TEST_TAG), + onCheckedChange = { licensed -> + if (licensed && !checked) { + showEnableConfirmation = true + } else { + onCheckedChange(licensed) + } + }, + containerColor = CardDefaults.cardColors().containerColor, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index d9a3843815f..26553ef9056 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -34,8 +34,6 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.call_sign import org.meshtastic.core.resources.call_sign_summary import org.meshtastic.core.resources.hardware_model -import org.meshtastic.core.resources.licensed_amateur_radio -import org.meshtastic.core.resources.licensed_amateur_radio_text import org.meshtastic.core.resources.long_name import org.meshtastic.core.resources.node_id import org.meshtastic.core.resources.short_name @@ -127,11 +125,10 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - SwitchPreference( - title = stringResource(Res.string.licensed_amateur_radio), - summary = stringResource(Res.string.licensed_amateur_radio_text), + LicensedModeSetting( checked = formState.value.is_licensed, enabled = state.connected, + signingSupported = state.metadata?.has_xeddsa, onCheckedChange = { licensed -> val longName = formState.value.long_name // The field becomes the callsign: clear an over-long name so the user enters one. @@ -142,7 +139,6 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { long_name = if (clearForCallsign) "" else longName, ) }, - containerColor = CardDefaults.cardColors().containerColor, ) } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt new file mode 100644 index 00000000000..f77e0e287ba --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 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.radio.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.licensed_mode_enable_confirm +import org.meshtastic.core.resources.licensed_mode_enable_warning +import org.meshtastic.core.resources.licensed_mode_enable_warning_signed +import org.meshtastic.core.ui.theme.AppTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalTestApi::class) +class LicensedModeSettingTest { + + @Test + fun `enabling licensed mode requires confirmation`() = runComposeUiTest { + var checked: Boolean? = null + setContent { + AppTheme { + LicensedModeSetting( + checked = false, + enabled = true, + signingSupported = true, + onCheckedChange = { checked = it }, + ) + } + } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + + assertNull(checked) + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertIsDisplayed() + onNodeWithText(getString(Res.string.cancel)).performClick() + assertNull(checked) + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist() + } + + @Test + fun `confirmed enable applies the change`() = runComposeUiTest { + var checked: Boolean? = null + setContent { + AppTheme { + LicensedModeSetting( + checked = false, + enabled = true, + signingSupported = true, + onCheckedChange = { checked = it }, + ) + } + } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).performClick() + + assertEquals(true, checked) + } + + @Test + fun `signing firmware shows authenticated plaintext and migration copy`() = runComposeUiTest { + setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = true) {} } } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + + onNodeWithText(getString(Res.string.licensed_mode_enable_warning_signed)).assertIsDisplayed() + } + + @Test + fun `non-signing firmware shows legacy plaintext copy`() = runComposeUiTest { + setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = false) {} } } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + + onNodeWithText(getString(Res.string.licensed_mode_enable_warning)).assertIsDisplayed() + } + + @Test + fun `unknown capability shows legacy plaintext copy`() = runComposeUiTest { + setContent { AppTheme { LicensedModeSetting(checked = false, enabled = true, signingSupported = null) {} } } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + + onNodeWithText(getString(Res.string.licensed_mode_enable_warning)).assertIsDisplayed() + } + + @Test + fun `disabling licensed mode applies immediately without confirmation`() = runComposeUiTest { + var checked: Boolean? = null + setContent { + AppTheme { + LicensedModeSetting( + checked = true, + enabled = true, + signingSupported = true, + onCheckedChange = { checked = it }, + ) + } + } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + + assertEquals(false, checked) + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist() + } + + @Test + fun `connection loss dismisses confirmation without changing state`() = runComposeUiTest { + var enabled by mutableStateOf(true) + var checked: Boolean? = null + setContent { + AppTheme { + LicensedModeSetting( + checked = false, + enabled = enabled, + signingSupported = true, + onCheckedChange = { checked = it }, + ) + } + } + + onNodeWithTag(LICENSED_MODE_SWITCH_TEST_TAG).performClick() + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertIsDisplayed() + + enabled = false + waitForIdle() + + onNodeWithText(getString(Res.string.licensed_mode_enable_confirm)).assertDoesNotExist() + assertNull(checked) + } +} From 7be0e65cf3e3a3545b5acbd8f6495831d908eea0 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 11 Aug 2026 21:47:24 -0500 Subject: [PATCH 2/3] copy(settings): tighten signed licensed-mode warning to two sentences Same four facts required by design#122 (no PSK/admin channel, signed plaintext, one-time NodeNum migration, licence responsibility) with the clauses joined rather than any content dropped. Co-Authored-By: Claude Fable 5 --- .../src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 93af93ad50f..19395250c7d 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -894,7 +894,7 @@ Enable licensed mode Enable licensed (Ham) mode? Licensed mode removes channel encryption keys and disables the admin channel. All traffic is sent as plaintext that anyone can read, and it is not compatible with the default Meshtastic network. You are responsible for meeting the requirements of your amateur radio license and local regulations. - Licensed mode removes channel encryption keys and disables the admin channel. All traffic is sent as plaintext that anyone can read, but it is digitally signed so other nodes can verify it came from you. Your node number may change one time to match your identity key, so favorites, message history, remote admin references, and other nodes may initially treat this node as new. You are responsible for meeting the requirements of your amateur radio license and local regulations. + Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — but it is digitally signed, letting other nodes verify it came from you. Your node number may change once to match your identity key, so favorites, message history, remote admin, and other nodes may initially treat it as new; you remain responsible for meeting your amateur radio license requirements and local regulations. Load Load 15m From 67ebbecdab3590a000db4e5c96c99f9f4b7363ba Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 12 Aug 2026 07:16:16 -0500 Subject: [PATCH 3/3] copy(settings): match non-signing licensed warning to two sentences Mirrors the signed variant's structure so only the signing status differs between firmware versions. States the converse explicitly: pre-2.8 licensed mode publishes no key, so peers cannot verify the sender. Co-Authored-By: Claude Fable 5 --- .../src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 19395250c7d..8a292bcc0cc 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -893,7 +893,7 @@ Enabling this option disables encryption and is not compatible with the default Meshtastic network. Enable licensed mode Enable licensed (Ham) mode? - Licensed mode removes channel encryption keys and disables the admin channel. All traffic is sent as plaintext that anyone can read, and it is not compatible with the default Meshtastic network. You are responsible for meeting the requirements of your amateur radio license and local regulations. + Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — and this firmware cannot sign it, so other nodes cannot verify it came from you. This is not compatible with the default Meshtastic network, and you remain responsible for meeting your amateur radio license requirements and local regulations. Licensed mode removes channel encryption keys and disables the admin channel, so all traffic is sent as plaintext that anyone can read — but it is digitally signed, letting other nodes verify it came from you. Your node number may change once to match your identity key, so favorites, message history, remote admin, and other nodes may initially treat it as new; you remain responsible for meeting your amateur radio license requirements and local regulations. Load