feat(settings): gate config fields on the schema's firmware versions (#7262)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-20 00:06:01 +00:00
1 parent da71c2810d
commit d635ba4603
9 files changed
+179 -88

No files matched your search

@@ -17,6 +17,7 @@
package org.meshtastic.core.model
import org.meshtastic.core.model.util.isDebug
import org.meshtastic.proto.FieldMetadata
/**
* Defines the capabilities and feature support based on the device firmware version.
@@ -31,6 +32,22 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
/**
* Whether a config field is worth offering on this firmware, from the version gates its schema declares. Below
* `since_firmware` the node ignores the field. At or above `deprecated_since` it is shown only while [isSet], so a
* value the node still holds stays visible instead of being silently kept.
*/
fun offers(field: FieldMetadata, isSet: Boolean = false): Boolean {
val arrived = field.since_firmware?.let(::gate)?.let(::atLeast) ?: true
val retired =
field.deprecated_since?.let(::gate)?.let { !forceEnableAll && version != null && version >= it } ?: false
return arrived && (!retired || isSet)
}
// The schema declares these; an unparseable one must fail here rather than silently pass every gate.
private fun gate(declared: String): DeviceVersion =
DeviceVersion(declared).also { require(it.isValid) { "Unparseable firmware version in schema: $declared" } }
/** Ability to mute notifications from specific nodes via admin messages. */
val canMuteNode = atLeast(V2_7_18)
@@ -40,17 +57,14 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
val canSendVerifiedContacts = atLeast(V2_7_12)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
val canToggleTelemetryEnabled = atLeast(V2_7_12)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
val canToggleUnmessageable = atLeast(V2_6_9)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing = atLeast(V2_6_8)
/** Support for Status Message module. Supported since firmware v2.8.0. */
val supportsStatusMessage = atLeast(V2_8_0)
/** Support for Status Message module. Supported since firmware v2.7.20. */
val supportsStatusMessage = atLeast(V2_7_20)
/**
* Support for TAK (ATAK) module configuration. Gated to firmware v2.8.0.
@@ -125,6 +139,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
private val V2_6_10 = DeviceVersion("2.6.10")
private val V2_7_12 = DeviceVersion("2.7.12")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_7_20 = DeviceVersion("2.7.20")
private val V2_8_0 = DeviceVersion("2.8.0")
private val UNRELEASED = DeviceVersion("9.9.9")
}
@@ -16,8 +16,10 @@
*/
package org.meshtastic.core.model
import org.meshtastic.proto.FieldMetadata
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -50,12 +52,6 @@ class CapabilitiesTest {
assertTrue(caps("2.7.12").canSendVerifiedContacts)
}
@Test
fun canToggleTelemetryEnabled_requires_V2_7_12() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
fun canToggleUnmessageable_requires_V2_6_9() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
@@ -75,9 +71,47 @@ class CapabilitiesTest {
}
@Test
fun supportsStatusMessage_requires_V2_8_0() {
assertFalse(caps("2.7.21").supportsStatusMessage)
assertTrue(caps("2.8.0").supportsStatusMessage)
fun supportsStatusMessage_requires_V2_7_20() {
assertFalse(caps("2.7.19").supportsStatusMessage)
assertTrue(caps("2.7.20").supportsStatusMessage)
}
@Test
fun offers_hides_a_field_below_since_firmware() {
val field = FieldMetadata.Builder().since_firmware("2.7.13").build()
assertFalse(caps("2.7.12").offers(field))
assertTrue(caps("2.7.13").offers(field))
assertFalse(caps(null).offers(field))
}
@Test
fun offers_hides_a_deprecated_field_at_deprecated_since_unless_set() {
val field = FieldMetadata.Builder().deprecated_since("2.8.0").build()
assertTrue(caps("2.7.26").offers(field))
assertFalse(caps("2.8.0").offers(field))
assertTrue(caps("2.8.0").offers(field, isSet = true))
// Unknown firmware cannot be shown to have retired the field.
assertTrue(caps(null).offers(field))
}
@Test
fun offers_an_unannotated_field_everywhere() {
val field = FieldMetadata.Builder().build()
assertTrue(caps(null).offers(field))
assertTrue(caps("2.3.15").offers(field))
}
@Test
fun offers_everything_when_forceEnableAll() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.offers(FieldMetadata.Builder().since_firmware("9.9.9").build()))
assertTrue(c.offers(FieldMetadata.Builder().deprecated_since("1.0.0").build()))
}
@Test
fun offers_rejects_an_unparseable_schema_version() {
val field = FieldMetadata.Builder().since_firmware("soon").build()
assertFailsWith<IllegalArgumentException> { caps("2.8.0").offers(field) }
}
@Test
@@ -164,7 +198,6 @@ class CapabilitiesTest {
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
assertFalse(c.canSendVerifiedContacts)
assertFalse(c.canToggleTelemetryEnabled)
assertFalse(c.canToggleUnmessageable)
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
@@ -50,7 +50,7 @@ class StatusMessageActionGateTest {
@Test
fun `older firmware does not offer the action`() {
val old = node(num = 1, firmware = "2.7.21")
val old = node(num = 1, firmware = "2.7.19")
assertFalse(canEditStatusMessage(old, old, ConnectionState.Connected))
}
@@ -23,6 +23,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
@@ -30,6 +31,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.allow_input_source
import org.meshtastic.core.resources.canned_message
@@ -52,11 +54,14 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.allow_input_source
@Suppress("DEPRECATION", "LongMethod")
@Composable
fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val cannedMessageConfig = state.moduleConfig.canned_message ?: ModuleConfig.CannedMessageConfig.Builder().build()
val messages = state.cannedMessageMessages
val formState = rememberConfigState(initialValue = cannedMessageConfig)
@@ -176,19 +181,27 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.allow_input_source),
value = formState.value.allow_input_source,
maxSize = 63, // allow_input_source max_size:16
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value = formState.value.newBuilder().also { wb -> wb.allow_input_source = it }.build()
},
)
if (
capabilities.offers(
ModuleConfig.CannedMessageConfig.allow_input_source,
isSet = formState.value.allow_input_source.isNotEmpty(),
)
) {
EditTextPreference(
title = stringResource(Res.string.allow_input_source),
value = formState.value.allow_input_source,
maxSize = 63, // allow_input_source max_size:16
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value =
formState.value.newBuilder().also { wb -> wb.allow_input_source = it }.build()
},
)
}
SwitchPreference(
title = stringResource(Res.string.send_bell),
checked = formState.value.send_bell,
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.always_point_north
@@ -55,11 +56,15 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
import org.meshtastic.proto.compass_north_top
import org.meshtastic.proto.use_12h_clock
@Suppress("DEPRECATION", "LongMethod")
@Composable
fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val displayConfig = state.radioConfig.display ?: Config.DisplayConfig.Builder().build()
val formState = rememberConfigState(initialValue = displayConfig)
@@ -77,28 +82,38 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
) {
item {
TitledCard(title = stringResource(Res.string.display_config)) {
SwitchPreference(
title = stringResource(Res.string.always_point_north),
summary = stringResource(Res.string.config_display_compass_north_top_summary),
checked = formState.value.compass_north_top,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.newBuilder().also { wb -> wb.compass_north_top = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_12h_format),
summary = stringResource(Res.string.display_time_in_12h_format),
enabled = state.connected,
checked = formState.value.use_12h_clock,
onCheckedChange = {
formState.value = formState.value.newBuilder().also { wb -> wb.use_12h_clock = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
if (
capabilities.offers(
Config.DisplayConfig.compass_north_top,
isSet = formState.value.compass_north_top,
)
) {
SwitchPreference(
title = stringResource(Res.string.always_point_north),
summary = stringResource(Res.string.config_display_compass_north_top_summary),
checked = formState.value.compass_north_top,
enabled = state.connected,
onCheckedChange = {
formState.value =
formState.value.newBuilder().also { wb -> wb.compass_north_top = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
}
if (capabilities.offers(Config.DisplayConfig.use_12h_clock)) {
SwitchPreference(
title = stringResource(Res.string.use_12h_format),
summary = stringResource(Res.string.display_time_in_12h_format),
enabled = state.connected,
checked = formState.value.use_12h_clock,
onCheckedChange = {
formState.value = formState.value.newBuilder().also { wb -> wb.use_12h_clock = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
}
SwitchPreference(
title = stringResource(Res.string.bold_heading),
summary = stringResource(Res.string.config_display_heading_bold_summary),
@@ -36,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -48,6 +49,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.MqttConnectionState
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.network.repository.effectiveTlsEnabled
@@ -91,13 +93,16 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.json_enabled
// json_enabled is deprecated in the protobuf schema but remains the only toggle for MQTT JSON
// publish/consume, so the settings UI must keep exposing it until the proto provides a replacement.
// json_enabled still drives MQTT JSON on firmware below its deprecated_since; beyond that the schema gate hides it
// unless the node still holds it set, so a stale value stays visible.
@Suppress("DEPRECATION")
@Composable
fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle()
val mqttProxyActive by viewModel.mqttProxyActive.collectAsStateWithLifecycle()
@@ -217,16 +222,18 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.json_output_enabled),
checked = formState.value.json_enabled,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.newBuilder().also { wb -> wb.json_enabled = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
if (capabilities.offers(ModuleConfig.MQTTConfig.json_enabled, isSet = formState.value.json_enabled)) {
SwitchPreference(
title = stringResource(Res.string.json_output_enabled),
checked = formState.value.json_enabled,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.newBuilder().also { wb -> wb.json_enabled = it }.build()
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
}
MqttTlsPreference(
enabled = state.connected,
address = formState.value.address,
@@ -28,6 +28,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -39,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.extractWifiCredentials
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.handleMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
@@ -83,6 +85,7 @@ import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.proto.Config
import org.meshtastic.proto.enabled_protocols
@Composable
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
@@ -98,6 +101,8 @@ private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}."
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onOpenNfcSettings: () -> Unit = {}) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig.Builder().build()
val formState = rememberConfigState(initialValue = networkConfig)
@@ -296,27 +301,29 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked =
formState.value.enabled_protocols and Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value !=
0,
onCheckedChange = { enabled ->
val flags =
if (enabled) {
formState.value.enabled_protocols or
Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value
} else {
formState.value.enabled_protocols and
Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value.inv()
}
formState.value =
formState.value.newBuilder().also { wb -> wb.enabled_protocols = flags }.build()
},
enabled = state.connected,
)
HorizontalDivider()
if (capabilities.offers(Config.NetworkConfig.enabled_protocols)) {
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked =
formState.value.enabled_protocols and
Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value != 0,
onCheckedChange = { enabled ->
val flags =
if (enabled) {
formState.value.enabled_protocols or
Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value
} else {
formState.value.enabled_protocols and
Config.NetworkConfig.ProtocolFlags.UDP_BROADCAST.value.inv()
}
formState.value =
formState.value.newBuilder().also { wb -> wb.enabled_protocols = flags }.build()
},
enabled = state.connected,
)
HorizontalDivider()
}
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
@@ -47,6 +47,7 @@ import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.device_telemetry_enabled
@Composable
fun TelemetryConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
@@ -72,7 +73,7 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
) {
item {
TitledCard(title = stringResource(Res.string.telemetry_config)) {
if (capabilities.canToggleTelemetryEnabled) {
if (capabilities.offers(ModuleConfig.TelemetryConfig.device_telemetry_enabled)) {
SwitchPreference(
title = stringResource(Res.string.device_telemetry_enabled),
summary = stringResource(Res.string.device_telemetry_enabled_summary),
@@ -149,7 +149,7 @@ class RadioConfigViewModelTest {
Node(
num = 123,
user = User.Builder().also { wb -> wb.id = "!123" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.21" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.19" }.build(),
)
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel(destNum = 123)
@@ -196,7 +196,7 @@ class RadioConfigViewModelTest {
Node(
num = 456,
user = User.Builder().also { wb -> wb.id = "!456" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.21" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.19" }.build(),
)
nodeRepository.setNodes(listOf(localNode, remoteNode))
nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
@@ -2035,7 +2035,7 @@ class RadioConfigViewModelTest {
Node(
num = 456,
user = User.Builder().also { wb -> wb.id = "!456" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.21" }.build(),
metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "2.7.19" }.build(),
)
val packetFlow = MutableSharedFlow<MeshPacket>()
val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")