Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,13 @@
<string name="led_state">LED state</string>
<string name="legacy_admin_channel">Legacy Admin channel</string>
<string name="library_count">%1$d libraries</string>
<!-- LICENSED -->
<string name="licensed_amateur_radio">Licensed amateur radio (Ham)</string>
<string name="licensed_amateur_radio_text">Enabling this option disables encryption and is not compatible with the default Meshtastic network.</string>
<string name="licensed_mode_enable_confirm">Enable licensed mode</string>
<string name="licensed_mode_enable_title">Enable licensed (Ham) mode?</string>
<string name="licensed_mode_enable_warning">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.</string>
<string name="licensed_mode_enable_warning_signed">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.</string>
<!-- LOAD -->
<string name="load">Load</string>
<string name="load_15_min">Load 15m</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -142,7 +139,6 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
long_name = if (clearForCallsign) "" else longName,
)
},
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
Loading