diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
index 77730ed7e8..4e8d0891ff 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
@@ -184,7 +184,7 @@ private fun ChannelSettings.withoutPositionSharing(): ChannelSettings =
* @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes
*/
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri {
- val channelBytes = ChannelSet.ADAPTER.encode(this)
+ val channelBytes = ChannelSet.ADAPTER.encode(if (shouldAdd) copy(lora_config = null) else this)
val enc = channelBytes.toByteString().base64Url().replace("=", "")
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetUrlTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetUrlTest.kt
index 1229a44bbb..e047d4c2f1 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetUrlTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetUrlTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model.util
+import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
@@ -43,16 +44,34 @@ class ChannelSetUrlTest {
fun `all supported channel counts preserve settings for replace and add`() {
for (channelCount in 1..8) {
val original = channelSet(channelCount)
- val replace = original.getChannelUrl().toChannelSet()
- val add = original.getChannelUrl(shouldAdd = true).toChannelSet()
+ val replaceUrl = original.getChannelUrl()
+ val addUrl = original.getChannelUrl(shouldAdd = true)
+ val replace = replaceUrl.toChannelSet()
+ val add = addUrl.toChannelSet()
+ val replacePayload = replaceUrl.encodedChannelSet()
+ val addPayload = addUrl.encodedChannelSet()
+ assertEquals(original.settings, replacePayload.settings, "$channelCount-channel replace payload settings")
+ assertEquals(
+ original.lora_config,
+ replacePayload.lora_config,
+ "$channelCount-channel replace payload LoRa config",
+ )
assertEquals(original.settings, replace.settings, "$channelCount-channel replace settings")
assertEquals(original.lora_config, replace.lora_config, "$channelCount-channel replace LoRa config")
+ assertEquals(original.settings, addPayload.settings, "$channelCount-channel add payload settings")
+ assertNull(addPayload.lora_config, "$channelCount-channel add payload must omit LoRa config")
assertEquals(original.settings, add.settings, "$channelCount-channel add settings")
assertNull(add.lora_config, "$channelCount-channel add must not retune")
}
}
+ private fun CommonUri.encodedChannelSet(): ChannelSet {
+ val base64 = requireNotNull(fragment).substringBefore('?').replace('-', '+').replace('_', '/')
+ val bytes = requireNotNull(base64.decodeBase64())
+ return ChannelSet.ADAPTER.decode(bytes)
+ }
+
private fun channelSet(channelCount: Int): ChannelSet = ChannelSet(
settings =
(0 until channelCount).map { index ->
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
index 03b360ba70..dfe6f1d70e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
@@ -64,7 +64,6 @@ import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.defaultPresetFor
-import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
@@ -126,7 +125,7 @@ fun ChannelScreen(
var showResetDialog by rememberSaveable { mutableStateOf(false) }
- var shouldAddChannelsState by remember { mutableStateOf(true) }
+ val channelShareState = rememberChannelShareState()
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
@@ -222,8 +221,7 @@ fun ChannelScreen(
if (showShareDialog) {
ChannelShareDialog(
- channelSet = selectedChannelSet,
- shouldAddChannel = shouldAddChannelsState,
+ uriString = channelShareState.uriString(selectedChannelSet),
onDismiss = { showShareDialog = false },
)
}
@@ -264,14 +262,14 @@ fun ChannelScreen(
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
SegmentedButton(
label = { Text(text = stringResource(Res.string.replace)) },
- onClick = { shouldAddChannelsState = false },
- selected = !shouldAddChannelsState,
+ onClick = { channelShareState.shouldAdd = false },
+ selected = !channelShareState.shouldAdd,
shape = SegmentedButtonDefaults.itemShape(0, 2),
)
SegmentedButton(
label = { Text(text = stringResource(Res.string.add)) },
- onClick = { shouldAddChannelsState = true },
- selected = shouldAddChannelsState,
+ onClick = { channelShareState.shouldAdd = true },
+ selected = channelShareState.shouldAdd,
shape = SegmentedButtonDefaults.itemShape(1, 2),
)
}
@@ -303,8 +301,7 @@ fun ChannelScreen(
}
@Composable
-private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
- val uriString = channelSet.getChannelUrl(false, shouldAddChannel).toString()
+private fun ChannelShareDialog(uriString: String, onDismiss: () -> Unit) {
QrDialog(title = stringResource(Res.string.share_channels_qr), uriString = uriString, onDismiss = onDismiss)
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelShareState.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelShareState.kt
new file mode 100644
index 0000000000..b9bd23dd2a
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelShareState.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.channel
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.meshtastic.core.model.util.getChannelUrl
+import org.meshtastic.proto.ChannelSet
+
+internal class ChannelShareState {
+ var shouldAdd by mutableStateOf(false)
+
+ fun uriString(channelSet: ChannelSet): String = channelSet.getChannelUrl(shouldAdd = shouldAdd).toString()
+}
+
+@Composable internal fun rememberChannelShareState(): ChannelShareState = remember { ChannelShareState() }
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreenTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreenTest.kt
new file mode 100644
index 0000000000..6425fe9c21
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreenTest.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.channel
+
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.model.util.toChannelSet
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.Config.LoRaConfig
+import org.meshtastic.proto.Config.LoRaConfig.RegionCode
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+
+class ChannelScreenTest {
+ @Test
+ fun `channel share state defaults to a replace URL`() {
+ val channelSet = ChannelSet(lora_config = LoRaConfig(region = RegionCode.US))
+
+ val url = ChannelShareState().uriString(channelSet)
+
+ assertFalse(url.contains("?add=true"))
+ assertEquals(channelSet.lora_config, CommonUri.parse(url).toChannelSet().lora_config)
+ }
+}