diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt index d47fc4ed71..c927871ccd 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt @@ -65,7 +65,14 @@ class SwitchingChannelSetDataSource( /** Replaces all [ChannelSettings] in a single atomic operation. */ suspend fun replaceAllSettings(settingsList: List) { - mutate { it.copy(settings = settingsList) } + updateChannelSet(settingsList = settingsList, loraConfig = null) + } + + /** Atomically updates supplied [ChannelSet] fields while preserving fields omitted by the caller. */ + suspend fun updateChannelSet(settingsList: List?, loraConfig: Config.LoRaConfig?) { + mutate { current -> + current.copy(settings = settingsList ?: current.settings, lora_config = loraConfig ?: current.lora_config) + } } /** Places [channel]'s settings at its index, resizing with blank channels to fill any gap (parity with legacy). */ @@ -82,7 +89,7 @@ class SwitchingChannelSetDataSource( } suspend fun setLoraConfig(config: Config.LoRaConfig) { - mutate { it.copy(lora_config = config) } + updateChannelSet(settingsList = null, loraConfig = config) } private suspend fun mutate(transform: (ChannelSet) -> ChannelSet) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt index e34a235a43..a32d21b6b7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt @@ -69,6 +69,10 @@ open class RadioConfigRepositoryImpl( channelSetDataSource.replaceAllSettings(settingsList) } + override suspend fun updateChannelSet(settingsList: List?, loraConfig: Config.LoRaConfig?) { + channelSetDataSource.updateChannelSet(settingsList, loraConfig) + } + /** * Updates the [ChannelSettings] list with the provided channel and returns the index of the admin channel after the * update (if not found, returns 0). diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt index 87a8665dec..88eb0a8d2d 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.test.runTest import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.testing.FakeDatabaseProvider import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config import kotlin.test.AfterTest @@ -75,9 +76,23 @@ class SwitchingChannelSetDataSourceTest { @Test fun `replaceAllSettings replaces the whole list`() = runTest(testDispatcher) { dataSource.updateChannelSettings(secondary(0, "old")) + val originalLora = Config.LoRaConfig(channel_num = 4) + dataSource.setLoraConfig(originalLora) dataSource.replaceAllSettings(listOf(ChannelSettings(name = "a"), ChannelSettings(name = "b"))) - assertEquals(listOf("a", "b"), dataSource.channelSetFlow.first().settings.map { it.name }) + val set = dataSource.channelSetFlow.first() + assertEquals(listOf("a", "b"), set.settings.map { it.name }) + assertEquals(originalLora, set.lora_config) + } + + @Test + fun `updateChannelSet atomically replaces settings and lora`() = runTest(testDispatcher) { + val settings = listOf(ChannelSettings(name = "new")) + val lora = Config.LoRaConfig(channel_num = 7) + + dataSource.updateChannelSet(settingsList = settings, loraConfig = lora) + + assertEquals(ChannelSet(settings = settings, lora_config = lora), dataSource.channelSetFlow.first()) } @Test diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt index 8c63ebd2b5..7d026ead2e 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt @@ -16,10 +16,19 @@ */ package org.meshtastic.core.domain.usecase.settings +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.Position +import org.meshtastic.core.model.util.ChannelReplacementPlan +import org.meshtastic.core.model.util.MalformedMeshtasticUrlException +import org.meshtastic.core.model.util.toChannelReplacementPlan +import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.repository.AdminEditScope +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController +import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.LocalConfig @@ -29,7 +38,11 @@ import org.meshtastic.proto.User /** Use case for installing a device profile onto a radio. */ @Single -open class InstallProfileUseCase constructor(private val radioController: RadioController) { +open class InstallProfileUseCase +constructor( + private val radioController: RadioController, + private val radioConfigRepository: RadioConfigRepository, +) { /** * Installs the provided [DeviceProfile] onto the radio at [destNum]. * @@ -37,13 +50,52 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC * @param profile The device profile to install. * @param currentUser The current user configuration of the destination node (to preserve names if not in profile). */ - open suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) { + open suspend operator fun invoke( + destNum: Int, + profile: DeviceProfile, + currentUser: User?, + currentLoraConfig: Config.LoRaConfig?, + isLocal: Boolean, + ) { + // Decode and validate before opening the radio transaction. A malformed channel URL must not leave an edit + // session open or allow the rest of the profile to be partially applied. + val channelSet = profile.channel_url?.takeIf { it.isNotBlank() }?.let(::parseProfileChannelSet) + val desiredLoraConfig = channelSet?.lora_config ?: profile.config?.lora + val replacementPlan = + channelSet?.let { + try { + it.toChannelReplacementPlan( + currentSettings = emptyList(), + fallbackLoraConfig = profile.config?.lora ?: currentLoraConfig, + requirePrimary = true, + ) + } catch (e: IllegalArgumentException) { + throw MalformedMeshtasticUrlException("Invalid channel set in device profile", e) + } + } + val loraConfigToWrite = desiredLoraConfig?.takeIf { it != currentLoraConfig } + radioController.editSettings(destNum) { installOwner(profile, currentUser) installConfig(profile.config) installFixedPosition(profile.fixed_position) installModuleConfig(profile.module_config) + installChannelsAndLora(replacementPlan, loraConfigToWrite) } + if (isLocal && (replacementPlan != null || loraConfigToWrite != null)) { + withContext(NonCancellable) { + radioConfigRepository.updateChannelSet( + settingsList = replacementPlan?.normalizedSettings, + loraConfig = loraConfigToWrite, + ) + } + } + } + + private fun parseProfileChannelSet(url: String): ChannelSet = try { + CommonUri.parse(url).toChannelSet() + } catch (e: IllegalArgumentException) { + throw MalformedMeshtasticUrlException("Invalid channel URL in device profile", e) } // is_licensed is deliberately not installed here: enabling ham mode is a dedicated onboarding flow @@ -70,7 +122,6 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC lc.power?.let { setConfig(Config(power = it)) } lc.network?.let { setConfig(Config(network = it)) } lc.display?.let { setConfig(Config(display = it)) } - lc.lora?.let { setConfig(Config(lora = it)) } lc.bluetooth?.let { setConfig(Config(bluetooth = it)) } lc.security?.let { setConfig(Config(security = it)) } } @@ -109,4 +160,12 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) } lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) } } + + private suspend fun AdminEditScope.installChannelsAndLora( + replacementPlan: ChannelReplacementPlan?, + loraConfig: Config.LoRaConfig?, + ) { + replacementPlan?.channelWrites?.forEach { setChannel(it) } + loraConfig?.let { setConfig(Config(lora = it)) } + } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt index 09a193c229..1fcfcb2e92 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt @@ -17,7 +17,16 @@ package org.meshtastic.core.domain.usecase.settings import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.log.expectedConditionLabel +import org.meshtastic.core.model.util.MalformedMeshtasticUrlException +import org.meshtastic.core.model.util.getChannelUrl +import org.meshtastic.core.testing.FakeRadioConfigRepository import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeRadioController.SettingsOperation +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config import org.meshtastic.proto.Config.BluetoothConfig import org.meshtastic.proto.Config.DeviceConfig import org.meshtastic.proto.Config.DisplayConfig @@ -46,22 +55,26 @@ import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class InstallProfileUseCaseTest { private lateinit var radioController: FakeRadioController + private lateinit var radioConfigRepository: FakeRadioConfigRepository private lateinit var useCase: InstallProfileUseCase @BeforeTest fun setUp() { radioController = FakeRadioController() - useCase = InstallProfileUseCase(radioController) + radioConfigRepository = FakeRadioConfigRepository() + useCase = InstallProfileUseCase(radioController, radioConfigRepository) } @Test fun `invoke calls begin and commit edit settings`() = runTest { - useCase(1234, DeviceProfile(), User()) + useCase(1234, DeviceProfile(), User(), currentLoraConfig = null, isLocal = false) assertTrue(radioController.editSettingsCalled) } @@ -104,7 +117,7 @@ class InstallProfileUseCaseTest { fixed_position = org.meshtastic.proto.Position(), ) - useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old")) + useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old"), currentLoraConfig = null, isLocal = false) assertTrue(radioController.editSettingsCalled) } @@ -113,9 +126,216 @@ class InstallProfileUseCaseTest { fun `invoke installs is_unmessagable but never auto-installs is_licensed`() = runTest { val profile = DeviceProfile(is_unmessagable = true, is_licensed = true) - useCase(1234, profile, User(long_name = "Old")) + useCase(1234, profile, User(long_name = "Old"), currentLoraConfig = null, isLocal = false) assertEquals(true, radioController.lastSetOwnerUser?.is_unmessagable) assertEquals(false, radioController.lastSetOwnerUser?.is_licensed) } + + @Test + fun `invoke normalizes channels refreshes local cache and writes URL LoRa once`() = runTest { + val primary = ChannelSettings(name = "Node A Primary") + val secondary = ChannelSettings(name = "Node A Secondary") + val urlLoraConfig = + LoRaConfig( + use_preset = true, + modem_preset = LoRaConfig.ModemPreset.MEDIUM_FAST, + region = LoRaConfig.RegionCode.US, + ) + val profileLoraConfig = + LoRaConfig( + use_preset = true, + modem_preset = LoRaConfig.ModemPreset.LONG_SLOW, + region = LoRaConfig.RegionCode.EU_868, + ) + val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ) + val oldSettings = listOf(ChannelSettings(name = "Node B Primary")) + val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.EU_433) + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings, lora_config = cachedLoraConfig)) + val exportedProfile = + DeviceProfile( + config = org.meshtastic.proto.LocalConfig(lora = profileLoraConfig), + channel_url = + ChannelSet( + settings = listOf(primary, ChannelSettings(), primary, secondary), + lora_config = urlLoraConfig, + ) + .getChannelUrl() + .toString(), + ) + + useCase( + 4321, + exportedProfile, + User(long_name = "Node B"), + currentLoraConfig = currentLoraConfig, + isLocal = true, + ) + + assertTrue(radioController.editSettingsCalled, "profile install transaction did not run") + assertEquals((0..7).toList(), radioController.localChannels.map(Channel::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(Channel::role), + ) + assertEquals(listOf(primary, secondary), radioController.localChannels.take(2).map(Channel::settings)) + assertEquals(listOf(primary, secondary), radioConfigRepository.currentChannelSet.settings) + assertEquals(urlLoraConfig, radioConfigRepository.currentChannelSet.lora_config) + assertEquals(listOf(Config(lora = urlLoraConfig)), radioController.localConfigs) + assertEquals( + radioController.localChannels.map { SettingsOperation.SetChannel(it) } + + SettingsOperation.SetConfig(Config(lora = urlLoraConfig)), + radioController.settingsOperations, + ) + } + + @Test + fun `invoke treats blank channel URL as absent and installs profile LoRa once`() = runTest { + val oldSettings = listOf(ChannelSettings(name = "Keep Me")) + val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.EU_868) + val profileLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.US) + val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ) + radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings, lora_config = cachedLoraConfig)) + val profile = + DeviceProfile(channel_url = " \t\n", config = org.meshtastic.proto.LocalConfig(lora = profileLoraConfig)) + + useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = currentLoraConfig, isLocal = true) + + assertTrue(radioController.editSettingsCalled) + assertTrue(radioController.localChannels.isEmpty()) + assertEquals(listOf(Config(lora = profileLoraConfig)), radioController.localConfigs) + assertEquals( + listOf(FakeRadioConfigRepository.ChannelSetUpdate(settingsList = null, loraConfig = profileLoraConfig)), + radioConfigRepository.channelSetUpdates, + ) + assertEquals(oldSettings, radioConfigRepository.currentChannelSet.settings) + assertEquals(profileLoraConfig, radioConfigRepository.currentChannelSet.lora_config) + } + + @Test + fun `invoke replaces local channels and preserves cached LoRa when no LoRa write is needed`() = runTest { + val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ) + val importedPrimary = ChannelSettings(name = "Imported Primary") + radioConfigRepository.setChannelSet( + ChannelSet(settings = listOf(ChannelSettings(name = "Old Primary")), lora_config = cachedLoraConfig), + ) + val profile = + DeviceProfile(channel_url = ChannelSet(settings = listOf(importedPrimary)).getChannelUrl().toString()) + + useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = cachedLoraConfig, isLocal = true) + + assertTrue(radioController.localConfigs.isEmpty()) + assertEquals(listOf(importedPrimary), radioConfigRepository.currentChannelSet.settings) + assertEquals(cachedLoraConfig, radioConfigRepository.currentChannelSet.lora_config) + } + + @Test + fun `invoke skips redundant LoRa write when desired config is already active`() = runTest { + val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.US) + val profile = DeviceProfile(config = org.meshtastic.proto.LocalConfig(lora = currentLoraConfig)) + + useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = currentLoraConfig, isLocal = false) + + assertTrue(radioController.localConfigs.isEmpty()) + } + + @Test + fun `invoke does not replace local cache for a remote profile install`() = runTest { + val oldSettings = listOf(ChannelSettings(name = "Local Primary")) + val remotePrimary = ChannelSettings(name = "Remote Primary") + val cachedChannelSet = + ChannelSet(settings = oldSettings, lora_config = LoRaConfig(region = LoRaConfig.RegionCode.ANZ)) + radioConfigRepository.setChannelSet(cachedChannelSet) + val profile = + DeviceProfile( + channel_url = + ChannelSet( + settings = listOf(remotePrimary), + lora_config = LoRaConfig(region = LoRaConfig.RegionCode.US), + ) + .getChannelUrl() + .toString(), + ) + + useCase(4321, profile, User(long_name = "Remote"), currentLoraConfig = null, isLocal = false) + + assertEquals(remotePrimary, radioController.localChannels.first().settings) + assertEquals(cachedChannelSet, radioConfigRepository.currentChannelSet) + } + + @Test + fun `invoke leaves local cache unchanged when a channel write fails`() = runTest { + val oldSettings = listOf(ChannelSettings(name = "Local Primary")) + val cachedChannelSet = + ChannelSet(settings = oldSettings, lora_config = LoRaConfig(region = LoRaConfig.RegionCode.ANZ)) + radioConfigRepository.setChannelSet(cachedChannelSet) + radioController.failChannelWriteAfter = 2 + val profile = + DeviceProfile( + channel_url = + ChannelSet( + settings = listOf(ChannelSettings(name = "Imported Primary")), + lora_config = LoRaConfig(region = LoRaConfig.RegionCode.US), + ) + .getChannelUrl() + .toString(), + ) + + assertFailsWith { + useCase(4321, profile, User(long_name = "Local"), currentLoraConfig = null, isLocal = true) + } + + assertEquals(cachedChannelSet, radioConfigRepository.currentChannelSet) + } + + @Test + fun `invoke rejects an empty channel set before opening the transaction`() = runTest { + val destinationPrimary = + Channel(role = Channel.Role.PRIMARY, index = 0, settings = ChannelSettings(name = "Node B Primary")) + radioController.localChannels.add(destinationPrimary) + val profile = + DeviceProfile( + long_name = "Must Not Apply", + channel_url = + ChannelSet( + settings = emptyList(), + lora_config = LoRaConfig(use_preset = true, region = LoRaConfig.RegionCode.US), + ) + .getChannelUrl() + .toString(), + ) + + assertFailsWith { + useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = null, isLocal = true) + } + + assertFalse(radioController.editSettingsCalled) + assertEquals(listOf(destinationPrimary), radioController.localChannels) + assertTrue(radioController.localConfigs.isEmpty()) + assertTrue(radioController.settingsOperations.isEmpty()) + } + + @Test + fun `invoke rejects a malformed channel URL before opening the transaction`() = runTest { + val profile = DeviceProfile(long_name = "Must Not Apply", channel_url = "https://example.com/not-a-channel") + + val error = + assertFailsWith { + useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = null, isLocal = true) + } + + assertFalse(radioController.editSettingsCalled) + assertTrue(radioController.localChannels.isEmpty()) + assertTrue(radioController.localConfigs.isEmpty()) + assertEquals("malformed-meshtastic-url", error.expectedConditionLabel()) + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index 4e8d0891ff..f479f3181c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("MagicNumber") +@file:Suppress("MagicNumber", "TooGenericExceptionCaught") package org.meshtastic.core.model.util @@ -36,6 +36,7 @@ import org.meshtastic.proto.ModuleSettings * * @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL */ +@Suppress("ThrowsCount") @Throws(MalformedMeshtasticUrlException::class) fun CommonUri.toChannelSet(): ChannelSet { val h = host ?: "" @@ -56,7 +57,12 @@ fun CommonUri.toChannelSet(): ChannelSet { val fragmentBase64 = fragment!!.substringBefore('?').replace('-', '+').replace('_', '/') val fragmentBytes = fragmentBase64.decodeBase64() ?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment") - val url = ChannelSet.ADAPTER.decode(fragmentBytes) + val url = + try { + ChannelSet.ADAPTER.decode(fragmentBytes) + } catch (e: Exception) { + throw MalformedMeshtasticUrlException("Failed to decode channel set: ${e::class.simpleName}", e) + } val shouldAdd = fragment?.substringAfter('?', "")?.addParameter() ?: getBooleanQueryParameter("add", false) return if (shouldAdd) url.copy(lora_config = null) else url diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt new file mode 100644 index 0000000000..c3442a8413 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt @@ -0,0 +1,141 @@ +/* + * 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 . + */ +package org.meshtastic.core.model.util + +import okio.ByteString +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.core.model.Channel as ModelChannel + +/** Firmware channel files expose eight slots: one primary plus up to seven secondary channels. */ +const val CHANNEL_REPLACEMENT_SLOT_COUNT = 8 + +/** A normalized authoritative channel set and the complete radio writes needed to materialize it. */ +data class ChannelReplacementPlan(val normalizedSettings: List, val channelWrites: List) + +/** + * Produces the canonical authoritative replacement plan used by every full channel-set import path. + * + * Normalization runs before the firmware-slot bound is checked, allowing padded exports to shed blank or duplicate + * secondaries before validation. [requirePrimary] lets profile installation reject an empty decoded set while the + * explicit QR replacement flow retains its existing ability to clear every slot. + * + * @param currentSettings The destination's current settings. Only the size is used when deciding which trailing slots + * must be disabled. + * @param fallbackLoraConfig LoRa config used for semantic identity when this channel set does not carry one. + * @param requirePrimary Whether an empty normalized set must be rejected. + */ +fun ChannelSet.toChannelReplacementPlan( + currentSettings: List, + fallbackLoraConfig: Config.LoRaConfig?, + requirePrimary: Boolean = false, +): ChannelReplacementPlan { + val normalizedSettings = normalizeReplacementSettings(settings, lora_config ?: fallbackLoraConfig) + require(!requirePrimary || normalizedSettings.isNotEmpty()) { + "Imported channel set must contain a primary channel" + } + require(normalizedSettings.size <= CHANNEL_REPLACEMENT_SLOT_COUNT) { + "Imported channel set exceeds supported channel slot count" + } + return ChannelReplacementPlan( + normalizedSettings = normalizedSettings, + channelWrites = + getChannelReplacementList( + new = normalizedSettings, + currentSettings = currentSettings, + minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, + maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, + ), + ) +} + +/** + * Builds an authoritative [Channel] list for a full replacement. Every position in [new] is emitted, and any trailing + * positions beyond [new]'s range are emitted as disabled so the radio stops using them. + * + * Unlike a diff, this never skips settings already present in [currentSettings]. The imported set is authoritative, and + * stale local cache entries must not suppress a radio write. + */ +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 (index in 0..endIndex) { + add( + Channel( + role = + when (index) { + 0 -> if (new.isEmpty()) Channel.Role.DISABLED else Channel.Role.PRIMARY + in 1..new.lastIndex -> Channel.Role.SECONDARY + else -> Channel.Role.DISABLED + }, + index = index, + settings = new.getOrNull(index) ?: ChannelSettings(), + ), + ) + } +} + +/** + * Normalizes replacement settings so firmware only materializes real, distinct channels. + * + * Slot zero is preserved as-is. Blank placeholder secondaries and semantic duplicates under [loraConfig] are removed, + * and the remaining secondaries compact into sequential slots. + */ +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.isChannelPlaceholder()) { + seen.add(primary.channelIdentity(effectiveLora)) + } + val compact = mutableListOf(primary) + for (index in 1..settings.lastIndex) { + val candidate = settings[index] + val identity = if (candidate.isChannelPlaceholder()) null else candidate.channelIdentity(effectiveLora) + if (identity != null && seen.add(identity)) compact.add(candidate) + } + return compact +} + +/** True when these settings carry no name and no PSK, making them padding rather than an intended channel. */ +fun ChannelSettings.isChannelPlaceholder(): Boolean = name.isNullOrBlank() && psk.size == 0 + +/** Semantic channel identity based on the effective name and effective PSK. */ +data class ChannelIdentity(val name: String, val psk: ByteString) { + // Never expose an effective PSK through diagnostics or an auto-generated data-class toString. + override fun toString(): String = "ChannelIdentity(name=$name, psk=)" +} + +/** Resolves this setting's semantic identity under [loraConfig]. */ +fun ChannelSettings.channelIdentity(loraConfig: Config.LoRaConfig): ChannelIdentity { + val channel = ModelChannel(settings = this, loraConfig = loraConfig) + return ChannelIdentity(name = channel.name, psk = channel.psk) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt index 56f74c146b..040611ad7b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt @@ -16,5 +16,11 @@ */ package org.meshtastic.core.model.util +import org.meshtastic.core.common.log.ExpectedCondition + /** Exception thrown when a Meshtastic URL cannot be parsed. */ -class MalformedMeshtasticUrlException(message: String) : Exception(message) +class MalformedMeshtasticUrlException(message: String, cause: Throwable? = null) : + Exception(message, cause), + ExpectedCondition { + override val expectedConditionLabel: String = "malformed-meshtastic-url" +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt new file mode 100644 index 0000000000..c270f9f740 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ +package org.meshtastic.core.model.util + +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ChannelSetReplacementTest { + + @Test + fun `replacement plan normalizes padding and duplicates before slot validation`() { + val primary = ChannelSettings(name = "Primary") + val uniqueSecondaries = (1..7).map { ChannelSettings(name = "Secondary $it") } + val rawSettings = listOf(primary, ChannelSettings(), primary) + uniqueSecondaries + + val plan = + ChannelSet(settings = rawSettings) + .toChannelReplacementPlan( + currentSettings = emptyList(), + fallbackLoraConfig = null, + requirePrimary = true, + ) + + assertEquals(listOf(primary) + uniqueSecondaries, plan.normalizedSettings) + assertEquals((0..7).toList(), plan.channelWrites.map(Channel::index)) + assertEquals(Channel.Role.PRIMARY, plan.channelWrites.first().role) + assertEquals(List(7) { Channel.Role.SECONDARY }, plan.channelWrites.drop(1).map(Channel::role)) + } + + @Test + fun `replacement plan rejects empty profile channel set before producing writes`() { + assertFailsWith { + ChannelSet(settings = emptyList()) + .toChannelReplacementPlan( + currentSettings = listOf(ChannelSettings(name = "Existing")), + fallbackLoraConfig = null, + requirePrimary = true, + ) + } + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt index 67be2a8230..bff29300fb 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt @@ -40,6 +40,14 @@ interface RadioConfigRepository { /** Replaces the [ChannelSettings] list with a new [settingsList]. */ suspend fun replaceAllSettings(settingsList: List) + /** + * Atomically updates the cached channel set. A null argument preserves that field's current value. + * + * This is used after a committed radio transaction when channel settings and LoRa configuration must become visible + * together. + */ + suspend fun updateChannelSet(settingsList: List?, loraConfig: Config.LoRaConfig?) + /** Updates the [ChannelSettings] list with the provided channel. */ suspend fun updateChannelSettings(channel: Channel) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt index 7550d3dbb8..c8197efefa 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt @@ -41,6 +41,8 @@ class FakeRadioConfigRepository : BaseFake(), RadioConfigRepository { + data class ChannelSetUpdate(val settingsList: List?, val loraConfig: Config.LoRaConfig?) + private val channelSetBacking = mutableStateFlow(ChannelSet()) override val channelSetFlow: Flow = channelSetBacking @@ -93,10 +95,14 @@ class FakeRadioConfigRepository : var lastSetModuleConfig: ModuleConfig? = null private set + /** Arguments supplied to [updateChannelSet], in call order. */ + val channelSetUpdates = mutableListOf() + init { registerResetAction { lastSetLocalConfig = null lastSetModuleConfig = null + channelSetUpdates.clear() } } @@ -105,7 +111,14 @@ class FakeRadioConfigRepository : } override suspend fun replaceAllSettings(settingsList: List) { - channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) + updateChannelSet(settingsList = settingsList, loraConfig = null) + } + + override suspend fun updateChannelSet(settingsList: List?, loraConfig: Config.LoRaConfig?) { + channelSetUpdates += ChannelSetUpdate(settingsList, loraConfig) + val current = channelSetBacking.value + channelSetBacking.value = + current.copy(settings = settingsList ?: current.settings, lora_config = loraConfig ?: current.lora_config) } override suspend fun updateChannelSettings(channel: Channel) { 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 6668f94655..8c97f71746 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 @@ -37,6 +37,12 @@ class FakeRadioController : BaseFake(), RadioController { + sealed interface SettingsOperation { + data class SetConfig(val config: Config) : SettingsOperation + + data class SetChannel(val channel: Channel) : SettingsOperation + } + /** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */ private val _connectionState = mutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState @@ -56,6 +62,9 @@ class FakeRadioController : /** Every [setLocalChannel] call, in order. */ val localChannels = mutableListOf() + /** Every config and channel write, in their shared call order. */ + val settingsOperations = mutableListOf() + var throwOnSend: Boolean = false /** When true, [setLocalConfig] throws — simulates the radio link dropping mid config write. */ @@ -86,6 +95,7 @@ class FakeRadioController : sentSharedContacts.clear() localConfigs.clear() localChannels.clear() + settingsOperations.clear() throwOnSend = false throwOnSetLocalConfig = false failChannelWriteAfter = null @@ -130,10 +140,12 @@ class FakeRadioController : override suspend fun setLocalConfig(config: Config) { if (throwOnSetLocalConfig) error("Fake local config write failure") localConfigs.add(config) + settingsOperations.add(SettingsOperation.SetConfig(config)) } override suspend fun setLocalChannel(channel: Channel) { localChannels.add(channel) + settingsOperations.add(SettingsOperation.SetChannel(channel)) } override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { @@ -144,6 +156,7 @@ class FakeRadioController : override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { localConfigs.add(config) + settingsOperations.add(SettingsOperation.SetConfig(config)) } override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} @@ -151,6 +164,7 @@ class FakeRadioController : override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { failChannelWriteAfter?.let { if (localChannels.size >= it) error("Fake channel write failure") } localChannels.add(channel) + settingsOperations.add(SettingsOperation.SetChannel(channel)) } override suspend fun setFixedPosition(destNum: Int, position: Position) {} 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 909f27da37..ecfb76aba8 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 @@ -21,10 +21,12 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.NonCancellable 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 import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.util.channelIdentity +import org.meshtastic.core.model.util.isChannelPlaceholder +import org.meshtastic.core.model.util.toChannelReplacementPlan import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res @@ -36,13 +38,9 @@ import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Position import kotlin.time.Duration.Companion.days -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 - @Composable fun Position.formatPositionTime(): String { val currentTime = nowMillis @@ -93,125 +91,27 @@ fun getChannelList(new: List, old: List): 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. - * @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, - 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 - - 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.size == 0 - /** * Imports a [ChannelSet] as an authoritative REPLACE: writes every channel and — when present and actually different — * the imported LoRa config, all inside one [RadioController.editLocalSettings] transaction, then replaces the local * channel cache. * * Reads the current LoRa config and channel set from [radioConfigRepository]'s flows (avoiding the StateFlow - * placeholder window) and builds the authoritative replacement list via [getChannelReplacementList]. The edit-settings - * transaction defers disk persistence, radio reload/reconfiguration, and reboot until the closing commit, so channels + - * LoRa land in a single reboot with no per-slot reconfigure to pace against. (Firmware still writes each `set_channel` - * into its in-memory channel table as it arrives — the transaction is not a full staging of channel state — but the - * expensive persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors - * `InstallProfileUseCase` and is why the old pre/post settle delays are gone: the begin/commit boundary is the settle. + * placeholder window) and builds the shared authoritative replacement plan. The edit-settings transaction defers disk + * persistence, radio reload/reconfiguration, and reboot until the closing commit, so channels + LoRa land in a single + * reboot with no per-slot reconfigure to pace against. (Firmware still writes each `set_channel` into its in-memory + * channel table as it arrives — the transaction is not a full staging of channel state — but the expensive + * persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors `InstallProfileUseCase` and is + * why the old pre/post settle delays are gone: the begin/commit boundary is the settle. * * The local channel cache is commit-shaped: transactional channel writes deliberately do not mirror per slot (see * `AdminControllerImpl.EditSettingsSession.setChannel`), and this function replaces the cached channel list once, after - * the session succeeds — so an import interrupted before that point leaves the local channel cache untouched. (The - * imported LoRa config is the one exception: it still writes through the cache-mirroring `setConfig`, so its local - * cache update is not itself deferred to commit — a single trailing write that self-heals on the device's next config - * re-send. Making `setConfig` transaction-aware is future work.) + * the session succeeds — so an import interrupted before that point leaves the local channel cache untouched. The + * post-commit [RadioConfigRepository.updateChannelSet] call updates the normalized settings and imported LoRa config + * together; an import without LoRa preserves the cached LoRa config. * - * 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. + * Imported settings are normalized before any write or bounds check, so blank placeholder secondaries and semantic + * duplicates never reach the radio or the local cache. * * @param channelSet The imported [ChannelSet] to apply as a replacement. Its `lora_config`, if present and different * from the device's current LoRa config, is written inside the same transaction. @@ -223,32 +123,21 @@ suspend fun importChannelSet( radioController: RadioController, radioConfigRepository: RadioConfigRepository, ) { - // 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" - } val currentSettings = radioConfigRepository.channelSetFlow.first().settings - val replacements = - getChannelReplacementList( - new = normalizedSettings, - currentSettings = currentSettings, - minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, - maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT, - ) + val replacementPlan = + channelSet.toChannelReplacementPlan(currentSettings = currentSettings, fallbackLoraConfig = currentLoraConfig) // Only write LoRa when the import carries one that actually differs from the device — avoids a redundant // reconfigure. val importedLoraConfig = channelSet.lora_config?.takeIf { it != currentLoraConfig } Logger.i { - "Applying imported channel replacement writes=${replacements.size} " + - "importedSettings=${channelSet.settings.size} normalizedSettings=${normalizedSettings.size} " + + "Applying imported channel replacement writes=${replacementPlan.channelWrites.size} " + + "importedSettings=${channelSet.settings.size} " + + "normalizedSettings=${replacementPlan.normalizedSettings.size} " + "writesLora=${importedLoraConfig != null}" } radioController.editLocalSettings { - for (channel in replacements) { + for (channel in replacementPlan.channelWrites) { Logger.i { "Writing imported channel index=${channel.index} role=${channel.role} " + "hasName=${channel.settings?.name?.isNotBlank() == true}" @@ -257,7 +146,12 @@ suspend fun importChannelSet( } importedLoraConfig?.let { setConfig(Config(lora = it)) } } - withContext(NonCancellable) { radioConfigRepository.replaceAllSettings(normalizedSettings) } + withContext(NonCancellable) { + radioConfigRepository.updateChannelSet( + settingsList = replacementPlan.normalizedSettings, + loraConfig = importedLoraConfig, + ) + } } /** @@ -287,7 +181,7 @@ fun getChannelPreviewForAdd( val previewSelections = MutableList(existing.size) { true } var remaining = (maxChannels - existing.size).coerceAtLeast(0) for (channel in incoming) { - val shouldShow = !channel.isPlaceholder() + val shouldShow = !channel.isChannelPlaceholder() val identity = if (shouldShow) channel.channelIdentity(loraConfig) else null // Omit blank placeholders and semantic duplicates entirely — they are not shown to the user. if (identity != null && seen.add(identity)) { @@ -302,16 +196,3 @@ fun getChannelPreviewForAdd( /** Filtered ADD-mode preview: the visible channel list paired with its default selections (always size-matched). */ data class ChannelAddPreview(val settings: List, val selections: List) - -/** Semantic channel identity based on effective name and effective PSK. */ -private data class ChannelIdentity(val name: String, val psk: ByteString) { - // Redact the effective PSK from auto-generated diagnostics so a cryptographic key never leaks - // via toString() in exception messages, debug logs, or stack traces. - override fun toString(): String = "ChannelIdentity(name=$name, psk=)" -} - -/** 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) - return ChannelIdentity(name = channel.name, psk = channel.psk) -} 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 6e99e3b115..af8b7f0ec5 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 @@ -18,6 +18,8 @@ package org.meshtastic.core.ui.util import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.util.getChannelReplacementList +import org.meshtastic.core.model.util.normalizeReplacementSettings import org.meshtastic.core.testing.FakeRadioConfigRepository import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.proto.Channel @@ -196,6 +198,10 @@ class ProtoExtensionsTest { ), radioController.localChannels.map { it.role }, ) + assertEquals( + importedSettings + List(6) { ChannelSettings() }, + radioController.localChannels.map { it.settings ?: error("Channel write omitted settings") }, + ) assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings) } @@ -357,6 +363,7 @@ class ProtoExtensionsTest { // LoRa write is the last op in the edit session, with no settle delays around it. assertEquals(listOf(Config(lora = imported)), radioController.localConfigs) + assertEquals(imported, radioConfigRepository.currentChannelSet.lora_config) } @Test diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index ea2696719b..4da4940209 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -58,6 +58,7 @@ import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position +import org.meshtastic.core.model.util.MalformedMeshtasticUrlException import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.HomoglyphPrefs @@ -77,6 +78,7 @@ import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown +import org.meshtastic.core.resources.channel_invalid import org.meshtastic.core.resources.key_backup_deleted import org.meshtastic.core.resources.key_backup_not_found import org.meshtastic.core.resources.key_backup_restore_failed @@ -727,7 +729,22 @@ open class RadioConfigViewModel( fun installProfile(protobuf: DeviceProfile) { val destNum = destNum ?: destNode.value?.num ?: return - safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + val state = radioConfigState.value + val isLocal = this.destNum == null || destNum == myNodeNum + safeLaunch(tag = "installProfile") { + try { + installProfileUseCase( + destNum = destNum, + profile = protobuf, + currentUser = destNode.value?.user, + currentLoraConfig = state.radioConfig.lora, + isLocal = isLocal, + ) + } catch (_: MalformedMeshtasticUrlException) { + Logger.w { "[installProfile] Rejected invalid profile channel URL" } + snackbarManager.showSnackbar(message = UiText.Resource(Res.string.channel_invalid).resolve()) + } + } } fun clearPacketResponse() { diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 137a98edae..9399f4486f 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -56,6 +56,7 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.MalformedMeshtasticUrlException import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.HomoglyphPrefs @@ -1019,11 +1020,28 @@ class RadioConfigViewModelTest { viewModel = createViewModel() val profile = DeviceProfile() - everySuspend { installProfileUseCase(any(), any(), any()) } returns Unit + everySuspend { installProfileUseCase(any(), any(), any(), any(), any()) } returns Unit viewModel.installProfile(profile) - verifySuspend { installProfileUseCase(123, profile, any()) } + verifySuspend { installProfileUseCase(123, profile, any(), null, true) } + } + + @Test + fun `installProfile surfaces malformed channel URL in snackbar`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + viewModel = createViewModel() + val profile = DeviceProfile(channel_url = "not-a-channel-url") + everySuspend { installProfileUseCase(any(), any(), any(), any(), any()) } calls + { + throw MalformedMeshtasticUrlException("bad profile") + } + + viewModel.installProfile(profile) + runCurrent() + + verify { snackbarManager.showSnackbar(message = "This Channel URL is invalid and can not be used") } } @Test