From 7d79b9844128dc4ec74624f2367c88ec492c9dec Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:59:02 -0700 Subject: [PATCH 1/2] fix: align channel QR share contract --- .../meshtastic/core/model/util/ChannelSet.kt | 2 +- .../core/model/util/ChannelSetUrlTest.kt | 23 ++++++++++++++-- .../settings/radio/channel/ChannelScreen.kt | 4 ++- .../radio/channel/ChannelScreenTest.kt | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreenTest.kt 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..b89f0719e1 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 @@ -100,6 +100,8 @@ import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config +internal const val DEFAULT_SHOULD_ADD_CHANNELS = false + /** * Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel * configurations via QR codes or URLs. @@ -126,7 +128,7 @@ fun ChannelScreen( var showResetDialog by rememberSaveable { mutableStateOf(false) } - var shouldAddChannelsState by remember { mutableStateOf(true) } + var shouldAddChannelsState by remember { mutableStateOf(DEFAULT_SHOULD_ADD_CHANNELS) } val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle() 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..5d865d38b8 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreenTest.kt @@ -0,0 +1,27 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertFalse + +class ChannelScreenTest { + @Test + fun `channel sharing defaults to replace`() { + assertFalse(DEFAULT_SHOULD_ADD_CHANNELS) + } +} From 689945c34ed6d9e3453a29dde08e86e99bf32ada Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:09:38 -0700 Subject: [PATCH 2/2] test: bind channel share default to URL flow --- .../settings/radio/channel/ChannelScreen.kt | 19 ++++------- .../radio/channel/ChannelShareState.kt | 33 +++++++++++++++++++ .../radio/channel/ChannelScreenTest.kt | 15 +++++++-- 3 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelShareState.kt 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 b89f0719e1..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 @@ -100,8 +99,6 @@ import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config -internal const val DEFAULT_SHOULD_ADD_CHANNELS = false - /** * Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel * configurations via QR codes or URLs. @@ -128,7 +125,7 @@ fun ChannelScreen( var showResetDialog by rememberSaveable { mutableStateOf(false) } - var shouldAddChannelsState by remember { mutableStateOf(DEFAULT_SHOULD_ADD_CHANNELS) } + val channelShareState = rememberChannelShareState() val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle() @@ -224,8 +221,7 @@ fun ChannelScreen( if (showShareDialog) { ChannelShareDialog( - channelSet = selectedChannelSet, - shouldAddChannel = shouldAddChannelsState, + uriString = channelShareState.uriString(selectedChannelSet), onDismiss = { showShareDialog = false }, ) } @@ -266,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), ) } @@ -305,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 index 5d865d38b8..6425fe9c21 100644 --- 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 @@ -16,12 +16,23 @@ */ 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 sharing defaults to replace`() { - assertFalse(DEFAULT_SHOULD_ADD_CHANNELS) + 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) } }