mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-03 01:45:36 -04:00
fix(qr): Apply channel replacements reliably (#6072)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user