Skip to content

feat(settings): warn before enabling licensed (ham) mode - #6632

Merged
jamesarich merged 3 commits into
mainfrom
claude/focused-maxwell-e36781
Aug 12, 2026
Merged

feat(settings): warn before enabling licensed (ham) mode#6632
jamesarich merged 3 commits into
mainfrom
claude/focused-maxwell-e36781

Conversation

@jamesarich

@jamesarich jamesarich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Why

Firmware 2.8.0 changes what licensed (ham) mode means. meshtastic/firmware#10969 (merged Jul 27, tracked by meshtastic/design#122) now generates and publishes an identity keypair in licensed mode and XEdDSA-signs every locally originated plaintext packet, including direct messages. Traffic stays readable on air — signatures authenticate origin, they do not encrypt — but because NodeNum is derived as crc32(public_key), enabling licensed mode on an existing node can change its node number one time. design#122 asks clients to warn before that migration and to say that favorites, message history, remote-admin references, and peer caches may initially treat the node as new.

Today the Android toggle flips the form immediately, carrying only the pre-2.8 one-liner: "Enabling this option disables encryption and is not compatible with the default Meshtastic network." That is now both incomplete (no migration warning) and, on signing firmware, misleading about what the node actually transmits. Per garth's Aug 11 status update on design#122, this client-side copy work had not been started on any client.

🌟 What this adds

Enabling licensed mode is now staged behind a confirmation dialog; disabling still applies immediately (no reason to gate the safe direction). The message is chosen from the firmware capability, reusing the exact pattern PacketAuthenticitySetting established in #6178MeshtasticResourceDialog plus a nullable tri-state capability gate.

On firmware that signs licensed traffic (DeviceMetadata.has_xeddsa), the dialog explains authenticated-but-plaintext operation and the identity migration. On older firmware — or before metadata is known — it keeps a plaintext-only warning and makes no migration claim, since neither signing nor the NodeNum derivation applies there.

🛠️ Implementation notes

The switch moved out of UserConfigItemList into a new LicensedModeSetting component, which holds the staged-confirmation state; the callsign-clearing behaviour on enable is unchanged, just deferred until after confirm. The pending dialog is dropped if the device disconnects, so a stale confirm can never apply a config change to a device that went away.

has_xeddsa (field 14) is the only signing-capability bit in DeviceMetadata — there is no design#122-specific flag — so it is used as the proxy for "this firmware signs licensed traffic," consistent with how #6178 gates the Strict policy. Worth noting for reviewers: past "packet authenticity disabled on nightly" reports were old-firmware artifacts, not app bugs, and the same gate applies here.

⚠️ Copy needs review

Neither design thread specifies wording, so the four new strings are my draft against the requirements in design#122's Identity migration section and its jurisdiction disclaimer. Please review before this merges and translations are seeded:

  • licensed_mode_enable_title — Enable licensed (Ham) mode?
  • licensed_mode_enable_confirm — Enable licensed mode
  • licensed_mode_enable_warning (pre-2.8 / capability unknown) — 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_enable_warning_signed (2.8.0+) — 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.

