From 2dba958e1e633f2ad76913f726694eb2ec9d65ec Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:17:19 -0500 Subject: [PATCH] fix(qr): Preserve incoming channels when adding from QR (#6013) --- .../core/ui/qr/ScannedQrCodeDialog.kt | 5 +- .../core/ui/util/ProtoExtensions.kt | 17 +++++ .../core/ui/util/ProtoExtensionsTest.kt | 71 +++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index ed5f8c9f9..239f4f6ff 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -63,6 +63,7 @@ import org.meshtastic.core.resources.replace import org.meshtastic.core.resources.replace_channels_and_settings_description import org.meshtastic.core.ui.component.ChannelSelection import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.mergeChannelSettingsForAdd import org.meshtastic.proto.ChannelSet @Composable @@ -107,9 +108,7 @@ fun ScannedQrCodeDialog( ), ) } else { - // To guarantee consistent ordering, using a LinkedHashSet which iterates through - // its entries according to the order an item was *first* inserted. - val result = (channels.settings + incoming.settings).distinct() + val result = mergeChannelSettingsForAdd(channels.settings, incoming.settings) channels.copy(settings = result) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt index 5925e39e3..4a2e1d380 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt @@ -154,3 +154,20 @@ suspend fun applyReplacementChannelSet( } radioConfigRepository.replaceAllSettings(channelSet.settings) } + +/** + * Builds the ADD-mode preview list for QR import. Existing channels are preserved at their positions; every incoming + * channel is appended in order without deduplication. + * + * Structural `.distinct()` was previously used here, but it silently dropped incoming channels that matched existing + * entries, shifting later channels to wrong indices and hiding them from the user. The caller (UI) lets the user select + * which incoming channels to keep. + * + * @param existing The current [ChannelSettings] list on the radio. Preserved in order. + * @param incoming The imported [ChannelSettings] list. Appended in order. + * @return The concatenated list `[existing..., incoming...]`. + */ +fun mergeChannelSettingsForAdd( + existing: List, + incoming: List, +): List = existing + incoming diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt index 4afc97923..bf6a55dcf 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt @@ -141,4 +141,75 @@ class ProtoExtensionsTest { assertEquals(2, result[2].index) assertEquals(secondaryB, result[2].settings) } + + // --- mergeChannelSettingsForAdd tests --- + + @Test + fun merge_preserves_all_existing_channels_in_order() { + val existing = listOf(ChannelSettings(name = "A"), ChannelSettings(name = "B")) + + val result = mergeChannelSettingsForAdd(existing, incoming = emptyList()) + + assertEquals(2, result.size) + assertEquals("A", result[0].name) + assertEquals("B", result[1].name) + } + + @Test + fun merge_appends_all_incoming_channels_in_order() { + val incoming = listOf(ChannelSettings(name = "C"), ChannelSettings(name = "D")) + + val result = mergeChannelSettingsForAdd(existing = emptyList(), incoming) + + assertEquals(2, result.size) + assertEquals("C", result[0].name) + assertEquals("D", result[1].name) + } + + @Test + fun merge_preserves_structurally_equal_channels() { + val channel = ChannelSettings(name = "LongFast", psk = byteArrayOf(1).toByteString()) + + val result = mergeChannelSettingsForAdd(existing = listOf(channel), incoming = listOf(channel)) + + assertEquals(2, result.size) + } + + @Test + fun merge_preserves_same_name_different_psk() { + val existingChan = ChannelSettings(name = "A", psk = byteArrayOf(1).toByteString()) + val incomingChan = ChannelSettings(name = "A", psk = byteArrayOf(2).toByteString()) + + val result = mergeChannelSettingsForAdd(listOf(existingChan), listOf(incomingChan)) + + assertEquals(2, result.size) + } + + @Test + fun merge_preserves_same_psk_different_name() { + val psk = byteArrayOf(1, 2).toByteString() + val existingChan = ChannelSettings(name = "A", psk = psk) + val incomingChan = ChannelSettings(name = "B", psk = psk) + + val result = mergeChannelSettingsForAdd(listOf(existingChan), listOf(incomingChan)) + + assertEquals(2, result.size) + } + + @Test + fun merge_preserves_duplicate_inside_incoming() { + val a = ChannelSettings(name = "A", psk = byteArrayOf(1).toByteString()) + val b = ChannelSettings(name = "B", psk = byteArrayOf(2).toByteString()) + + val result = mergeChannelSettingsForAdd(existing = emptyList(), incoming = listOf(a, a, b)) + + assertEquals(3, result.size) + } + + @Test + fun merge_both_empty_produces_empty_list() { + val result = mergeChannelSettingsForAdd(existing = emptyList(), incoming = emptyList()) + + assertTrue(result.isEmpty()) + } }