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..8a292bcc0cc 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, 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 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) + } +}