fix(qr): Apply channel replacements reliably (#6072)

This commit is contained in:
Jeremiah K
2026-07-02 13:31:32 -05:00
committed by GitHub
parent bacf6be018
commit 55279d4800
5 changed files with 664 additions and 54 deletions

View File

@@ -53,6 +53,9 @@ class FakeRadioController :
val lastLocalConfig: Config?
get() = localConfigs.lastOrNull()
/** Every [setLocalChannel] call, in order. */
val localChannels = mutableListOf<Channel>()
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

View File

@@ -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,
)
}
}

View File

@@ -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<ChannelSettings>, old: List<ChannelSettings>): 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<ChannelSettings>, currentSettings: List<ChannelSettings>): List<Channel> =
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<ChannelSettings>,
currentSettings: List<ChannelSettings>,
minimumSlotCount: Int = 0,
maximumSlotCount: Int = Int.MAX_VALUE,
): List<Channel> = 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<ChannelSettings>,
loraConfig: Config.LoRaConfig?,
): List<ChannelSettings> {
if (settings.size <= 1) return settings
val effectiveLora = loraConfig ?: Config.LoRaConfig()
val primary = settings.first()
val seen = mutableSetOf<ChannelIdentity>()
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<ChannelSettings>,
normalizedSettings: List<ChannelSettings>,
) {
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=<redacted>)"
}
/** 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)

View File

@@ -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<IllegalArgumentException> {
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<List<ChannelSettings>>()
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<IllegalStateException> {
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<IllegalStateException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<Duration>()
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<Duration>()
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)
}
}

View File

@@ -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)