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()