feat(settings): label enum pickers from the schema (#7298)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-22 15:27:23 +00:00
1 parent 8c22e3b2ae
commit 720c7a0850
20 files changed
+353 -226

No files matched your search

-44
View File
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"]
@@ -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
@@ -16,7 +16,7 @@
-->
<resources>
<!-- Generated by ./gradlew :schema-strings:sync from org.meshtastic:protobufs 2.8.0.114-g8f97d66-SNAPSHOT. Do not edit: change the schema, then run sync. -->
<!-- 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. -->
<string name="schema_ambientlighting_blue">Blue</string>
<string name="schema_ambientlighting_blue_description">The blue level of the ambient lighting LED.</string>
<string name="schema_ambientlighting_current">Current</string>
@@ -1478,13 +1478,6 @@
<string name="range_test_config">Range Test Config</string>
<string name="react">React</string>
<string name="reboot">Reboot</string>
<!-- REBROADCAST -->
<string name="rebroadcast_mode_all_desc">Rebroadcast any observed message, if it was on our private channel or from another mesh with the same lora parameters.</string>
<string name="rebroadcast_mode_all_skip_decoding_desc">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.</string>
<string name="rebroadcast_mode_core_portnums_only_desc">Ignores packets from non-standard portnums such as: TAK, RangeTest, PaxCounter, etc. Only rebroadcasts packets with standard portnums: NodeInfo, Text, Position, Telemetry, and Routing.</string>
<string name="rebroadcast_mode_known_only_desc">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.</string>
<string name="rebroadcast_mode_local_only_desc">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.</string>
<string name="rebroadcast_mode_none_desc">Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role.</string>
<string name="recent_network_devices">Recent Network Devices</string>
<string name="reconnecting">Reconnecting…</string>
<string name="redacted" translatable="false">***[REDACTED]***</string>
@@ -1544,30 +1537,17 @@
<string name="role">Device Role</string>
<string name="role_client">Client</string>
<string name="role_client_base">Client Base</string>
<string name="role_client_base_desc">Treats packets from or to favorited nodes as ROUTER_LATE, and all other packets as CLIENT.</string>
<string name="role_client_desc">App connected or standalone messaging device.</string>
<string name="role_client_hidden">Client Hidden</string>
<string name="role_client_hidden_desc">Device that only broadcasts as needed for stealth or power savings.</string>
<string name="role_client_mute">Client Mute</string>
<string name="role_client_mute_desc">Device that does not forward packets from other devices.</string>
<string name="role_lost_and_found">Lost and Found</string>
<string name="role_lost_and_found_desc">Broadcasts location as message to default channel regularly to assist with device recovery.</string>
<string name="role_repeater">Repeater</string>
<string name="role_repeater_desc">Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in nodes list.</string>
<string name="role_router">Router</string>
<string name="role_router_client">Router Client</string>
<string name="role_router_client_desc">Combination of both ROUTER and CLIENT. Not for mobile devices.</string>
<string name="role_router_desc">Infrastructure node for extending network coverage by relaying messages. Visible in nodes list.</string>
<string name="role_router_late">Router Late</string>
<string name="role_router_late_desc">Infrastructure node that always rebroadcasts packets once but only after all other modes, ensuring additional coverage for local clusters. Visible in nodes list.</string>
<string name="role_sensor">Sensor</string>
<string name="role_sensor_desc">Broadcasts telemetry packets as priority.</string>
<string name="role_tak">TAK</string>
<string name="role_tak_desc">Optimized for ATAK system communication, reduces routine broadcasts.</string>
<string name="role_tak_tracker">TAK Tracker</string>
<string name="role_tak_tracker_desc">Enables automatic TAK PLI broadcasts and reduces routine broadcasts.</string>
<string name="role_tracker">Tracker</string>
<string name="role_tracker_desc">Broadcasts GPS position packets as priority.</string>
<string name="router_role_confirmation_text">I have read the &lt;a href="https://meshtastic.org/docs/configuration/radio/device/#roles"&gt;Device Role Documentation&lt;/a&gt; and the blog post about &lt;a href="http://meshtastic.org/blog/choosing-the-right-device-role"&gt;Choosing The Right Device Role&lt;/a&gt;.</string>
<!-- ROUTING -->
<string name="routing_error_admin_bad_session_key">Admin session expired</string>
@@ -1753,15 +1733,6 @@
<string name="tak">TAK (ATAK)</string>
<string name="tak_config">TAK Configuration</string>
<string name="tak_role">Member Role</string>
<string name="tak_role_forwardobserver">Forward Observer</string>
<string name="tak_role_hq">Headquarters</string>
<string name="tak_role_k9">Doggo (K9)</string>
<string name="tak_role_medic">Medic</string>
<string name="tak_role_rto">Radio Telephone Operator</string>
<string name="tak_role_sniper">Sniper</string>
<string name="tak_role_teamlead">Team Lead</string>
<string name="tak_role_teammember">Team Member</string>
<string name="tak_role_unspecified">Unspecified</string>
<string name="tak_server">TAK Server</string>
<string name="tak_server_channel">TAK Mesh Channel</string>
<string name="tak_server_channel_desc">Meshtastic channel used for outgoing TAK traffic</string>
@@ -1797,21 +1768,6 @@
<string name="tak_server_test_running">Running: %1$s</string>
<string name="tak_server_v1_fallback_notice">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.</string>
<string name="tak_team">Team Color</string>
<string name="tak_team_blue">Blue</string>
<string name="tak_team_brown">Brown</string>
<string name="tak_team_cyan">Cyan</string>
<string name="tak_team_dark_blue">Dark Blue</string>
<string name="tak_team_dark_green">Dark Green</string>
<string name="tak_team_green">Green</string>
<string name="tak_team_magenta">Magenta</string>
<string name="tak_team_maroon">Maroon</string>
<string name="tak_team_orange">Orange</string>
<string name="tak_team_purple">Purple</string>
<string name="tak_team_red">Red</string>
<string name="tak_team_teal">Teal</string>
<string name="tak_team_unspecified_color">Unspecified</string>
<string name="tak_team_white">White</string>
<string name="tak_team_yellow">Yellow</string>
<string name="telemetry">Telemetry</string>
<string name="telemetry_config">Telemetry Config</string>
<string name="temperature">Temp</string>
@@ -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 <T : Enum<T>> 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 <T : Enum<T>> enumEntriesOf(selectedItem: T): List<T>
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() {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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()
@@ -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 =
@@ -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 =
@@ -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()
@@ -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 =
@@ -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()
@@ -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()
@@ -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()
@@ -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,
)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<LabelledEnum>): 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<LabelledEnum>): List<String> =
(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()
}
@@ -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<EnumLabelsKt.LabelledEnum> = generatedTypePaths().mapNotNull { path ->
val type = Class.forName(GENERATED_PACKAGE + path.replace('.', '$'))
val constants = type.enumConstants ?: return@mapNotNull null
val metadata =
constants.filterIsInstance<WireEnum>().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`.
@@ -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<File>
@@ -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<File, Set<String>> = 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<String>): Set<String> = 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+)\.""")
@@ -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()