mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
fix(settings): restore channels from device profiles (#6618)
This commit is contained in:
1 parent
35e2abbe26
commit
b4bedd92fc
16 files changed
+638
-163
No files matched your search
+9
-2
@@ -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) {
|
||||
|
||||
+4
@@ -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).
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
+62
-3
@@ -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)) }
|
||||
}
|
||||
}
|
||||
+224
-4
@@ -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
|
||||
|
||||
+141
@@ -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)
|
||||
}
|
||||
+7
-1
@@ -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"
|
||||
}
|
||||
+59
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+18
-1
@@ -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() {
|
||||
|
||||
+20
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user