mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-28 23:45:43 -04:00
fix(qr): Serialize channel import writes (#5999)
This commit is contained in:
@@ -20,10 +20,9 @@ import androidx.lifecycle.ViewModel
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.ui.util.getChannelList
|
||||
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.Channel
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
@@ -40,8 +39,7 @@ class ScannedQrCodeViewModel(
|
||||
|
||||
/** Set the radio config (also updates our saved copy in preferences). */
|
||||
fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
|
||||
getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel)
|
||||
radioConfigRepository.replaceAllSettings(channelSet.settings)
|
||||
applyReplacementChannelSet(channelSet, radioController, radioConfigRepository)
|
||||
|
||||
val loraConfig = channelSet.lora_config
|
||||
if (loraConfig != null && localConfig.value.lora != loraConfig) {
|
||||
@@ -49,10 +47,6 @@ class ScannedQrCodeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setChannel(channel: Channel) {
|
||||
safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) }
|
||||
}
|
||||
|
||||
// Set the radio config (also updates our saved copy in preferences)
|
||||
private fun setConfig(config: Config) {
|
||||
safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) }
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.unknown_age
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Position
|
||||
@@ -79,3 +83,74 @@ fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an authoritative [Channel] list for a full REPLACE import. Every position in [new] is emitted (PRIMARY at
|
||||
* index 0, SECONDARY for 1..new.lastIndex) and any trailing positions beyond [new]'s range are emitted as DISABLED so
|
||||
* the radio stops using them.
|
||||
*
|
||||
* Unlike [getChannelList], this does NOT skip positions where `currentSettings[i] == new[i]`: the imported set is
|
||||
* authoritative, the local cache must not gate the writes, and silent diff-skips during REPLACE were the source of
|
||||
* stale channels.
|
||||
*
|
||||
* [currentSettings] is consulted only for its size (to determine trailing DISABLED writes); its values are never
|
||||
* compared against [new]. Callers should read it from `radioConfigRepository.channelSetFlow.first().settings`, not from
|
||||
* a `stateInWhileSubscribed` StateFlow's `.value` — the StateFlow placeholder window can return an empty list and
|
||||
* suppress trailing DISABLED writes.
|
||||
*
|
||||
* Edge case: if [new] is empty, every emitted slot (including index 0) is DISABLED rather than wrongly promoting an
|
||||
* empty [ChannelSettings] to PRIMARY.
|
||||
*
|
||||
* @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.
|
||||
* @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
|
||||
|
||||
in 1..new.lastIndex -> Channel.Role.SECONDARY
|
||||
|
||||
else -> Channel.Role.DISABLED
|
||||
},
|
||||
index = i,
|
||||
settings = new.getOrNull(i) ?: ChannelSettings(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
radioConfigRepository.replaceAllSettings(channelSet.settings)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Coverage for [getChannelReplacementList]. The REPLACE helper must emit an authoritative slot list for QR imports:
|
||||
* every imported index becomes a write (PRIMARY at 0, SECONDARY thereafter), and any trailing slots present in the
|
||||
* cached set are emitted as DISABLED so the radio stops using them. Critically, positions where the cache already
|
||||
* matches the import are NOT skipped — the diff-skip was the source of stale channels.
|
||||
*/
|
||||
class ProtoExtensionsTest {
|
||||
@Test
|
||||
fun index_zero_emits_primary_with_new_settings_even_when_unchanged_from_old() {
|
||||
val same = ChannelSettings(name = "Main", psk = byteArrayOf(1, 2, 3).toByteString())
|
||||
|
||||
val result = getChannelReplacementList(new = listOf(same), currentSettings = listOf(same))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(Channel.Role.PRIMARY, result.single().role)
|
||||
assertEquals(0, result.single().index)
|
||||
assertEquals(same, result.single().settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondary_indices_emit_secondary_with_new_settings_even_when_unchanged_from_old() {
|
||||
val primary = ChannelSettings(name = "Main")
|
||||
val secondary = ChannelSettings(name = "Chat")
|
||||
|
||||
val result =
|
||||
getChannelReplacementList(new = listOf(primary, secondary), currentSettings = listOf(primary, secondary))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(Channel.Role.PRIMARY, result[0].role)
|
||||
assertEquals(primary, result[0].settings)
|
||||
assertEquals(Channel.Role.SECONDARY, result[1].role)
|
||||
assertEquals(1, result[1].index)
|
||||
assertEquals(secondary, result[1].settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun old_trailing_indices_beyond_new_are_emitted_as_disabled_with_empty_settings() {
|
||||
val primary = ChannelSettings(name = "Main")
|
||||
|
||||
val result =
|
||||
getChannelReplacementList(
|
||||
new = listOf(primary),
|
||||
currentSettings = listOf(primary, ChannelSettings(name = "Old")),
|
||||
)
|
||||
|
||||
// index 0 PRIMARY (new), index 1 DISABLED (trailing old slot)
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(Channel.Role.PRIMARY, result[0].role)
|
||||
assertEquals(primary, result[0].settings)
|
||||
assertEquals(Channel.Role.DISABLED, result[1].role)
|
||||
assertEquals(1, result[1].index)
|
||||
assertEquals(ChannelSettings(), result[1].settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_new_and_empty_old_produces_empty_list() {
|
||||
val result = getChannelReplacementList(new = emptyList(), currentSettings = emptyList())
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_new_with_non_empty_current_emits_disabled_for_every_current_index() {
|
||||
val currentSettings =
|
||||
listOf(ChannelSettings(name = "A"), ChannelSettings(name = "B"), ChannelSettings(name = "C"))
|
||||
|
||||
val result = getChannelReplacementList(new = emptyList(), currentSettings = currentSettings)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
result.forEachIndexed { i, channel ->
|
||||
assertEquals(Channel.Role.DISABLED, channel.role, "index $i should be DISABLED")
|
||||
assertEquals(i, channel.index)
|
||||
assertEquals(ChannelSettings(), channel.settings, "index $i should carry empty settings")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun single_entry_new_with_multi_entry_current_emits_primary_then_disabled_trailing() {
|
||||
val newPrimary = ChannelSettings(name = "Imported")
|
||||
val currentSettings =
|
||||
listOf(
|
||||
ChannelSettings(name = "CurrentPrimary"),
|
||||
ChannelSettings(name = "CurrentSecondary"),
|
||||
ChannelSettings(name = "CurrentTertiary"),
|
||||
)
|
||||
|
||||
val result = getChannelReplacementList(new = listOf(newPrimary), currentSettings = currentSettings)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(Channel.Role.PRIMARY, result[0].role)
|
||||
assertEquals(0, result[0].index)
|
||||
assertEquals(newPrimary, result[0].settings)
|
||||
assertEquals(Channel.Role.DISABLED, result[1].role)
|
||||
assertEquals(Channel.Role.DISABLED, result[2].role)
|
||||
assertEquals(ChannelSettings(), result[1].settings)
|
||||
assertEquals(ChannelSettings(), result[2].settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun new_larger_than_old_emits_primary_plus_secondaries_for_every_new_index() {
|
||||
val primary = ChannelSettings(name = "Main")
|
||||
val secondaryA = ChannelSettings(name = "Chat")
|
||||
val secondaryB = ChannelSettings(name = "Data")
|
||||
|
||||
val result =
|
||||
getChannelReplacementList(new = listOf(primary, secondaryA, secondaryB), currentSettings = listOf(primary))
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(Channel.Role.PRIMARY, result[0].role)
|
||||
assertEquals(0, result[0].index)
|
||||
assertEquals(primary, result[0].settings)
|
||||
assertEquals(Channel.Role.SECONDARY, result[1].role)
|
||||
assertEquals(1, result[1].index)
|
||||
assertEquals(secondaryA, result[1].settings)
|
||||
assertEquals(Channel.Role.SECONDARY, result[2].role)
|
||||
assertEquals(2, result[2].index)
|
||||
assertEquals(secondaryB, result[2].settings)
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,9 @@ 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.getChannelList
|
||||
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.Channel
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
@@ -86,8 +85,7 @@ class ChannelViewModel(
|
||||
|
||||
/** Set the radio config (also updates our saved copy in preferences). */
|
||||
fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
|
||||
getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel)
|
||||
radioConfigRepository.replaceAllSettings(channelSet.settings)
|
||||
applyReplacementChannelSet(channelSet, radioController, radioConfigRepository)
|
||||
|
||||
val newLoraConfig = channelSet.lora_config
|
||||
if (localConfig.value.lora != newLoraConfig) {
|
||||
@@ -95,10 +93,6 @@ class ChannelViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setChannel(channel: Channel) {
|
||||
safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) }
|
||||
}
|
||||
|
||||
// Set the radio config (also updates our saved copy in preferences)
|
||||
fun setConfig(config: Config) {
|
||||
safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) }
|
||||
|
||||
Reference in New Issue
Block a user