Open questions for you:

  1. The signed variant is long (four sentences). Resolved: both variants tightened to two sentences at James's request, joining clauses rather than dropping required facts. They now share an opening and an em-dash pivot to the signing status, so the only visible difference between firmware versions is the fact that actually differs.

    One thing to eyeball: matching the structure meant filling the slot where the signed variant says "but it is digitally signed…" — so the non-signing variant now states the converse (this firmware cannot sign, so peers cannot verify the sender). That is new information not in the original string, though accurate: design#122's Current firmware behavior notes licensed mode blocks key generation and strips User.public_key, which is exactly why Strict rejects pre-2.8 licensed traffic. If you would rather the older-firmware dialog not editorialise about signing, drop that clause and let sentence one end at "anyone can read" — shorter, but the variants stop mirroring.

  2. design#122 also mentions callsign-identification requirements. The screen already repurposes the long-name field as the callsign with its own summary, so I folded this into the general licence-responsibility line rather than restating it. Enough?

  3. Should the migration warning be conditional on actually migrating (i.e. only when the node's current number is not already crc32(public_key))? The app cannot cheaply predict this pre-enable, so the copy hedges with "may change." Left as-is deliberately.

  4. Existing strict-policy copy says "licensed or ham nodes without PKI keys… may disappear." That remains accurate for pre-2.8 licensed nodes, but 2.8 licensed nodes now pass Strict. Follow-up to reword, or leave until 2.8 is broadly deployed?

Deliberately out of scope: shield/lock semantics are unchanged (design#122 confirms licensed DMs use the verified-signature shield, not the PKI lock — already the app's behaviour), and no screenshot references were added, to keep this copy-only.

🧹 Testing Performed

LicensedModeSettingTest — 7 tests, all passing:

Case Asserts
enabling requires confirmation switch alone does not mutate; cancel leaves it off
confirmed enable applies confirm propagates true
signing firmware copy signed variant shown when has_xeddsa = true
non-signing firmware copy legacy variant when false
unknown capability legacy variant when null
disabling applies immediately, no dialog
connection loss pending dialog dismissed, no config change

Full baseline green on this branch: ./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests.

Reference: meshtastic/design#122

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a licensed amateur-radio mode setting with a dedicated toggle.
    • Enabling licensed mode now requires confirmation.
    • Added warnings explaining unsigned and digitally signed mode behavior, network compatibility, identity changes, and regulatory responsibilities.
    • Disabling licensed mode takes effect immediately.

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcbfd9de-a248-4ed2-812c-a0bcf6945198

📥 Commits

Reviewing files that changed from the base of the PR and between 6c06601 and 67ebbec.

📒 Files selected for processing (5)
  • .skills/compose-ui/strings-index.txt
  • core/resources/src/commonMain/composeResources/values/strings.xml
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
  • feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt

📝 Walkthrough

Walkthrough

The licensed amateur-radio setting now uses a dedicated Compose component. Enabling requires confirmation with signing-specific warnings. Disabling remains immediate. Localized resources and Compose tests cover the new behavior.

Changes

Licensed mode settings

Layer / File(s) Summary
Licensed mode confirmation flow
.skills/compose-ui/strings-index.txt, core/resources/src/commonMain/composeResources/values/strings.xml, feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSetting.kt, feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LicensedModeSettingTest.kt
Added licensed-mode strings and a dedicated switch component. The component confirms enable actions, selects warnings by signing support, applies disable actions immediately, and clears pending confirmation when unavailable. Tests cover these behaviors.
Settings list integration
feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
Replaced the inline switch with LicensedModeSetting and passed license, connection, and signing state.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: rcgv1

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LicensedModeSetting
  participant ConfirmationDialog
  participant SettingsState
  User->>LicensedModeSetting: Toggle licensed mode on
  LicensedModeSetting->>ConfirmationDialog: Show signing-dependent warning
  User->>ConfirmationDialog: Confirm enable
  ConfirmationDialog->>LicensedModeSetting: Return confirmation
  LicensedModeSetting->>SettingsState: Enable licensed mode if still available
Loading
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a warning before enabling licensed mode.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sibling Call Sites And Presence Semantics ✅ Passed The diff adds no physical measurement field, zero guard, or zero default; the new nullable signingSupported parameter is supplied at its only production call site and all tests, with no unfixed sib...
Tests Prove The Path, Not The End State ✅ Passed All seven added tests exercise dialog visibility or the onCheckedChange callback; none use fake backing stores, collection-size-only checks, or Unconfined emission order.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 12, 2026
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
5838 1 5837 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.app.map.MapNodeClusterItemsTest::node changes and minute rollover refresh cluster items
Stack Traces | 0.283s run time
kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions before the test started. Please avoid this, as such exceptions are also reported in a platform-dependent manner so that they are not lost.
	at kotlinx.coroutines.test.TestScopeImpl.enter(TestScope.kt:231)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:308)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:167)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at androidx.compose.ui.test.AndroidComposeUiTestEnvironment.runTest(ComposeUiTest.android.kt:658)
	at androidx.compose.ui.test.v2.ComposeUiTest_androidKt.runAndroidComposeUiTest-zkXUZaI(ComposeUiTest.android.kt:188)
	at androidx.compose.ui.test.v2.ComposeUiTest_androidKt.runComposeUiTest-exY8QGI(ComposeUiTest.android.kt:81)
	at androidx.compose.ui.test.v2.ComposeUiTest_androidKt.runComposeUiTest-exY8QGI$default(ComposeUiTest.android.kt:75)
	at org.meshtastic.app.map.MapNodeClusterItemsTest.node changes and minute rollover refresh cluster items(MapNodeClusterItemsTest.kt:84)
	Suppressed: org.koin.core.error.InstanceCreationException: Could not create instance for '[Singleton: 'org.meshtastic.feature.discovery.DiscoveryScanEngine',binds:org.meshtastic.core.repository.DiscoveryPacketCollector]'
		at org.koin.core.instance.InstanceFactory.create(InstanceFactory.kt:56)
		at org.koin.core.instance.SingleInstanceFactory.create(SingleInstanceFactory.kt:48)
		at org.koin.core.instance.SingleInstanceFactory.get$lambda$0(SingleInstanceFactory.kt:60)
		at org.koin.mp.KoinPlatformTools.synchronized(KoinPlatformTools.kt:36)
		at org.koin.core.instance.SingleInstanceFactory.get(SingleInstanceFactory.kt:57)
		at org.koin.core.resolution.CoreResolverV2.resolveDirectDefinition(CoreResolverV2.kt:66)
		at org.koin.core.resolution.CoreResolverV2.resolveFromRegistry(CoreResolverV2.kt:58)
		at org.koin.core.resolution.CoreResolverV2.resolveFromContextOrNull(CoreResolverV2.kt:50)
		at org.koin.core.resolution.CoreResolverV2.resolveFromContext(CoreResolverV2.kt:44)
		at org.koin.core.scope.Scope.resolveFromContext(Scope.kt:330)
		at org.koin.core.scope.Scope.stackParametersCall(Scope.kt:293)
		at org.koin.core.scope.Scope.resolveInstance(Scope.kt:279)
		at org.koin.core.scope.Scope.resolve(Scope.kt:252)
		at org.koin.core.scope.Scope.get(Scope.kt:234)
		at org.meshtastic.app.MeshUtilApplication$onCreate$4.invokeSuspend(MeshUtilApplication.kt:180)
		at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
		at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
		at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:807)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
		Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [CoroutineId(294), "coroutine#294":StandaloneCoroutine{Cancelling}@6b338349, Dispatchers.Default]
	Caused by: org.koin.core.error.InstanceCreationException: Could not create instance for '[Singleton: 'org.meshtastic.core.repository.RadioController',binds:org.meshtastic.core.repository.RadioController,org.meshtastic.core.repository.AdminController,org.meshtastic.core.repository.MessagingController,org.meshtastic.core.repository.NodeController,org.meshtastic.core.repository.QueryController]'
		at org.koin.core.instance.InstanceFactory.create(InstanceFactory.kt:56)
		at org.koin.core.instance.SingleInstanceFactory.create(SingleInstanceFactory.kt:48)
		at org.koin.core.instance.SingleInstanceFactory.get$lambda$0(SingleInstanceFactory.kt:60)
		at org.koin.mp.KoinPlatformTools.synchronized(KoinPlatformTools.kt:36)
		at org.koin.core.instance.SingleInstanceFactory.get(SingleInstanceFactory.kt:57)
		at org.koin.core.resolution.CoreResolverV2.resolveDirectDefinition(CoreResolverV2.kt:66)
		at org.koin.core.resolution.CoreResolverV2.resolveFromRegistry(CoreResolverV2.kt:58)
		at org.koin.core.resolution.CoreResolverV2.resolveFromContextOrNull(CoreResolverV2.kt:50)
		at org.koin.core.resolution.CoreResolverV2.resolveFromContext(CoreResolverV2.kt:44)
		at org.koin.core.scope.Scope.resolveFromContext(Scope.kt:330)
		at org.koin.core.scope.Scope.stackParametersCall(Scope.kt:293)
		at org.koin.core.scope.Scope.resolveInstance(Scope.kt:279)
		at org.koin.core.scope.Scope.resolve(Scope.kt:252)
		at org.koin.core.scope.Scope.get(Scope.kt:234)
		at org.meshtastic.feature.discovery.di.OrgMeshtasticFeatureDiscoveryDiFeatureDiscoveryModuleModuleKt.module$lambda$0$4(orgMeshtasticFeatureDiscoveryDiFeatureDiscoveryModuleModule.kt:3)
		at org.koin.core.instance.InstanceFactory.create(InstanceFactory.kt:49)
		... 20 more
	Caused by: org.koin.core.error.ClosedScopeException: Scope '_root_' is closed
		at org.koin.core.scope.Scope.resolveInstance(Scope.kt:475)
		at org.koin.core.scope.Scope.resolve(Scope.kt:252)
		at org.koin.core.scope.Scope.get(Scope.kt:234)
		at org.meshtastic.core.service.di.OrgMeshtasticCoreServiceDiCoreServiceAndroidModuleModuleKt.module$lambda$0$14(orgMeshtasticCoreServiceDiCoreServiceAndroidModuleModule.kt:2)
		at org.koin.core.instance.InstanceFactory.create(InstanceFactory.kt:49)
		... 35 more
	Suppressed: android.database.SQLException: Error code: 21, message: Connection pool is closed
		at androidx.sqlite.SQLite__SQLiteKt.throwSQLiteException(SQLite.kt:64)
		at androidx.sqlite.SQLite.throwSQLiteException(Unknown Source)
		at androidx.room3.coroutines.ConnectionPoolImpl.useConnection(ConnectionPoolImpl.kt:118)
		at androidx.room3.RoomConnectionManager.useConnection(RoomConnectionManager.android.kt:99)
		at androidx.room3.RoomDatabase.useConnection(RoomDatabase.android.kt:436)
		at androidx.room3.util.DBUtil__DBUtilKt$performSuspending$2.invokeSuspend(DBUtil.kt:208)
		at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
		at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
		at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:124)
		at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
		at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:798)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
		at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
		Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [CoroutineId(389), "coroutine#389":StandaloneCoroutine{Cancelling}@68b9d9cd, Dispatchers.Default]
	Suppressed: org.robolectric.android.internal.AndroidTestEnvironment$UnExecutedRunnablesException: Main looper has queued unexecuted runnables. This might be the cause of the test failure. You might need a shadowOf(Looper.getMainLooper()).idle() call.

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions

This comment has been minimized.

jamesarich and others added 2 commits August 11, 2026 21:47
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@jamesarich
jamesarich marked this pull request as ready for review August 12, 2026 13:04
@jamesarich
jamesarich added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit b79ec33 Aug 12, 2026
15 checks passed
@jamesarich
jamesarich deleted the claude/focused-maxwell-e36781 branch August 12, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant