diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 6d0b4ed57f..a904a74473 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1430,13 +1430,6 @@ range_test
range_test_config
react
reboot
-### REBROADCAST ###
-rebroadcast_mode_all_desc
-rebroadcast_mode_all_skip_decoding_desc
-rebroadcast_mode_core_portnums_only_desc
-rebroadcast_mode_known_only_desc
-rebroadcast_mode_local_only_desc
-rebroadcast_mode_none_desc
recent_network_devices
reconnecting
redacted
@@ -1493,30 +1486,17 @@ ringtone_imported
role
role_client
role_client_base
-role_client_base_desc
-role_client_desc
role_client_hidden
-role_client_hidden_desc
role_client_mute
-role_client_mute_desc
role_lost_and_found
-role_lost_and_found_desc
role_repeater
-role_repeater_desc
role_router
role_router_client
-role_router_client_desc
-role_router_desc
role_router_late
-role_router_late_desc
role_sensor
-role_sensor_desc
role_tak
-role_tak_desc
role_tak_tracker
-role_tak_tracker_desc
role_tracker
-role_tracker_desc
router_role_confirmation_text
### ROUTING ###
routing_error_admin_bad_session_key
@@ -1702,15 +1682,6 @@ system_settings
tak
tak_config
tak_role
-tak_role_forwardobserver
-tak_role_hq
-tak_role_k9
-tak_role_medic
-tak_role_rto
-tak_role_sniper
-tak_role_teamlead
-tak_role_teammember
-tak_role_unspecified
tak_server
tak_server_channel
tak_server_channel_desc
@@ -1743,21 +1714,6 @@ tak_server_test_run
tak_server_test_running
tak_server_v1_fallback_notice
tak_team
-tak_team_blue
-tak_team_brown
-tak_team_cyan
-tak_team_dark_blue
-tak_team_dark_green
-tak_team_green
-tak_team_magenta
-tak_team_maroon
-tak_team_orange
-tak_team_purple
-tak_team_red
-tak_team_teal
-tak_team_unspecified_color
-tak_team_white
-tak_team_yellow
telemetry
telemetry_config
temperature
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SchemaEnumLabels.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SchemaEnumLabels.kt
new file mode 100644
index 0000000000..f78004213d
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SchemaEnumLabels.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.allStringResources
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.MemberRole
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.Team
+
+// Generated by ./gradlew :schema-strings:sync from org.meshtastic:protobufs 2.8.0.116-g51028ca-SNAPSHOT.
+// Do not edit: change the schema, then run sync.
+
+/**
+ * The schema's label for this enum value, or null where the schema does not name it. A picker shows this in place of
+ * the constant's Kotlin name, and falls back to that name when it is null, so a value the schema has not reached still
+ * renders.
+ */
+@Suppress("CyclomaticComplexMethod")
+fun Enum<*>.schemaLabelRes(): StringResource? = when (this) {
+ is Config.BluetoothConfig.PairingMode -> schemaLabel("schema_bluetooth_pairingmode_")
+ is Config.DeviceConfig.RebroadcastMode -> schemaLabel("schema_device_rebroadcastmode_")
+ is Config.DeviceConfig.Role -> schemaLabel("schema_device_role_")
+ is Config.DisplayConfig.CompassOrientation -> schemaLabel("schema_display_compassorientation_")
+ is Config.DisplayConfig.DisplayMode -> schemaLabel("schema_display_displaymode_")
+ is Config.DisplayConfig.DisplayUnits -> schemaLabel("schema_display_displayunits_")
+ is Config.DisplayConfig.OledType -> schemaLabel("schema_display_oledtype_")
+ is Config.LoRaConfig.ModemPreset -> schemaLabel("schema_lora_modempreset_")
+ is Config.LoRaConfig.RegionCode -> schemaLabel("schema_lora_regioncode_")
+ is Config.NetworkConfig.AddressMode -> schemaLabel("schema_network_addressmode_")
+ is Config.NetworkConfig.ProtocolFlags -> schemaLabel("schema_network_protocolflags_")
+ is Config.PositionConfig.GpsMode -> schemaLabel("schema_position_gpsmode_")
+ is Config.PositionConfig.PositionFlags -> schemaLabel("schema_position_positionflags_")
+ is Config.SecurityConfig.PacketSignaturePolicy -> schemaLabel("schema_security_packetsignaturepolicy_")
+ is MemberRole -> schemaLabel("schema_memberrole_")
+ is ModuleConfig.AudioConfig.Audio_Baud -> schemaLabel("schema_audio_audio_baud_")
+ is ModuleConfig.CannedMessageConfig.InputEventChar -> schemaLabel("schema_cannedmessage_inputeventchar_")
+ is ModuleConfig.DetectionSensorConfig.TriggerType -> schemaLabel("schema_detectionsensor_triggertype_")
+ is ModuleConfig.SerialConfig.Serial_Baud -> schemaLabel("schema_serial_serial_baud_")
+ is ModuleConfig.SerialConfig.Serial_Mode -> schemaLabel("schema_serial_serial_mode_")
+ is Team -> schemaLabel("schema_team_")
+ else -> null
+}
+
+/** The schema's one-sentence explanation of this enum value, or null where it has none. */
+@Suppress("CyclomaticComplexMethod")
+fun Enum<*>.schemaDescriptionRes(): StringResource? = when (this) {
+ is Config.DeviceConfig.RebroadcastMode -> schemaDescription("schema_device_rebroadcastmode_")
+ is Config.DeviceConfig.Role -> schemaDescription("schema_device_role_")
+ is Config.PositionConfig.PositionFlags -> schemaDescription("schema_position_positionflags_")
+ is Config.SecurityConfig.PacketSignaturePolicy -> schemaDescription("schema_security_packetsignaturepolicy_")
+ else -> null
+}
+
+private fun Enum<*>.schemaLabel(prefix: String): StringResource? = Res.allStringResources[prefix + name.lowercase()]
+
+private fun Enum<*>.schemaDescription(prefix: String): StringResource? =
+ Res.allStringResources[prefix + name.lowercase() + "_description"]
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt
index 7ae5293f8d..20b34058f4 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt
@@ -16,66 +16,8 @@
*/
package org.meshtastic.core.model
-import org.jetbrains.compose.resources.StringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.tak_role_forwardobserver
-import org.meshtastic.core.resources.tak_role_hq
-import org.meshtastic.core.resources.tak_role_k9
-import org.meshtastic.core.resources.tak_role_medic
-import org.meshtastic.core.resources.tak_role_rto
-import org.meshtastic.core.resources.tak_role_sniper
-import org.meshtastic.core.resources.tak_role_teamlead
-import org.meshtastic.core.resources.tak_role_teammember
-import org.meshtastic.core.resources.tak_role_unspecified
-import org.meshtastic.core.resources.tak_team_blue
-import org.meshtastic.core.resources.tak_team_brown
-import org.meshtastic.core.resources.tak_team_cyan
-import org.meshtastic.core.resources.tak_team_dark_blue
-import org.meshtastic.core.resources.tak_team_dark_green
-import org.meshtastic.core.resources.tak_team_green
-import org.meshtastic.core.resources.tak_team_magenta
-import org.meshtastic.core.resources.tak_team_maroon
-import org.meshtastic.core.resources.tak_team_orange
-import org.meshtastic.core.resources.tak_team_purple
-import org.meshtastic.core.resources.tak_team_red
-import org.meshtastic.core.resources.tak_team_teal
-import org.meshtastic.core.resources.tak_team_unspecified_color
-import org.meshtastic.core.resources.tak_team_white
-import org.meshtastic.core.resources.tak_team_yellow
-import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
-@Suppress("CyclomaticComplexMethod")
-fun getStringResFrom(team: Team): StringResource = when (team) {
- Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
- Team.White -> Res.string.tak_team_white
- Team.Yellow -> Res.string.tak_team_yellow
- Team.Orange -> Res.string.tak_team_orange
- Team.Magenta -> Res.string.tak_team_magenta
- Team.Red -> Res.string.tak_team_red
- Team.Maroon -> Res.string.tak_team_maroon
- Team.Purple -> Res.string.tak_team_purple
- Team.Dark_Blue -> Res.string.tak_team_dark_blue
- Team.Blue -> Res.string.tak_team_blue
- Team.Cyan -> Res.string.tak_team_cyan
- Team.Teal -> Res.string.tak_team_teal
- Team.Green -> Res.string.tak_team_green
- Team.Dark_Green -> Res.string.tak_team_dark_green
- Team.Brown -> Res.string.tak_team_brown
-}
-
-fun getStringResFrom(role: MemberRole): StringResource = when (role) {
- MemberRole.Unspecifed -> Res.string.tak_role_unspecified
- MemberRole.TeamMember -> Res.string.tak_role_teammember
- MemberRole.TeamLead -> Res.string.tak_role_teamlead
- MemberRole.HQ -> Res.string.tak_role_hq
- MemberRole.Sniper -> Res.string.tak_role_sniper
- MemberRole.Medic -> Res.string.tak_role_medic
- MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
- MemberRole.RTO -> Res.string.tak_role_rto
- MemberRole.K9 -> Res.string.tak_role_k9
-}
-
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun getColorFrom(team: Team): Long = when (team) {
Team.Unspecifed_Color -> 0xFF00FFFF
diff --git a/core/resources/src/commonMain/composeResources/values/schema_strings.xml b/core/resources/src/commonMain/composeResources/values/schema_strings.xml
index e7fa7cd867..c9591fc434 100644
--- a/core/resources/src/commonMain/composeResources/values/schema_strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/schema_strings.xml
@@ -16,7 +16,7 @@
-->
-
+
Blue
The blue level of the ambient lighting LED.
Current
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 17baa28ca3..47ec0b6417 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1478,13 +1478,6 @@
Range Test Config
React
Reboot
-
- Rebroadcast any observed message, if it was on our private channel or from another mesh with the same lora parameters.
- Same as behavior as ALL but skips packet decoding and simply rebroadcasts them. Only available in Repeater role. Setting this on any other roles will result in ALL behavior.
- Ignores packets from non-standard portnums such as: TAK, RangeTest, PaxCounter, etc. Only rebroadcasts packets with standard portnums: NodeInfo, Text, Position, Telemetry, and Routing.
- Ignores observed messages from foreign meshes like LOCAL ONLY, but takes it step further by also ignoring messages from nodes not already in the node's known list.
- Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels.
- Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role.
Recent Network Devices
Reconnecting…
***[REDACTED]***
@@ -1544,30 +1537,17 @@
Device Role
Client
Client Base
- Treats packets from or to favorited nodes as ROUTER_LATE, and all other packets as CLIENT.
- App connected or standalone messaging device.
Client Hidden
- Device that only broadcasts as needed for stealth or power savings.
Client Mute
- Device that does not forward packets from other devices.
Lost and Found
- Broadcasts location as message to default channel regularly to assist with device recovery.
Repeater
- Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in nodes list.
Router
Router Client
- Combination of both ROUTER and CLIENT. Not for mobile devices.
- Infrastructure node for extending network coverage by relaying messages. Visible in nodes list.
Router Late
- Infrastructure node that always rebroadcasts packets once but only after all other modes, ensuring additional coverage for local clusters. Visible in nodes list.
Sensor
- Broadcasts telemetry packets as priority.
TAK
- Optimized for ATAK system communication, reduces routine broadcasts.
TAK Tracker
- Enables automatic TAK PLI broadcasts and reduces routine broadcasts.
Tracker
- Broadcasts GPS position packets as priority.
I have read the <a href="https://meshtastic.org/docs/configuration/radio/device/#roles">Device Role Documentation</a> and the blog post about <a href="http://meshtastic.org/blog/choosing-the-right-device-role">Choosing The Right Device Role</a>.
Admin session expired
@@ -1753,15 +1733,6 @@
TAK (ATAK)
TAK Configuration
Member Role
- Forward Observer
- Headquarters
- Doggo (K9)
- Medic
- Radio Telephone Operator
- Sniper
- Team Lead
- Team Member
- Unspecified
TAK Server
TAK Mesh Channel
Meshtastic channel used for outgoing TAK traffic
@@ -1797,21 +1768,6 @@
Running: %1$s
Connected node firmware doesn't support full TAK integration — only location and chat messages will bridge to ATAK. Markers and other event types need firmware 2.8.0 or newer.
Team Color
- Blue
- Brown
- Cyan
- Dark Blue
- Dark Green
- Green
- Magenta
- Maroon
- Orange
- Purple
- Red
- Teal
- Unspecified
- White
- Yellow
Telemetry
Telemetry Config
Temp
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index b518cdd77b..754c66863a 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -44,6 +44,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.schemaLabelRes
import org.meshtastic.core.ui.theme.AppTheme
import kotlin.jvm.JvmName
@@ -59,14 +61,18 @@ fun > DropDownPreference(
itemColor: @Composable ((T) -> Color)? = null,
itemLabel: @Composable ((T) -> String)? = null,
) {
+ // A deprecated value the radio currently holds stays on the list: dropping it leaves the field showing nothing and
+ // writes a different value on the next save.
val enumConstants =
remember(selectedItem) {
- enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() }
+ enumEntriesOf(selectedItem).filter {
+ it.name != "UNRECOGNIZED" && (it == selectedItem || !it.isDeprecatedEnumEntry())
+ }
}
val items =
enumConstants.map {
- val label = itemLabel?.invoke(it) ?: it.name
+ val label = itemLabel?.invoke(it) ?: it.schemaLabel()
val icon = itemIcon?.invoke(it)
val color = itemColor?.invoke(it)
DropDownItem(it, label, icon, color)
@@ -218,6 +224,9 @@ internal expect fun > enumEntriesOf(selectedItem: T): List
internal expect fun Enum<*>.isDeprecatedEnumEntry(): Boolean
+/** The label the schema gives this value, falling back to the constant's name where the schema does not name it. */
+@Composable private fun Enum<*>.schemaLabel(): String = schemaLabelRes()?.let { stringResource(it) } ?: name
+
@Preview(showBackground = true)
@Composable
fun DropDownPreferencePreview() {
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/DropDownPreferenceUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/DropDownPreferenceUiTest.kt
new file mode 100644
index 0000000000..5fa013b7b5
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/DropDownPreferenceUiTest.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.component
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class DropDownPreferenceUiTest {
+
+ @Test
+ fun deprecatedValueTheRadioHoldsStaysOnTheList() = runComposeUiTest {
+ setContent {
+ DropDownPreference(
+ title = "Bitrate",
+ enabled = true,
+ selectedItem = ModuleConfig.AudioConfig.Audio_Baud.CODEC2_700,
+ onItemSelected = {},
+ )
+ }
+
+ onNodeWithText("CODEC2_700").assertIsDisplayed()
+ }
+
+ @Test
+ fun valueTheSchemaNamesIsShownByItsLabel() = runComposeUiTest {
+ setContent {
+ DropDownPreference(
+ title = "Rebroadcast Mode",
+ enabled = true,
+ selectedItem = Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY,
+ onItemSelected = {},
+ )
+ }
+
+ onNodeWithText("Local Only").assertIsDisplayed()
+ }
+}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt
index 9eff1d866d..9b3f98fd23 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AudioConfigItemList.kt
@@ -86,7 +86,6 @@ fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DropDownPreference(
title = stringResource(Res.string.schema_audio_bitrate),
enabled = state.connected,
- items = ModuleConfig.AudioConfig.Audio_Baud.entries.map { it to it.name },
selectedItem = formState.value.bitrate,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.bitrate = it }.build()
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt
index d2e1176301..ecc445db11 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt
@@ -144,7 +144,6 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni
DropDownPreference(
title = stringResource(Res.string.schema_cannedmessage_inputbroker_event_press),
enabled = state.connected,
- items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem = formState.value.inputbroker_event_press,
onItemSelected = {
formState.value =
@@ -155,7 +154,6 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni
DropDownPreference(
title = stringResource(Res.string.schema_cannedmessage_inputbroker_event_cw),
enabled = state.connected,
- items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem = formState.value.inputbroker_event_cw,
onItemSelected = {
formState.value =
@@ -166,7 +164,6 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni
DropDownPreference(
title = stringResource(Res.string.schema_cannedmessage_inputbroker_event_ccw),
enabled = state.connected,
- items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem = formState.value.inputbroker_event_ccw,
onItemSelected = {
formState.value =
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt
index 94fb9a8cef..d7d2e8215e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DetectionSensorConfigItemList.kt
@@ -147,7 +147,6 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U
DropDownPreference(
title = stringResource(Res.string.schema_detectionsensor_detection_trigger_type),
enabled = state.connected,
- items = ModuleConfig.DetectionSensorConfig.TriggerType.entries.map { it to it.name },
selectedItem = formState.value.detection_trigger_type,
onItemSelected = {
formState.value =
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
index 65cc20f93f..6c5880dd09 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt
@@ -50,8 +50,8 @@ import androidx.compose.ui.text.input.ImeAction
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.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.schemaDescriptionRes
import org.meshtastic.core.model.util.isDebug
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.accept
@@ -73,26 +73,7 @@ import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.led_heartbeat
import org.meshtastic.core.resources.no_files_manifested
import org.meshtastic.core.resources.options
-import org.meshtastic.core.resources.rebroadcast_mode_all_desc
-import org.meshtastic.core.resources.rebroadcast_mode_all_skip_decoding_desc
-import org.meshtastic.core.resources.rebroadcast_mode_core_portnums_only_desc
-import org.meshtastic.core.resources.rebroadcast_mode_known_only_desc
-import org.meshtastic.core.resources.rebroadcast_mode_local_only_desc
-import org.meshtastic.core.resources.rebroadcast_mode_none_desc
import org.meshtastic.core.resources.role
-import org.meshtastic.core.resources.role_client_base_desc
-import org.meshtastic.core.resources.role_client_desc
-import org.meshtastic.core.resources.role_client_hidden_desc
-import org.meshtastic.core.resources.role_client_mute_desc
-import org.meshtastic.core.resources.role_lost_and_found_desc
-import org.meshtastic.core.resources.role_repeater_desc
-import org.meshtastic.core.resources.role_router_client_desc
-import org.meshtastic.core.resources.role_router_desc
-import org.meshtastic.core.resources.role_router_late_desc
-import org.meshtastic.core.resources.role_sensor_desc
-import org.meshtastic.core.resources.role_tak_desc
-import org.meshtastic.core.resources.role_tak_tracker_desc
-import org.meshtastic.core.resources.role_tracker_desc
import org.meshtastic.core.resources.router_role_confirmation_text
import org.meshtastic.core.resources.schema_device_button_gpio
import org.meshtastic.core.resources.schema_device_buzzer_gpio
@@ -119,42 +100,6 @@ import org.meshtastic.proto.Config
@Composable expect fun rememberSystemTimeZonePosixString(): String
-@Suppress("DEPRECATION")
-private val Config.DeviceConfig.Role.description: StringResource
- get() =
- when (this) {
- Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc
- Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc
- Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc
- Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc
- Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc
- Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc
- Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc
- Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc
- Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc
- Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc
- Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
- Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
- Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
- }
-
-private val Config.DeviceConfig.RebroadcastMode.description: StringResource
- get() =
- when (this) {
- Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc
-
- Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc
-
- Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc
-
- Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc
-
- Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
-
- Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
- Res.string.rebroadcast_mode_core_portnums_only_desc
- }
-
@Suppress("DEPRECATION", "LongMethod")
@Composable
fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
@@ -197,9 +142,8 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
enabled = state.connected,
selectedItem = currentRole,
onItemSelected = { selectedRole = it },
- summary = stringResource(currentRole.description),
+ summary = currentRole.schemaDescriptionRes()?.let { stringResource(it) },
itemIcon = { MeshtasticIcons.role(it) },
- itemLabel = { it.name },
)
HorizontalDivider()
@@ -212,7 +156,7 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.rebroadcast_mode = it }.build()
},
- summary = stringResource(currentRebroadcastMode.description),
+ summary = currentRebroadcastMode.schemaDescriptionRes()?.let { stringResource(it) },
)
HorizontalDivider()
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt
index 97aab73f53..11d7d4e13b 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DisplayConfigItemList.kt
@@ -129,7 +129,6 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.schema_display_units),
summary = stringResource(Res.string.schema_display_units_description),
enabled = state.connected,
- items = Config.DisplayConfig.DisplayUnits.entries.map { it to it.name },
selectedItem = formState.value.units,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.units = it }.build()
@@ -199,7 +198,6 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.schema_display_displaymode),
summary = stringResource(Res.string.schema_display_displaymode_description),
enabled = state.connected,
- items = Config.DisplayConfig.DisplayMode.entries.map { it to it.name },
selectedItem = formState.value.displaymode,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.displaymode = it }.build()
@@ -210,7 +208,6 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.schema_display_oled),
summary = stringResource(Res.string.schema_display_oled_description),
enabled = state.connected,
- items = Config.DisplayConfig.OledType.entries.map { it to it.name },
selectedItem = formState.value.oled,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.oled = it }.build()
@@ -220,7 +217,6 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DropDownPreference(
title = stringResource(Res.string.schema_display_compass_orientation),
enabled = state.connected,
- items = Config.DisplayConfig.CompassOrientation.entries.map { it to it.name },
selectedItem = formState.value.compass_orientation,
onItemSelected = {
formState.value =
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
index b8d117ac43..410e7584ef 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
@@ -331,7 +331,6 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.address_mode = it }.build()
},
- itemLabel = { it.name },
)
if (formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC) {
HorizontalDivider()
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt
index bbf290e9ce..0a03797871 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt
@@ -300,7 +300,6 @@ fun PositionConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
DropDownPreference(
title = stringResource(Res.string.schema_position_gps_mode),
enabled = state.connected,
- items = Config.PositionConfig.GpsMode.entries.map { it to it.name },
selectedItem = formState.value.gps_mode,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.gps_mode = it }.build()
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt
index 0161aee1cd..0e873df940 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SerialConfigItemList.kt
@@ -108,7 +108,6 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DropDownPreference(
title = stringResource(Res.string.schema_serial_baud),
enabled = state.connected,
- items = ModuleConfig.SerialConfig.Serial_Baud.entries.map { it to it.name },
selectedItem = formState.value.baud,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.baud = it }.build()
@@ -128,7 +127,6 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DropDownPreference(
title = stringResource(Res.string.schema_serial_mode),
enabled = state.connected,
- items = ModuleConfig.SerialConfig.Serial_Mode.entries.map { it to it.name },
selectedItem = formState.value.mode,
onItemSelected = {
formState.value = formState.value.newBuilder().also { wb -> wb.mode = it }.build()
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index 7b806de83c..21d7e8b26e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -55,7 +55,6 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.getColorFrom
-import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TakPrefs
@@ -174,7 +173,6 @@ internal fun TakConfigCard(
title = stringResource(Res.string.tak_team),
enabled = enabled,
selectedItem = team,
- itemLabel = { stringResource(getStringResFrom(it)) },
itemColor = { Color(getColorFrom(it)) },
onItemSelected = onTeamSelected,
)
@@ -183,7 +181,6 @@ internal fun TakConfigCard(
title = stringResource(Res.string.tak_role),
enabled = enabled,
selectedItem = role,
- itemLabel = { stringResource(getStringResFrom(it)) },
onItemSelected = onRoleSelected,
)
}
diff --git a/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/EnumLabelsKt.kt b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/EnumLabelsKt.kt
new file mode 100644
index 0000000000..74fe77ffa8
--- /dev/null
+++ b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/EnumLabelsKt.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.schemastrings
+
+/**
+ * The Kotlin half of the sync: a lookup from an enum value to the resource holding its schema label, so a picker shows
+ * the label every client shares instead of the constant's Kotlin name.
+ *
+ * The keys these build are [SchemaCatalog.keyFor]'s, so this file and `schema_strings.xml` cannot name a value
+ * differently. What the lookup cannot do is fail at compile time: a resource that is not there reads as null, which is
+ * why `SchemaEnumLabelsTest` walks every labelled constant.
+ */
+object EnumLabelsKt {
+
+ /** One enum the schema labels: its proto path, the prefix its values share, and whether any value is described. */
+ data class LabelledEnum(val path: String, val prefix: String, val hasDescriptions: Boolean)
+
+ /**
+ * The whole generated file. Spotless reformats it afterwards, so this is the input to the formatter rather than the
+ * committed bytes - which is why `RepositorySyncTest` compares the branches it declares, not its text. The notice
+ * sits below the imports because the licence-header step deletes anything between the header and `package`.
+ */
+ fun render(header: String, pin: String, enums: List): String = buildString {
+ append(header)
+ append("package $PACKAGE\n\n")
+ imports(enums).forEach { append("import ").append(it).append('\n') }
+ append('\n').append(notice(pin)).append('\n')
+ append(LABEL_DOC)
+ append("@Suppress(\"CyclomaticComplexMethod\")\n")
+ append("fun Enum<*>.schemaLabelRes(): StringResource? = when (this) {\n")
+ enums.forEach { append(branch(it, "schemaLabel")) }
+ append(" else -> null\n}\n")
+ append(DESCRIPTION_DOC)
+ append("@Suppress(\"CyclomaticComplexMethod\")\n")
+ append("fun Enum<*>.schemaDescriptionRes(): StringResource? = when (this) {\n")
+ enums.filter { it.hasDescriptions }.forEach { append(branch(it, "schemaDescription")) }
+ append(" else -> null\n}\n")
+ append(HELPER)
+ }
+
+ /**
+ * One `when` branch. A branch that would not fit on a line is an error rather than a wrap: a wrapped branch makes
+ * the formatter space out every other branch in the same `when`, and the fix belongs in the name, not here.
+ */
+ private fun branch(enum: LabelledEnum, function: String): String {
+ val line = " is ${enum.path} -> $function(\"${enum.prefix}\")"
+ require(line.length <= MAX_LINE) { "branch for ${enum.path} is ${line.length} characters, over $MAX_LINE" }
+ return "$line\n"
+ }
+
+ /** `is Config.DeviceConfig.Role -> ...`, as the committed file spells it, for the drift check. */
+ val branchPattern: Regex =
+ Regex("""^\s*is ([\w.]+) -> schema(Label|Description)\("([^"]+)"\)""", RegexOption.MULTILINE)
+
+ /** `Config.DeviceConfig.Role` needs only `Config` imported; a top-level enum needs itself. */
+ private fun imports(enums: List): List =
+ (FIXED_IMPORTS + enums.map { "$PROTO_PACKAGE.${it.path.substringBefore('.')}" }).distinct().sorted()
+
+ fun notice(pin: String): String =
+ "// Generated by ./gradlew :schema-strings:sync from org.meshtastic:protobufs $pin.\n" +
+ "// Do not edit: change the schema, then run sync."
+
+ private const val MAX_LINE = 120
+ private const val PACKAGE = "org.meshtastic.core.model"
+ private const val PROTO_PACKAGE = "org.meshtastic.proto"
+
+ private val FIXED_IMPORTS =
+ listOf(
+ "org.jetbrains.compose.resources.StringResource",
+ "org.meshtastic.core.resources.Res",
+ "org.meshtastic.core.resources.allStringResources",
+ )
+
+ private val LABEL_DOC =
+ """
+ |
+ |/**
+ | * The schema's label for this enum value, or null where the schema does not name it. A picker shows this in
+ | * place of the constant's Kotlin name, and falls back to that name when it is null, so a value the schema has
+ | * not reached still renders.
+ | */
+ |"""
+ .trimMargin()
+
+ private val DESCRIPTION_DOC =
+ """
+ |
+ |/** The schema's one-sentence explanation of this enum value, or null where it has none. */
+ |"""
+ .trimMargin()
+
+ private val HELPER =
+ """
+ |
+ |private fun Enum<*>.schemaLabel(prefix: String): StringResource? =
+ | Res.allStringResources[prefix + name.lowercase()]
+ |
+ |private fun Enum<*>.schemaDescription(prefix: String): StringResource? =
+ | Res.allStringResources[prefix + name.lowercase() + "_description"]
+ |"""
+ .trimMargin()
+}
diff --git a/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaCatalog.kt b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaCatalog.kt
index be59a74092..65b43be887 100644
--- a/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaCatalog.kt
+++ b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaCatalog.kt
@@ -73,6 +73,22 @@ object SchemaCatalog {
return out
}
+ /** Every enum the schema labels, in proto-path order, for the Kotlin accessors that read those labels. */
+ fun labelledEnums(): List = generatedTypePaths().mapNotNull { path ->
+ val type = Class.forName(GENERATED_PACKAGE + path.replace('.', '$'))
+ val constants = type.enumConstants ?: return@mapNotNull null
+ val metadata =
+ constants.filterIsInstance().mapNotNull {
+ FieldMetadataRegistry.forEnumValue(PROTO_PACKAGE + path, it.value)
+ }
+ if (metadata.none { !it.label.isNullOrBlank() }) return@mapNotNull null
+ EnumLabelsKt.LabelledEnum(
+ path = path,
+ prefix = keyFor(path, ""),
+ hasDescriptions = metadata.any { !it.description.isNullOrBlank() },
+ )
+ }
+
/**
* The resource for a field or enum value. Each message segment is lowercased with a trailing `Config` dropped, and
* the `Config`/`ModuleConfig` container is dropped: `ModuleConfig.MQTTConfig.address` is `schema_mqtt_address`.
diff --git a/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaStringsSync.kt b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaStringsSync.kt
index e168cfbe53..da85db7be6 100644
--- a/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaStringsSync.kt
+++ b/schema-strings/src/main/kotlin/org/meshtastic/schemastrings/SchemaStringsSync.kt
@@ -17,13 +17,15 @@
package org.meshtastic.schemastrings
import java.io.File
+import java.time.Year
/** The repository view of the sync: where the files live, what the English file should contain, how to write it. */
-class SchemaStringsSync(rootDir: File) {
+class SchemaStringsSync(private val rootDir: File) {
private val valuesRoot = rootDir.resolve("core/resources/src/commonMain/composeResources")
private val catalog = rootDir.resolve("gradle/libs.versions.toml")
val englishStrings: File = valuesRoot.resolve("values/$HAND_WRITTEN")
val englishSchemaStrings: File = valuesRoot.resolve("values/$GENERATED")
+ val enumLabels: File = rootDir.resolve("core/model/src/commonMain/kotlin/$GENERATED_KT")
/** Every hand-written strings file: the English one and one per locale. */
val handWrittenFiles: List
@@ -51,6 +53,25 @@ class SchemaStringsSync(rootDir: File) {
notice = notice(catalogPin),
)
+ /**
+ * The Kotlin accessors the registry implies. The licence header is the committed file's own where there is one, so
+ * a rerun does not restamp a year Spotless is happy with - everything before `package` is that header.
+ */
+ fun expectedEnumLabels(): String = EnumLabelsKt.render(
+ header = enumLabels.takeIf { it.isFile }?.let { licenceHeader(it.readText()) } ?: freshLicenceHeader(),
+ pin = catalogPin,
+ enums = SchemaCatalog.labelledEnums(),
+ )
+
+ /** The pin the committed Kotlin file says it was built from, or null when it is absent or unmarked. */
+ val recordedEnumLabelsPin: String?
+ get() = enumLabels.takeIf { it.isFile }?.let { noticePin.find(it.readText())?.groupValues?.get(1) }
+
+ private fun licenceHeader(kotlin: String): String = kotlin.substringBefore("package ").substringBefore("// ")
+
+ private fun freshLicenceHeader(): String =
+ rootDir.resolve("config/spotless/copyright.kt").readText().replace("\$YEAR", YEAR) + "\n"
+
/** Schema-named keys that someone wrote into a hand-written file, by file. Those files must never carry one. */
fun strays(): Map> = handWrittenFiles
.associateWith { file -> schemaKeys(StringsXml.bodies(file.readText()).keys) }
@@ -59,7 +80,9 @@ class SchemaStringsSync(rootDir: File) {
fun apply(): String {
val text = expectedEnglish()
englishSchemaStrings.writeText(text)
- return "wrote ${englishSchemaStrings.path} (${StringsXml.bodies(text).size} strings from protobufs $catalogPin)"
+ enumLabels.writeText(expectedEnumLabels())
+ return "wrote ${StringsXml.bodies(text).size} strings and " +
+ "${SchemaCatalog.labelledEnums().size} enum accessors from protobufs $catalogPin"
}
private fun schemaKeys(names: Set): Set = names.filterTo(HashSet()) { it.startsWith("schema_") }
@@ -67,6 +90,8 @@ class SchemaStringsSync(rootDir: File) {
companion object {
const val HAND_WRITTEN = "strings.xml"
const val GENERATED = "schema_strings.xml"
+ const val GENERATED_KT = "org/meshtastic/core/model/SchemaEnumLabels.kt"
+ private val YEAR = Year.now().toString()
private val catalogPinLine = Regex("""^meshtastic-protobufs = "([^"]+)"$""", RegexOption.MULTILINE)
private val noticePin = Regex("""from org\.meshtastic:protobufs (\S+)\.""")
diff --git a/schema-strings/src/test/kotlin/org/meshtastic/schemastrings/RepositorySyncTest.kt b/schema-strings/src/test/kotlin/org/meshtastic/schemastrings/RepositorySyncTest.kt
index 19ce64b35d..8b6eac57f9 100644
--- a/schema-strings/src/test/kotlin/org/meshtastic/schemastrings/RepositorySyncTest.kt
+++ b/schema-strings/src/test/kotlin/org/meshtastic/schemastrings/RepositorySyncTest.kt
@@ -51,6 +51,54 @@ class RepositorySyncTest {
)
}
+ @Test
+ fun `the generated Kotlin records the pin it was built from`() {
+ assertNotNull(
+ sync.recordedEnumLabelsPin,
+ "core/model SchemaEnumLabels.kt carries no pin: run ./gradlew :schema-strings:sync",
+ )
+ }
+
+ /**
+ * Compares the accessors the file declares rather than its bytes: Spotless reformats what the sync writes, so its
+ * text is the formatter's and only the set of enums and prefixes is the sync's to promise.
+ */
+ @Test
+ fun `at a matching pin the generated Kotlin declares the accessors the registry implies`() {
+ if (sync.recordedEnumLabelsPin != sync.catalogPin) {
+ println("protobufs moved to ${sync.catalogPin}; scheduled-updates re-syncs SchemaEnumLabels.kt")
+ return
+ }
+ val committed =
+ EnumLabelsKt.branchPattern
+ .findAll(sync.enumLabels.readText())
+ .map { Triple(it.groupValues[1], it.groupValues[2], it.groupValues[3]) }
+ .toSet()
+ val expected =
+ SchemaCatalog.labelledEnums()
+ .flatMap { enum ->
+ listOfNotNull(
+ Triple(enum.path, "Label", enum.prefix),
+ Triple(enum.path, "Description", enum.prefix).takeIf { enum.hasDescriptions },
+ )
+ }
+ .toSet()
+
+ assertEquals(expected, committed, "core/model SchemaEnumLabels.kt is stale: run ./gradlew :schema-strings:sync")
+ }
+
+ @Test
+ fun `every labelled enum builds a prefix the generated strings carry`() {
+ val strings = StringsXml.bodies(sync.englishSchemaStrings.readText()).keys
+ val missing = SchemaCatalog.labelledEnums().filter { enum -> strings.none { it.startsWith(enum.prefix) } }
+
+ assertTrue(
+ missing.isEmpty(),
+ "these enums build a resource prefix nothing in schema_strings.xml matches: " +
+ missing.joinToString { "${it.path} (${it.prefix})" },
+ )
+ }
+
@Test
fun `no schema key is written by hand`() {
val strays = sync.strays()