fix(settings): restore channels from device profiles (#6618)

This commit is contained in:
simulationstation authored and GitHub committed 2026-08-13 12:10:18 +00:00
1 parent 35e2abbe26
commit b4bedd92fc
16 files changed
+638 -163

No files matched your search

@@ -65,7 +65,14 @@ class SwitchingChannelSetDataSource(
/** Replaces all [ChannelSettings] in a single atomic operation. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
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<ChannelSettings>?, 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) {
@@ -69,6 +69,10 @@ open class RadioConfigRepositoryImpl(
channelSetDataSource.replaceAllSettings(settingsList)
}
override suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, 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).
@@ -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
@@ -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)) }
}
}
@@ -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<IllegalStateException> {
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<MalformedMeshtasticUrlException> {
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<MalformedMeshtasticUrlException> {
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())
}
}
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ChannelSettings>, val channelWrites: List<Channel>)
/**
* 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<ChannelSettings>,
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<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 (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<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.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=<redacted>)"
}
/** 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)
}
@@ -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"
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<IllegalArgumentException> {
ChannelSet(settings = emptyList())
.toChannelReplacementPlan(
currentSettings = listOf(ChannelSettings(name = "Existing")),
fallbackLoraConfig = null,
requirePrimary = true,
)
}
}
}
@@ -40,6 +40,14 @@ interface RadioConfigRepository {
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>)
/**
* 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<ChannelSettings>?, loraConfig: Config.LoRaConfig?)
/** Updates the [ChannelSettings] list with the provided channel. */
suspend fun updateChannelSettings(channel: Channel)
@@ -41,6 +41,8 @@ class FakeRadioConfigRepository :
BaseFake(),
RadioConfigRepository {
data class ChannelSetUpdate(val settingsList: List<ChannelSettings>?, val loraConfig: Config.LoRaConfig?)
private val channelSetBacking = mutableStateFlow(ChannelSet())
override val channelSetFlow: Flow<ChannelSet> = channelSetBacking
@@ -93,10 +95,14 @@ class FakeRadioConfigRepository :
var lastSetModuleConfig: ModuleConfig? = null
private set
/** Arguments supplied to [updateChannelSet], in call order. */
val channelSetUpdates = mutableListOf<ChannelSetUpdate>()
init {
registerResetAction {
lastSetLocalConfig = null
lastSetModuleConfig = null
channelSetUpdates.clear()
}
}
@@ -105,7 +111,14 @@ class FakeRadioConfigRepository :
}
override suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList)
updateChannelSet(settingsList = settingsList, loraConfig = null)
}
override suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, 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) {
@@ -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>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
@@ -56,6 +62,9 @@ class FakeRadioController :
/** Every [setLocalChannel] call, in order. */
val localChannels = mutableListOf<Channel>()
/** Every config and channel write, in their shared call order. */
val settingsOperations = mutableListOf<SettingsOperation>()
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) {}
@@ -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<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.
* @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>,
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
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.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<ChannelSettings>, val selections: List<Boolean>)
/** 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=<redacted>)"
}
/** 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)
}
@@ -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
@@ -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() {
@@ -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