From 76ddd29114bb4af7d75bffef0810e3c01ea7e2fd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:12:32 -0500 Subject: [PATCH] feat: Support the `add` export method on channel url/qr (#2934) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> --- .../com/geeksville/mesh/model/ChannelSet.kt | 59 ++++++++----------- .../com/geeksville/mesh/ui/sharing/Channel.kt | 36 ++++++++++- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index a3dbe67d1..06d769199 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -30,72 +30,61 @@ import kotlin.jvm.Throws private const val MESHTASTIC_HOST = "meshtastic.org" private const val CHANNEL_PATH = "/e/" -internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_PATH#" +internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_PATH" private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING /** * Return a [ChannelSet] that represents the ChannelSet encoded by the URL. + * * @throws MalformedURLException when not recognized as a valid Meshtastic URL */ @Throws(MalformedURLException::class) fun Uri.toChannelSet(): ChannelSet { - if (fragment.isNullOrBlank() || - !host.equals(MESHTASTIC_HOST, true) || - !path.equals(CHANNEL_PATH, true) - ) { + if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_PATH, true)) { throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") } // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. // This gracefully handles those cases until the newer version are generally available/used. val url = ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)) - val shouldAdd = fragment?.substringAfter('?', "") - ?.takeUnless { it.isBlank() } - ?.equals("add=true") - ?: getBooleanQueryParameter("add", false) + val shouldAdd = + fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true") + ?: getBooleanQueryParameter("add", false) return url.toBuilder().apply { if (shouldAdd) clearLoraConfig() }.build() } -/** - * @return A list of globally unique channel IDs usable with MQTT subscribe() - */ +/** @return A list of globally unique channel IDs usable with MQTT subscribe() */ val ChannelSet.subscribeList: List get() = settingsList.filter { it.downlinkEnabled }.map { Channel(it, loraConfig).name } fun ChannelSet.getChannel(index: Int): Channel? = if (settingsCount > index) Channel(getSettings(index), loraConfig) else null -/** - * Return the primary channel info - */ -val ChannelSet.primaryChannel: Channel? get() = getChannel(0) +/** Return the primary channel info */ +val ChannelSet.primaryChannel: Channel? + get() = getChannel(0) /** * Return a URL that represents the [ChannelSet] + * * @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes */ -fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false): Uri { +fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri { val channelBytes = this.toByteArray() ?: ByteArray(0) // if unset just use empty val enc = Base64.encodeToString(channelBytes, BASE64FLAGS) val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX - return Uri.parse("$p$enc") + val query = if (shouldAdd) "?add=true" else "" + return Uri.parse("$p$query#$enc") } -val ChannelSet.qrCode: Bitmap? - get() = try { - val multiFormatWriter = MultiFormatWriter() - - val bitMatrix = - multiFormatWriter.encode( - getChannelUrl(false).toString(), - BarcodeFormat.QR_CODE, - 960, - 960 - ) - val barcodeEncoder = BarcodeEncoder() - barcodeEncoder.createBitmap(bitMatrix) - } catch (ex: Throwable) { - errormsg("URL was too complex to render as barcode") - null - } +fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try { + val multiFormatWriter = MultiFormatWriter() + val bitMatrix = + multiFormatWriter.encode(getChannelUrl(false, shouldAdd).toString(), BarcodeFormat.QR_CODE, 960, 960) + val barcodeEncoder = BarcodeEncoder() + barcodeEncoder.createBitmap(bitMatrix) +} catch (ex: Throwable) { + errormsg("URL was too complex to render as barcode") + null +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 079014cef..e81dd74e1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -50,6 +50,9 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -139,6 +142,8 @@ fun ChannelScreen( var showResetDialog by remember { mutableStateOf(false) } + var shouldAddChannelsState by remember { mutableStateOf(true) } + /* Animate waiting for the configurations */ var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { @@ -269,6 +274,7 @@ fun ChannelScreen( channelSet = channelSet, modemPresetName = modemPresetName, channelSelections = channelSelections, + shouldAddChannel = shouldAddChannelsState, onClick = { isWaiting = true radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS) @@ -276,10 +282,26 @@ fun ChannelScreen( ) EditChannelUrl( enabled = enabled, - channelUrl = selectedChannelSet.getChannelUrl(), + channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState), onConfirm = viewModel::requestChannelUrl, ) } + item { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + SegmentedButton( + label = { Text(text = stringResource(R.string.replace)) }, + onClick = { shouldAddChannelsState = false }, + selected = !shouldAddChannelsState, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) + SegmentedButton( + label = { Text(text = stringResource(R.string.add)) }, + onClick = { shouldAddChannelsState = true }, + selected = shouldAddChannelsState, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) + } + } item { ModemPresetInfo( modemPresetName = modemPresetName, @@ -401,9 +423,15 @@ private fun EditChannelUrl(enabled: Boolean, channelUrl: Uri, modifier: Modifier } @Composable -private fun QrCodeImage(enabled: Boolean, channelSet: ChannelSet, modifier: Modifier = Modifier) = Image( +private fun QrCodeImage( + enabled: Boolean, + channelSet: ChannelSet, + modifier: Modifier = Modifier, + shouldAddChannel: Boolean = false, +) = Image( painter = - channelSet.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode), + channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) } + ?: painterResource(id = R.drawable.qrcode), contentDescription = stringResource(R.string.qr_code), modifier = modifier, contentScale = ContentScale.Inside, @@ -417,6 +445,7 @@ private fun ChannelListView( channelSet: ChannelSet, modemPresetName: String, channelSelections: SnapshotStateList, + shouldAddChannel: Boolean = false, onClick: () -> Unit = {}, ) { val selectedChannelSet = @@ -459,6 +488,7 @@ private fun ChannelListView( enabled = enabled, channelSet = selectedChannelSet, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + shouldAddChannel = shouldAddChannel, ) }, )