diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 8a5e10a2f..cc8c2437a 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -53,6 +53,9 @@ class FakeRadioController : val lastLocalConfig: Config? get() = localConfigs.lastOrNull() + /** Every [setLocalChannel] call, in order. */ + val localChannels = mutableListOf() + var throwOnSend: Boolean = false var lastSetDeviceAddress: String? = null var lastSetOwnerUser: User? = null @@ -66,6 +69,7 @@ class FakeRadioController : favoritedNodes.clear() sentSharedContacts.clear() localConfigs.clear() + localChannels.clear() throwOnSend = false lastSetDeviceAddress = null lastSetOwnerUser = null @@ -107,7 +111,9 @@ class FakeRadioController : localConfigs.add(config) } - override suspend fun setLocalChannel(channel: Channel) {} + override suspend fun setLocalChannel(channel: Channel) { + localChannels.add(channel) + } override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { lastSetOwnerUser = user diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 8d72ec0b1..1b8cbc7d7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -22,12 +22,11 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController +import org.meshtastic.core.ui.util.applyImportedLoraConfigAfterChannelReplacement import org.meshtastic.core.ui.util.applyReplacementChannelSet import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.Config -import org.meshtastic.proto.LocalConfig internal const val DEFAULT_MAX_CHANNELS = 8 @@ -47,20 +46,13 @@ class ScannedQrCodeViewModel( initialValue = nodeRepository.myNodeInfo.value?.maxChannels?.takeIf { it > 0 } ?: DEFAULT_MAX_CHANNELS, ) - private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - /** Set the radio config (also updates our saved copy in preferences). */ fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { - applyReplacementChannelSet(channelSet, radioController, radioConfigRepository) - - val loraConfig = channelSet.lora_config - if (loraConfig != null && localConfig.value.lora != loraConfig) { - setConfig(Config(lora = loraConfig)) - } - } - - // Set the radio config (also updates our saved copy in preferences) - private fun setConfig(config: Config) { - safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } + val currentLoraConfig = applyReplacementChannelSet(channelSet, radioController, radioConfigRepository) + applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig = channelSet.lora_config, + currentLoraConfig = currentLoraConfig, + radioController = radioController, + ) } } 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 23c933dec..12ea29288 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 @@ -17,7 +17,11 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable +import co.touchlab.kermit.Logger +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import okio.ByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter @@ -32,11 +36,20 @@ import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Position +import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds import org.meshtastic.core.model.Channel as ModelChannel private const val SECONDS_TO_MILLIS = 1000L +// Firmware channel files expose eight slots: one primary plus up to seven secondary channels. +private const val CHANNEL_REPLACEMENT_SLOT_COUNT = 8 + +// Full channel replacement writes need conservative settle windows so hardware can persist each slot. +private val CHANNEL_REPLACEMENT_WRITE_DELAY = 1.seconds +private val LORA_CONFIG_SETTLE_DELAY = 2.seconds + @Composable fun Position.formatPositionTime(): String { val currentTime = nowMillis @@ -107,64 +120,215 @@ fun getChannelList(new: List, old: List): List * @param new The imported [ChannelSettings] list. Every index becomes a write to the radio. * @param currentSettings The current [ChannelSettings] list. Only its size is used; trailing indices past [new] become * DISABLED writes so leftover slots are cleared. + * @param minimumSlotCount The minimum slot count to emit. Full replacement callers can use this to disable firmware + * slots even when the local cache is stale or shorter than the radio's actual channel list. + * @param maximumSlotCount The maximum slot count to emit. Full replacement callers use this to avoid unsupported + * firmware channel indices even if an imported or cached list is longer than expected. * @return A [Channel] list covering every slot the radio needs written to materialize [new] and clear leftover slots. */ -fun getChannelReplacementList(new: List, currentSettings: List): List = - buildList { - for (i in 0..maxOf(currentSettings.lastIndex, new.lastIndex)) { - add( - Channel( - role = - when (i) { - // Empty-new is a degenerate import: every slot (including 0) must be DISABLED. - 0 -> if (new.isEmpty()) Channel.Role.DISABLED else Channel.Role.PRIMARY +fun getChannelReplacementList( + new: List, + currentSettings: List, + minimumSlotCount: Int = 0, + maximumSlotCount: Int = Int.MAX_VALUE, +): List = buildList { + require(minimumSlotCount <= maximumSlotCount) { "minimumSlotCount must be <= maximumSlotCount" } + val minimumLastIndex = minimumSlotCount.coerceAtLeast(0) - 1 + val maximumLastIndex = maximumSlotCount.coerceAtLeast(0) - 1 + val endIndex = maxOf(currentSettings.lastIndex, new.lastIndex, minimumLastIndex).coerceAtMost(maximumLastIndex) + if (endIndex < 0) return@buildList + for (i in 0..endIndex) { + add( + Channel( + role = + when (i) { + // Empty-new is a degenerate import: every slot (including 0) must be DISABLED. + 0 -> if (new.isEmpty()) Channel.Role.DISABLED else Channel.Role.PRIMARY - in 1..new.lastIndex -> Channel.Role.SECONDARY + in 1..new.lastIndex -> Channel.Role.SECONDARY - else -> Channel.Role.DISABLED - }, - index = i, - settings = new.getOrNull(i) ?: ChannelSettings(), - ), - ) + else -> Channel.Role.DISABLED + }, + index = i, + settings = new.getOrNull(i) ?: ChannelSettings(), + ), + ) + } +} + +/** + * Normalizes an imported REPLACE-mode [ChannelSettings] list so firmware only materializes real, distinct channels. + * + * Imported replacement sets can carry blank placeholder secondaries (trailing empty [ChannelSettings] padding) and + * semantic duplicates (two slots resolving to the same effective channel under the active LoRa preset). Both produce + * invalid LongFast-looking slots on the radio that cause route failures (`QueueStatus res=6` / `routeErr=6`). + * - Slot 0 (primary) is always preserved as-is, even if blank (a blank primary is a deliberate disable signal). + * - A blank placeholder primary does not participate in duplicate tracking. + * - Blank placeholder secondaries (no name AND no PSK) are dropped. + * - Semantic duplicates (same effective name + effective PSK as an earlier kept slot) are dropped. + * - Remaining valid secondaries compact into sequential slots 1..n. + * + * @param settings Raw imported settings list. + * @param loraConfig Active LoRa config used to resolve effective channel identity. Null falls back to defaults. + * @return Compacted, deduplicated list safe to write to the radio. + */ +fun normalizeReplacementSettings( + settings: List, + loraConfig: Config.LoRaConfig?, +): List { + if (settings.size <= 1) return settings + val effectiveLora = loraConfig ?: Config.LoRaConfig() + val primary = settings.first() + val seen = mutableSetOf() + if (!primary.isPlaceholder()) { + seen.add(primary.channelIdentity(effectiveLora)) + } + val compact = mutableListOf(primary) + for (index in 1..settings.lastIndex) { + val candidate = settings[index] + val identity = if (candidate.isPlaceholder()) null else candidate.channelIdentity(effectiveLora) + if (identity != null && seen.add(identity)) { + compact.add(candidate) } } + return compact +} + +/** True when a [ChannelSettings] carries no name and no PSK — a placeholder, not an intended channel. */ +private fun ChannelSettings.isPlaceholder(): Boolean = name.isNullOrBlank() && (psk == null || psk.size == 0) /** * Applies an imported [ChannelSet] as an authoritative replacement to the radio and local cache. * - * Reads the current channel set from [radioConfigRepository]'s flow (avoiding the StateFlow placeholder window), builds - * the authoritative replacement list via [getChannelReplacementList], enqueues each channel write to the radio - * sequentially via [radioController], then atomically replaces the local cached settings. + * Reads the current LoRa config and channel set from [radioConfigRepository]'s flows (avoiding the StateFlow + * placeholder window), builds the authoritative replacement list via [getChannelReplacementList], enqueues each channel + * write to the radio via [radioController], pauses between writes so the radio can persist and reconfigure each slot, + * then atomically replaces the local cached settings. * - * setLocalChannel returns once the packet is enqueued, not after firmware ACK — firmware echoes via - * MeshConfigHandlerImpl can still arrive after [radioConfigRepository.replaceAllSettings] and are tracked separately. + * setLocalChannel returns once the packet is enqueued, not after firmware ACK. The pacing avoids enqueueing a complete + * channel replacement plus LoRa reconfiguration faster than real hardware can materialize the later channel slots. If + * the sequence is interrupted after one or more successful writes, the local cache is reconciled to the successfully + * enqueued channel settings before the original cancellation or failure continues. + * + * Imported settings are normalized via [normalizeReplacementSettings] before any write or bounds check, so blank + * placeholder secondaries and semantic duplicates never reach the radio or the local cache. * * Does NOT handle LoRa config — callers are responsible for comparing and sending `lora_config` if present. * * @param channelSet The imported [ChannelSet] to apply as a replacement. * @param radioController The [RadioController] used to enqueue channel writes. * @param radioConfigRepository The [RadioConfigRepository] providing the current channel flow and cache. + * @param writeDelay Delay after each channel write. Exposed for fast unit tests. + * @param delayFn Delay implementation. Exposed for fast unit tests. + * @return The device's current LoRa config snapshot used by callers to compare against an imported LoRa config. */ suspend fun applyReplacementChannelSet( channelSet: ChannelSet, radioController: RadioController, radioConfigRepository: RadioConfigRepository, -) { - val currentSettings = radioConfigRepository.channelSetFlow.first().settings - for (channel in getChannelReplacementList(channelSet.settings, currentSettings)) { - radioController.setLocalChannel(channel) + writeDelay: Duration = CHANNEL_REPLACEMENT_WRITE_DELAY, + delayFn: suspend (Duration) -> Unit = { delay(it) }, +): Config.LoRaConfig? { + // Resolve the LoRa preset used for semantic identity: prefer the imported config, fall back to the device's current + // local config so duplicate detection stays correct when the import omits lora_config (e.g. a non-default preset). + val currentLoraConfig = radioConfigRepository.localConfigFlow.first().lora + val identityLoraConfig = channelSet.lora_config ?: currentLoraConfig + val normalizedSettings = normalizeReplacementSettings(channelSet.settings, identityLoraConfig) + require(normalizedSettings.size <= CHANNEL_REPLACEMENT_SLOT_COUNT) { + "Imported channel set exceeds supported channel slot count" } - radioConfigRepository.replaceAllSettings(channelSet.settings) + val currentSettings = radioConfigRepository.channelSetFlow.first().settings + val replacements = + getChannelReplacementList( + new = normalizedSettings, + currentSettings = currentSettings, + minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, + maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, + ) + Logger.i { + "Applying imported channel replacement writes=${replacements.size} " + + "importedSettings=${channelSet.settings.size} normalizedSettings=${normalizedSettings.size}" + } + val appliedSettings = currentSettings.take(CHANNEL_REPLACEMENT_SLOT_COUNT).toMutableList() + var appliedWriteCount = 0 + var replacementComplete = false + try { + for (channel in replacements) { + Logger.i { + "Writing imported channel index=${channel.index} role=${channel.role} " + + "hasName=${channel.settings?.name?.isNotBlank() == true}" + } + radioController.setLocalChannel(channel) + while (appliedSettings.size <= channel.index) { + appliedSettings.add(ChannelSettings()) + } + appliedSettings[channel.index] = + if (channel.role == Channel.Role.DISABLED) { + ChannelSettings() + } else { + channel.settings ?: ChannelSettings() + } + appliedWriteCount++ + delayFn(writeDelay) + } + replacementComplete = true + } finally { + if (!replacementComplete) { + radioConfigRepository.reconcileInterruptedReplacement( + appliedWriteCount = appliedWriteCount, + totalWriteCount = replacements.size, + appliedSettings = appliedSettings, + normalizedSettings = normalizedSettings, + ) + } + } + withContext(NonCancellable) { radioConfigRepository.replaceAllSettings(normalizedSettings) } + return currentLoraConfig +} + +private suspend fun RadioConfigRepository.reconcileInterruptedReplacement( + appliedWriteCount: Int, + totalWriteCount: Int, + appliedSettings: List, + normalizedSettings: List, +) { + if (appliedWriteCount == 0) return + val replacementSettings = if (appliedWriteCount == totalWriteCount) normalizedSettings else appliedSettings + Logger.w { + "Reconciling interrupted channel replacement appliedWrites=$appliedWriteCount totalWrites=$totalWriteCount" + } + withContext(NonCancellable) { replaceAllSettings(replacementSettings) } +} + +/** + * Applies an imported LoRa config after channel replacement writes have had time to settle. + * + * LoRa reconfiguration is expensive on firmware and can race with channel persistence if sent immediately after a full + * channel replacement. The pre/post settle delays give the radio time to materialize the imported channels before and + * after the LoRa write. + */ +suspend fun applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig: Config.LoRaConfig?, + currentLoraConfig: Config.LoRaConfig?, + radioController: RadioController, + settleDelay: Duration = LORA_CONFIG_SETTLE_DELAY, + delayFn: suspend (Duration) -> Unit = { delay(it) }, +) { + if (importedLoraConfig == null || currentLoraConfig == importedLoraConfig) return + + Logger.i { "Settling before imported LoRa config write" } + delayFn(settleDelay) + radioController.setLocalConfig(Config(lora = importedLoraConfig)) + Logger.i { "Settling after imported LoRa config write" } + delayFn(settleDelay) } /** * Builds the filtered ADD-mode preview for QR import: existing channels followed by only the unique incoming channels. * * Incoming channels that are semantic duplicates (same effective name + effective PSK) of an existing or earlier - * incoming channel are omitted entirely from the preview — they are not shown to the user. Unique incoming channels are - * appended in scanned order and selected by default while firmware channel capacity remains; unique channels beyond - * [maxChannels] stay visible but unchecked. + * incoming channel are omitted from the preview. Unique incoming channels are appended in scanned order and selected by + * default while firmware channel capacity remains; unique channels beyond [maxChannels] stay visible but unchecked. * * Semantic identity is resolved via the [Channel] domain model so preset/default channels match correctly across modem * presets: empty names resolve to the preset display name, and 1-byte PSK markers expand to the full default key. @@ -209,9 +373,6 @@ private data class ChannelIdentity(val name: String, val psk: ByteString) { override fun toString(): String = "ChannelIdentity(name=$name, psk=)" } -/** True when a [ChannelSettings] carries no name and no PSK — a placeholder, not an intended channel. */ -private fun ChannelSettings.isPlaceholder(): Boolean = name.isNullOrBlank() && (psk == null || psk.size == 0) - /** Resolves the [ChannelIdentity] of this [ChannelSettings] under the given [Config.LoRaConfig]. */ private fun ChannelSettings.channelIdentity(loraConfig: Config.LoRaConfig): ChannelIdentity { val channel = ModelChannel(settings = this, loraConfig = loraConfig) 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 de6b0ba1c..e5f069c0d 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 @@ -16,14 +16,25 @@ */ package org.meshtastic.core.ui.util +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString +import org.meshtastic.core.testing.FakeRadioConfigRepository +import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import org.meshtastic.core.model.Channel as ModelChannel /** @@ -145,6 +156,322 @@ class ProtoExtensionsTest { assertEquals(secondaryB, result[2].settings) } + @Test + fun replacement_list_rejects_minimum_slot_count_above_maximum_slot_count() { + assertFailsWith { + getChannelReplacementList( + new = listOf(ChannelSettings(name = "Main")), + currentSettings = emptyList(), + minimumSlotCount = 2, + maximumSlotCount = 1, + ) + } + } + + @Test + fun replacement_apply_paces_every_write_before_replacing_cached_settings() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = + listOf( + ChannelSettings(name = "Old Primary"), + ChannelSettings(name = "Old Secondary"), + ChannelSettings(name = "Old Tertiary"), + ) + val importedSettings = listOf(ChannelSettings(name = "Imported"), ChannelSettings(name = "Private")) + val cacheSnapshotsAtDelay = mutableListOf>() + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + applyReplacementChannelSet( + channelSet = ChannelSet(settings = importedSettings), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = { cacheSnapshotsAtDelay.add(radioConfigRepository.currentChannelSet.settings) }, + ) + + assertEquals((0..7).toList(), radioController.localChannels.map { it.index }) + assertEquals( + listOf( + Channel.Role.PRIMARY, + Channel.Role.SECONDARY, + Channel.Role.DISABLED, + Channel.Role.DISABLED, + Channel.Role.DISABLED, + Channel.Role.DISABLED, + Channel.Role.DISABLED, + Channel.Role.DISABLED, + ), + radioController.localChannels.map { it.role }, + ) + assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings) + assertEquals(List(size = 8) { oldSettings }, cacheSnapshotsAtDelay) + } + + @Test + fun replacement_apply_reconciles_successful_writes_when_interrupted_during_pacing() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = + listOf( + ChannelSettings(name = "Old Primary"), + ChannelSettings(name = "Old Secondary"), + ChannelSettings(name = "Old Tertiary"), + ) + val importedPrimary = ChannelSettings(name = "Imported") + val importedSecondary = ChannelSettings(name = "Private") + var delayCount = 0 + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + assertFailsWith { + applyReplacementChannelSet( + channelSet = ChannelSet(settings = listOf(importedPrimary, importedSecondary)), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = { + delayCount++ + if (delayCount == 2) error("stop") + }, + ) + } + + assertEquals(listOf(0, 1), radioController.localChannels.map { it.index }) + assertEquals( + listOf(importedPrimary, importedSecondary, oldSettings[2]), + radioConfigRepository.currentChannelSet.settings, + ) + } + + @Test + fun replacement_apply_compacts_cache_when_interrupted_after_all_channel_writes() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = + listOf( + ChannelSettings(name = "Old Primary"), + ChannelSettings(name = "Old Secondary"), + ChannelSettings(name = "Old Tertiary"), + ) + val importedSettings = listOf(ChannelSettings(name = "Imported"), ChannelSettings(name = "Private")) + var delayCount = 0 + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + assertFailsWith { + applyReplacementChannelSet( + channelSet = ChannelSet(settings = importedSettings), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = { + delayCount++ + if (delayCount == 8) error("stop") + }, + ) + } + + assertEquals((0..7).toList(), radioController.localChannels.map { it.index }) + assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun replacement_apply_final_cache_update_survives_cancellation() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = listOf(ChannelSettings(name = "Old Primary")) + val importedSettings = listOf(ChannelSettings(name = "Imported"), ChannelSettings(name = "Private")) + var delayCount = 0 + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + val applyJob = launch { + applyReplacementChannelSet( + channelSet = ChannelSet(settings = importedSettings), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = { + delayCount++ + if (delayCount == 8) { + currentCoroutineContext().cancel() + } + }, + ) + } + applyJob.join() + + assertTrue(applyJob.isCancelled) + assertEquals((0..7).toList(), radioController.localChannels.map { it.index }) + assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun replacement_apply_rejects_imported_settings_beyond_slot_count_before_writing() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = listOf(ChannelSettings(name = "Old")) + val oversizedSettings = (0..8).map { index -> ChannelSettings(name = "Imported $index") } + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + assertFailsWith { + applyReplacementChannelSet( + channelSet = ChannelSet(settings = oversizedSettings), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = {}, + ) + } + + assertTrue(radioController.localChannels.isEmpty()) + assertEquals(oldSettings, radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun replacement_apply_ignores_cached_settings_beyond_slot_count() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + val oldSettings = (0..9).map { index -> ChannelSettings(name = "Old $index") } + val importedSettings = listOf(ChannelSettings(name = "Imported")) + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings)) + + applyReplacementChannelSet( + channelSet = ChannelSet(settings = importedSettings), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = {}, + ) + + assertEquals((0..7).toList(), radioController.localChannels.map { it.index }) + assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun replacement_apply_normalizes_oversized_raw_import_under_limit_before_writing() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + radioConfigRepository.setChannelSet(ChannelSet(settings = listOf(ChannelSettings(name = "Old")))) + // 9 raw entries: 7 unique valid secondaries + 2 blank placeholders -> normalizes to 7 (under the 8-slot limit). + val ch0 = ChannelSettings(name = "Ch0", psk = byteArrayOf(1).toByteString()) + val ch1 = ChannelSettings(name = "Ch1", psk = byteArrayOf(2).toByteString()) + val ch2 = ChannelSettings(name = "Ch2", psk = byteArrayOf(3).toByteString()) + val ch3 = ChannelSettings(name = "Ch3", psk = byteArrayOf(4).toByteString()) + val ch4 = ChannelSettings(name = "Ch4", psk = byteArrayOf(5).toByteString()) + val ch5 = ChannelSettings(name = "Ch5", psk = byteArrayOf(6).toByteString()) + val ch6 = ChannelSettings(name = "Ch6", psk = byteArrayOf(7).toByteString()) + val raw = listOf(ch0, ch1, ChannelSettings(), ch2, ch3, ChannelSettings(), ch4, ch5, ch6) + + applyReplacementChannelSet( + channelSet = ChannelSet(settings = raw), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = {}, + ) + + // Cache holds the normalized 7-entry set, not the raw 9-entry import. + assertEquals(listOf(ch0, ch1, ch2, ch3, ch4, ch5, ch6), radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun replacement_apply_rejects_settings_still_oversized_after_normalization_drops_placeholders() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + radioConfigRepository.setChannelSet(ChannelSet(settings = listOf(ChannelSettings(name = "Old")))) + // 10 raw entries: 9 genuinely unique + 1 blank placeholder. Normalization drops the blank + // (-> 9) but the result still exceeds the 8-slot limit, so the post-normalize bounds check + // must reject before any write or cache mutation. + val unique = (1..9).map { ChannelSettings(name = "Ch$it", psk = byteArrayOf(it.toByte(), 0).toByteString()) } + + assertFailsWith { + applyReplacementChannelSet( + channelSet = ChannelSet(settings = unique + ChannelSettings()), + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = {}, + ) + } + + assertTrue(radioController.localChannels.isEmpty()) + } + + @Test + fun replacement_apply_uses_current_local_lora_preset_when_imported_lora_is_absent() = runTest { + val radioController = FakeRadioController() + val radioConfigRepository = FakeRadioConfigRepository() + // Device is on MEDIUM_FAST. The import omits lora_config, so identity resolution must fall + // back to the device's current preset to detect this duplicate. + radioConfigRepository.setLocalConfigDirect( + LocalConfig( + lora = Config.LoRaConfig(use_preset = true, modem_preset = Config.LoRaConfig.ModemPreset.MEDIUM_FAST), + ), + ) + // Primary carries an explicit preset name; the secondary has an empty name that resolves to + // the preset display name. Under MEDIUM_FAST the secondary resolves to "MediumFast" and + // duplicates the primary. Under a default/LongFast fallback it would resolve to "LongFast" + // and survive — so asserting the secondary is dropped proves the current-local preset was + // used for identity (a regression to Config.LoRaConfig() would fail this test). + val psk = byteArrayOf(1, 2, 3).toByteString() + val primary = ChannelSettings(name = "MediumFast", psk = psk) + val unnamedSecondary = ChannelSettings(psk = psk) + + val currentLoraConfig = + applyReplacementChannelSet( + channelSet = ChannelSet(settings = listOf(primary, unnamedSecondary)), // no lora_config + radioController = radioController, + radioConfigRepository = radioConfigRepository, + writeDelay = 1.seconds, + delayFn = {}, + ) + + // Secondary dropped as a semantic duplicate of the primary under the device's MEDIUM_FAST preset. + assertEquals(radioConfigRepository.currentLocalConfig.lora, currentLoraConfig) + assertEquals(listOf(primary), radioConfigRepository.currentChannelSet.settings) + } + + @Test + fun imported_lora_config_settles_before_and_after_write() = runTest { + val radioController = FakeRadioController() + val current = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.EU_868) + val imported = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US) + val delays = mutableListOf() + + applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig = imported, + currentLoraConfig = current, + radioController = radioController, + settleDelay = 2.seconds, + delayFn = { delays.add(it) }, + ) + + assertEquals(listOf(2.seconds, 2.seconds), delays) + assertEquals(listOf(Config(lora = imported)), radioController.localConfigs) + } + + @Test + fun imported_lora_config_is_not_written_when_absent_or_unchanged() = runTest { + val radioController = FakeRadioController() + val current = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US) + val delays = mutableListOf() + + applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig = null, + currentLoraConfig = current, + radioController = radioController, + delayFn = { delays.add(it) }, + ) + applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig = current, + currentLoraConfig = current, + radioController = radioController, + delayFn = { delays.add(it) }, + ) + + assertTrue(delays.isEmpty()) + assertTrue(radioController.localConfigs.isEmpty()) + } + // --- getChannelPreviewForAdd tests --- @Test @@ -344,4 +671,127 @@ class ProtoExtensionsTest { assertTrue(preview.settings.isEmpty()) assertTrue(preview.selections.isEmpty()) } + + // --- normalizeReplacementSettings tests --- + + @Test + fun normalize_empty_list_passes_through() { + assertEquals(emptyList(), normalizeReplacementSettings(emptyList(), ModelChannel.default.loraConfig)) + } + + @Test + fun normalize_single_element_passes_through() { + val primary = ChannelSettings(name = "Solo", psk = byteArrayOf(1).toByteString()) + + assertEquals(listOf(primary), normalizeReplacementSettings(listOf(primary), ModelChannel.default.loraConfig)) + } + + @Test + fun normalize_drops_blank_placeholder_secondary() { + val primary = ChannelSettings(name = "Main", psk = byteArrayOf(1, 2).toByteString()) + val real = ChannelSettings(name = "Chat", psk = byteArrayOf(3).toByteString()) + + val result = + normalizeReplacementSettings(listOf(primary, ChannelSettings(), real), ModelChannel.default.loraConfig) + + assertEquals(listOf(primary, real), result) + } + + @Test + fun normalize_preserves_blank_primary() { + val blankPrimary = ChannelSettings() + val real = ChannelSettings(name = "Chat", psk = byteArrayOf(3).toByteString()) + + // Slot 0 is always preserved, even when blank (deliberate disable signal). + val result = normalizeReplacementSettings(listOf(blankPrimary, real), ModelChannel.default.loraConfig) + + assertEquals(2, result.size) + assertEquals(blankPrimary, result[0]) + assertEquals(real, result[1]) + } + + @Test + fun normalize_blank_primary_does_not_seed_duplicate_tracking() { + val blankPrimary = ChannelSettings() + val publicSecondary = ChannelSettings(psk = byteArrayOf(1).toByteString()) + + val result = + normalizeReplacementSettings(listOf(blankPrimary, publicSecondary), ModelChannel.default.loraConfig) + + assertEquals(listOf(blankPrimary, publicSecondary), result) + } + + @Test + fun normalize_drops_semantic_duplicate_secondary() { + val primary = ChannelSettings(name = "Main", psk = byteArrayOf(1).toByteString()) + val dup = ChannelSettings(name = "Main", psk = byteArrayOf(1).toByteString()) + + val result = normalizeReplacementSettings(listOf(primary, dup), ModelChannel.default.loraConfig) + + assertEquals(listOf(primary), result) + } + + @Test + fun normalize_keeps_same_name_different_psk() { + val primary = ChannelSettings(name = "A", psk = byteArrayOf(1).toByteString()) + val other = ChannelSettings(name = "A", psk = byteArrayOf(2).toByteString()) + + val result = normalizeReplacementSettings(listOf(primary, other), ModelChannel.default.loraConfig) + + assertEquals(listOf(primary, other), result) + } + + @Test + fun normalize_keeps_same_psk_different_name() { + val psk = byteArrayOf(1, 2).toByteString() + val primary = ChannelSettings(name = "A", psk = psk) + val other = ChannelSettings(name = "B", psk = psk) + + val result = normalizeReplacementSettings(listOf(primary, other), ModelChannel.default.loraConfig) + + assertEquals(listOf(primary, other), result) + } + + @Test + fun normalize_compacts_valid_secondaries_into_sequential_slots() { + val primary = ChannelSettings(name = "Main", psk = byteArrayOf(1).toByteString()) + val b = ChannelSettings(name = "B", psk = byteArrayOf(2).toByteString()) + val c = ChannelSettings(name = "C", psk = byteArrayOf(3).toByteString()) + + // blank + duplicate mixed in; valid B and C must compact to slots 1 and 2 with no gap + val result = + normalizeReplacementSettings( + listOf( + primary, + ChannelSettings(), + b, + ChannelSettings(name = "Main", psk = byteArrayOf(1).toByteString()), + c, + ), + ModelChannel.default.loraConfig, + ) + + assertEquals(listOf(primary, b, c), result) + } + + @Test + fun normalize_null_lora_falls_back_to_defaults_without_crashing() { + val primary = ChannelSettings(name = "Main", psk = byteArrayOf(1).toByteString()) + + val result = normalizeReplacementSettings(listOf(primary, ChannelSettings()), loraConfig = null) + + assertEquals(listOf(primary), result) + } + + @Test + fun normalize_all_blank_input_preserves_only_primary() { + // Primary is always preserved (even blank); both blank placeholder secondaries are dropped. + val result = + normalizeReplacementSettings( + listOf(ChannelSettings(), ChannelSettings(), ChannelSettings()), + ModelChannel.default.loraConfig, + ) + + assertEquals(1, result.size) + } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index d3fa5026f..7b9536450 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController +import org.meshtastic.core.ui.util.applyImportedLoraConfigAfterChannelReplacement import org.meshtastic.core.ui.util.applyReplacementChannelSet import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @@ -85,12 +86,12 @@ class ChannelViewModel( /** Set the radio config (also updates our saved copy in preferences). */ fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { - applyReplacementChannelSet(channelSet, radioController, radioConfigRepository) - - val newLoraConfig = channelSet.lora_config - if (localConfig.value.lora != newLoraConfig) { - setConfig(Config(lora = newLoraConfig)) - } + val currentLoraConfig = applyReplacementChannelSet(channelSet, radioController, radioConfigRepository) + applyImportedLoraConfigAfterChannelReplacement( + importedLoraConfig = channelSet.lora_config, + currentLoraConfig = currentLoraConfig, + radioController = radioController, + ) } // Set the radio config (also updates our saved copy in preferences)