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
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -222,8 +221,7 @@ fun ChannelScreen(

if (showShareDialog) {
ChannelShareDialog(
channelSet = selectedChannelSet,
shouldAddChannel = shouldAddChannelsState,
uriString = channelShareState.uriString(selectedChannelSet),
onDismiss = { showShareDialog = false },
)
}
Expand Down Expand Up @@ -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),
)
}
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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() }
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}