diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f338a72478..3f65eb3ed9 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1088,7 +1088,9 @@ max ### MESH ### mesh_beacon mesh_beacon_broadcast +mesh_beacon_broadcast_requires_preset mesh_beacon_broadcast_summary +mesh_beacon_channel_number mesh_beacon_channels_title mesh_beacon_interval mesh_beacon_interval_error @@ -1097,26 +1099,25 @@ mesh_beacon_listen mesh_beacon_listen_summary mesh_beacon_message mesh_beacon_message_error +mesh_beacon_no_channels mesh_beacon_notification_body mesh_beacon_notification_title mesh_beacon_offer_add mesh_beacon_offer_channel -mesh_beacon_offer_channel_key mesh_beacon_offer_channel_name mesh_beacon_offer_discover mesh_beacon_offer_dismiss mesh_beacon_offer_from_unknown mesh_beacon_offer_join mesh_beacon_offer_preset -mesh_beacon_offer_preset_setting mesh_beacon_offer_region -mesh_beacon_offer_region_setting mesh_beacon_offer_signal mesh_beacon_offer_title mesh_beacon_on_preset -mesh_beacon_on_region mesh_beacon_preset_indicator mesh_beacon_preset_none +mesh_beacon_region_label +mesh_beacon_region_required mesh_beacon_target mesh_beacon_target_add mesh_beacon_target_channel_index diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index de0d8cb405..93dec9c996 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1124,38 +1124,39 @@ Mesh Beacon Broadcast a beacon + Beacon broadcasting advertises a modem preset for others to join. This radio uses custom LoRa settings instead of a standard preset, so a beacon could invite people onto settings they cannot hear. Broadcasting can be turned off here, but not on, until the radio uses a standard modem preset. Periodically advertise this mesh to nearby nodes + Channel %1$d Beacon channels - Broadcast interval (seconds) - Minimum %1$d seconds + Broadcast interval + Choose an interval of at least one hour Mesh invitations Listen for beacons Capture invitations advertised by nearby meshes Beacon message Maximum %1$d bytes + No channels available A nearby mesh invited you to join Mesh invitation Add channel Channel: %1$s - Offered channel key (base64) - Offered channel name + Offered channel Discover Dismiss From an unknown node Join Preset: %1$s - Offered preset Region: %1$s - Offered region Signal: %1$s / %2$s Mesh invitation Transmit preset - Transmit region Advertised by a nearby beacon None + Region + Set your radio's region before setting up a beacon. Target %1$d Add target - Channel index + Channel Remove target Broadcast targets Mesh Map Location diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index 23b7c490d2..281dcd18e9 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -83,8 +83,10 @@ private val CODING_RATE_RANGE = 5..8 * Builds the modem-preset dropdown items: hide presets the target firmware's preset table doesn't have yet * ([Capabilities.supportsPreset]), restrict to the region's legal presets (R7), then always keep the current selection * present (disabled) so the field is never blank when the device's preset is illegal for the region. + * + * Internal (not private): reused by the Mesh Beacon config editor's target-row preset pickers (design#140). */ -private fun buildPresetItems( +internal fun buildPresetItems( presetConstraint: RegionPresetConstraint?, presetsGated: Boolean, selectedPreset: ModemPreset, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt index bb93e8e0b4..d137013c78 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt @@ -16,95 +16,183 @@ */ package org.meshtastic.feature.settings.radio.component +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import okio.ByteString.Companion.decodeBase64 +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.Channel +import org.meshtastic.core.model.RegionInfo +import org.meshtastic.core.model.RegionPresetConstraint import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.config_lora_modem_preset_licensed_summary import org.meshtastic.core.resources.mesh_beacon import org.meshtastic.core.resources.mesh_beacon_broadcast +import org.meshtastic.core.resources.mesh_beacon_broadcast_requires_preset import org.meshtastic.core.resources.mesh_beacon_broadcast_summary +import org.meshtastic.core.resources.mesh_beacon_channel_number import org.meshtastic.core.resources.mesh_beacon_interval import org.meshtastic.core.resources.mesh_beacon_interval_error import org.meshtastic.core.resources.mesh_beacon_listen import org.meshtastic.core.resources.mesh_beacon_listen_summary import org.meshtastic.core.resources.mesh_beacon_message -import org.meshtastic.core.resources.mesh_beacon_offer_channel_key +import org.meshtastic.core.resources.mesh_beacon_no_channels import org.meshtastic.core.resources.mesh_beacon_offer_channel_name -import org.meshtastic.core.resources.mesh_beacon_offer_preset_setting -import org.meshtastic.core.resources.mesh_beacon_offer_region_setting import org.meshtastic.core.resources.mesh_beacon_on_preset -import org.meshtastic.core.resources.mesh_beacon_on_region +import org.meshtastic.core.resources.mesh_beacon_region_label +import org.meshtastic.core.resources.mesh_beacon_region_required +import org.meshtastic.core.resources.mesh_beacon_target import org.meshtastic.core.resources.mesh_beacon_target_add import org.meshtastic.core.resources.mesh_beacon_target_channel_index import org.meshtastic.core.resources.mesh_beacon_target_remove import org.meshtastic.core.resources.mesh_beacon_targets +import org.meshtastic.core.resources.plurals_seconds +import org.meshtastic.core.ui.component.DropDownItem import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference +import org.meshtastic.core.ui.component.RegularPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.feature.settings.util.FixedUpdateIntervals +import org.meshtastic.feature.settings.util.IntervalConfiguration +import org.meshtastic.feature.settings.util.toDisplayString import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config.LoRaConfig.RegionCode import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig private const val MESSAGE_MAX_BYTES = 100 -private const val CHANNEL_NAME_MAX_BYTES = 11 // ChannelSettings.name max_size:12 (buffer incl. null terminator) -private const val MIN_INTERVAL_SECS = 3600 +private val MIN_INTERVAL_SECS = FixedUpdateIntervals.ONE_HOUR.value.toInt() + +// Sentinel DropDownItem values (design#140's never-render-blank rule): -1 marks a stale stored value that no longer +// matches a radio channel, -2 marks an explicit "no channels" placeholder row. Never a real channel_index. +private const val NO_CHANNELS_ITEM_VALUE = -2 private fun Int.withFlag(flag: Int, on: Boolean): Int = if (on) this or flag else this and flag.inv() private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0 /** - * Editor for `ModuleConfig.MeshBeaconConfig` (Apple 014-mesh-beacons US2 / FR-009–FR-014). Reads from the connect-time - * config sync (there is no `ModuleConfigType` beacon value to request per-module) and writes via - * `AdminMessage.setModuleConfig`. Flag edits are read-modify-write so `FLAG_LEGACY_SPLIT` and any unknown bits survive. + * Editor for `ModuleConfig.MeshBeaconConfig` (design#140, Android issue #6931). Reads from the connect-time config sync + * (there is no `ModuleConfigType` beacon value to request per-module) and writes via `AdminMessage.setModuleConfig`. + * Flag edits are read-modify-write so unknown bits survive. * - * The repeated `broadcast_targets` list ([BroadcastTargetsCard]) is the only way to name a beacon destination. An empty - * list means one beacon on the node's running preset and region over the primary channel. + * The region and offered/transmit preset are never user-chosen here: the radio's own LoRa region and configured preset + * are always stamped in on save (`stampBeaconConfigForSave`), so the beacon can never transmit region or preset + * information the radio itself does not use. The repeated `broadcast_targets` list ([BroadcastTargetsCard]) is the only + * way to name extra beacon destinations beyond the offered channel; an empty list sends one beacon on that channel + * alone. */ @Suppress("LongMethod") @Composable fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, modifier: Modifier = Modifier) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val meshBeaconConfig = state.moduleConfig.mesh_beacon ?: MeshBeaconConfig() + val radioLora = state.radioConfig.lora + + if (radioLora == null || radioLora.region == RegionCode.UNSET) { + RadioConfigScreenList( + modifier = modifier, + title = stringResource(Res.string.mesh_beacon), + onBack = onBack, + configState = rememberConfigState(initialValue = meshBeaconConfig), + enabled = false, + responseState = state.responseState, + onDismissPacketResponse = viewModel::clearPacketResponse, + onSave = {}, + ) { + item { + TitledCard(title = stringResource(Res.string.mesh_beacon)) { + Text( + text = stringResource(Res.string.mesh_beacon_region_required), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + return + } + val formState = rememberConfigState(initialValue = meshBeaconConfig) val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value - // Only require a valid interval when broadcasting is actually on — otherwise a default (interval=0) config could - // never be saved, blocking even a listen-only toggle (FR-013 applies to the broadcast, not the whole form). + // Only require a valid interval when broadcasting is actually on, otherwise a default (interval=0) config could + // never be saved, blocking even a listen-only toggle. val broadcastEnabled = formState.value.flags.hasFlag(broadcastFlag) val intervalValid = !broadcastEnabled || formState.value.broadcast_interval_secs >= MIN_INTERVAL_SECS + // Gates the BROADCAST half of the editor when the radio uses custom LoRa params (design#140 Q1): stamping a + // stale modem_preset on save would mint a live on-air lie. + val storedBroadcastEnabled = meshBeaconConfig.flags.hasFlag(broadcastFlag) + val broadcastGate = + remember(state.connected, radioLora.use_preset, storedBroadcastEnabled) { + beaconBroadcastGate(state.connected, radioLora.use_preset, storedBroadcastEnabled) + } + + // Same region->preset legality reuse as LoRaConfigItemList (R7), but always constrained (design#140 behavior 2): + // a beacon never falls back to the raw unconstrained preset list. + val capabilities = remember(state.metadata?.firmware_version) { Capabilities(state.metadata?.firmware_version) } + val regionPresetMap = if (capabilities.supportsLoraRegionPresetMap) state.loraRegionPresetMap else null + val presetConstraint: RegionPresetConstraint = + remember(regionPresetMap, radioLora.region) { beaconPresetConstraint(regionPresetMap, radioLora.region) } + val presetsGated = presetConstraint.isGated(state.localIsLicensed) + + // Placeholder secondary slots are excluded (design#140 Q2); the primary is always kept, even blank, since a + // blank-name/zero-psk primary is a legal cleartext channel, not padding. + val selectableChannels = remember(state.channelList) { selectableBeaconChannels(state.channelList) } + // Display labels reuse Channel.name's empty-primary-name -> preset display name conversion (e.g. "LongFast"). + val channelItems = + remember(selectableChannels, radioLora) { + selectableChannels.map { (index, settings) -> DropDownItem(index, Channel(settings, radioLora).name) } + } + RadioConfigScreenList( modifier = modifier, title = stringResource(Res.string.mesh_beacon), onBack = onBack, configState = formState, - // Block saving an out-of-range interval (FR-013); message length is capped inline by the field itself. - enabled = state.connected && intervalValid, + enabled = state.connected, + // A beacon needs a channel to offer (behavior 3) and, when broadcasting, an interval of at least one hour; + // both are moot when the broadcast half is gated off (design#140 Q1), since save then preserves the stored + // broadcast fields verbatim. + saveEnabled = + meshBeaconSaveEnabled(state.connected, radioLora.use_preset, intervalValid, state.channelList.isNotEmpty()), responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { - // Drop the offered channel when no name is set (a text-only beacon offers no channel). - val offered = it.broadcast_offer_channel?.takeIf { c -> c.name.isNotBlank() } - viewModel.setModuleConfig(ModuleConfig(mesh_beacon = it.copy(broadcast_offer_channel = offered))) + val stamped = stampBeaconConfigForSave(it, meshBeaconConfig, radioLora, state.channelList) + viewModel.setModuleConfig(ModuleConfig(mesh_beacon = stamped)) }, ) { item { TitledCard(title = stringResource(Res.string.mesh_beacon)) { + RegularPreference( + title = stringResource(Res.string.mesh_beacon_region_label), + subtitle = + RegionInfo.fromRegionCode(radioLora.region)?.description + ?: radioLora.region.name.replace('_', ' '), + onClick = {}, + enabled = false, + ) + HorizontalDivider() SwitchPreference( title = stringResource(Res.string.mesh_beacon_listen), summary = stringResource(Res.string.mesh_beacon_listen_summary), @@ -120,137 +208,190 @@ fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, title = stringResource(Res.string.mesh_beacon_broadcast), summary = stringResource(Res.string.mesh_beacon_broadcast_summary), checked = formState.value.flags.hasFlag(broadcastFlag), - enabled = state.connected, + enabled = broadcastGate.toggleEnabled, onCheckedChange = { formState.value = formState.value.copy(flags = formState.value.flags.withFlag(broadcastFlag, it)) }, containerColor = CardDefaults.cardColors().containerColor, ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.mesh_beacon_message), - value = formState.value.broadcast_message, - maxSize = MESSAGE_MAX_BYTES, - enabled = state.connected, - isError = false, - keyboardOptions = KeyboardOptions.Default, - keyboardActions = KeyboardActions.Default, - onValueChanged = { formState.value = formState.value.copy(broadcast_message = it) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.mesh_beacon_interval), - value = formState.value.broadcast_interval_secs, - enabled = state.connected, - isError = !intervalValid, - keyboardActions = KeyboardActions.Default, - summary = - if (intervalValid) { - null - } else { - stringResource(Res.string.mesh_beacon_interval_error, MIN_INTERVAL_SECS) - }, - onValueChanged = { formState.value = formState.value.copy(broadcast_interval_secs = it) }, - ) + if (!radioLora.use_preset) { + HorizontalDivider() + Text( + text = stringResource(Res.string.mesh_beacon_broadcast_requires_preset), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + if (broadcastGate.sectionsVisible) { + HorizontalDivider() + EditTextPreference( + title = stringResource(Res.string.mesh_beacon_message), + value = formState.value.broadcast_message, + maxSize = MESSAGE_MAX_BYTES, + enabled = broadcastGate.sectionsEnabled, + isError = false, + keyboardOptions = KeyboardOptions.Default, + keyboardActions = KeyboardActions.Default, + onValueChanged = { formState.value = formState.value.copy(broadcast_message = it) }, + ) + HorizontalDivider() + val intervalOptions = remember { IntervalConfiguration.MESH_BEACON_BROADCAST.allowedIntervals } + val storedIntervalSecs = formState.value.broadcast_interval_secs.toLong() + val intervalFallback = + remember(storedIntervalSecs, intervalOptions) { + beaconIntervalFallback(storedIntervalSecs, intervalOptions) + } + val intervalItems = + intervalOptions.map { DropDownItem(value = it.value, label = it.toDisplayString()) } + + listOfNotNull( + intervalFallback?.let { fallbackSecs -> + DropDownItem( + value = fallbackSecs, + label = + pluralStringResource( + Res.plurals.plurals_seconds, + fallbackSecs.toInt(), + fallbackSecs.toInt(), + ), + enabled = false, + ) + }, + ) + DropDownPreference( + title = stringResource(Res.string.mesh_beacon_interval), + summary = + if (intervalValid) { + null + } else { + stringResource(Res.string.mesh_beacon_interval_error) + }, + items = intervalItems, + selectedItem = storedIntervalSecs, + enabled = broadcastGate.sectionsEnabled, + onItemSelected = { + formState.value = formState.value.copy(broadcast_interval_secs = it.toInt()) + }, + ) + } } } - item { - TitledCard(title = stringResource(Res.string.mesh_beacon_offer_channel_name)) { - EditTextPreference( - title = stringResource(Res.string.mesh_beacon_offer_channel_name), - value = formState.value.broadcast_offer_channel?.name.orEmpty(), - maxSize = CHANNEL_NAME_MAX_BYTES, - enabled = state.connected, - isError = false, - keyboardOptions = KeyboardOptions.Default, - keyboardActions = KeyboardActions.Default, - onValueChanged = { - val current = formState.value.broadcast_offer_channel ?: ChannelSettings() - formState.value = formState.value.copy(broadcast_offer_channel = current.copy(name = it)) - }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.mesh_beacon_offer_channel_key), - value = formState.value.broadcast_offer_channel?.psk?.base64().orEmpty(), - enabled = state.connected, - isError = false, - keyboardOptions = KeyboardOptions.Default, - keyboardActions = KeyboardActions.Default, - onValueChanged = { encoded -> - val psk = encoded.decodeBase64() ?: return@EditTextPreference - val current = formState.value.broadcast_offer_channel ?: ChannelSettings() - formState.value = formState.value.copy(broadcast_offer_channel = current.copy(psk = psk)) - }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.mesh_beacon_offer_region_setting), - selectedItem = formState.value.broadcast_offer_region, - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(broadcast_offer_region = it) }, - ) - HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.mesh_beacon_offer_preset_setting), - selectedItem = formState.value.broadcast_offer_preset ?: ModemPreset.LONG_FAST, - enabled = state.connected, - onItemSelected = { formState.value = formState.value.copy(broadcast_offer_preset = it) }, + if (broadcastGate.sectionsVisible) { + item { + TitledCard(title = stringResource(Res.string.mesh_beacon_offer_channel_name)) { + OfferChannelPreference( + offerChannel = formState.value.broadcast_offer_channel, + channelList = state.channelList, + selectableChannels = selectableChannels, + radioLora = radioLora, + channelItems = channelItems, + enabled = broadcastGate.sectionsEnabled, + onChannelSelect = { chosen -> + formState.value = formState.value.copy(broadcast_offer_channel = chosen) + }, + ) + } + } + item { + BroadcastTargetsCard( + targets = formState.value.broadcast_targets, + enabled = broadcastGate.sectionsEnabled, + channelItems = channelItems, + currentPreset = radioLora.modem_preset, + presetConstraint = presetConstraint, + presetsGated = presetsGated, + capabilities = capabilities, + onChange = { formState.value = formState.value.copy(broadcast_targets = it) }, ) } } - item { - BroadcastTargetsCard( - targets = formState.value.broadcast_targets, - enabled = state.connected, - onChange = { formState.value = formState.value.copy(broadcast_targets = it) }, - ) - } } } /** - * Editor for the repeated `broadcast_targets` list (Apple FR-014). Each target carries its own region/preset and an - * optional `channel_index`; rows can be added and removed. One beacon copy is sent per distinct destination. + * Picker for the required offered channel (design#140 behavior 3): one of the radio's own channels, defaulting to the + * primary when unset. Placeholder slots are excluded (design#140 Q2, [selectableChannels]); a stored channel the radio + * no longer has (or that only matches a placeholder) is kept selected as a disabled fallback item, and an empty channel + * list shows an explicit disabled placeholder row, so the picker never renders blank. + */ +@Composable +internal fun OfferChannelPreference( + offerChannel: ChannelSettings?, + channelList: List, + selectableChannels: List>, + radioLora: Config.LoRaConfig, + channelItems: List>, + enabled: Boolean, + onChannelSelect: (ChannelSettings) -> Unit, +) { + val matchedIndex = + remember(offerChannel, selectableChannels) { beaconOfferChannelIndex(offerChannel, selectableChannels) } + val noChannels = channelItems.isEmpty() + val offerItems = + when { + offerChannel != null && matchedIndex == null -> { + val staleLabel = offerChannel.name.ifBlank { Channel(offerChannel, radioLora).name } + channelItems + DropDownItem(value = -1, label = staleLabel, enabled = false) + } + + noChannels -> + listOf( + DropDownItem( + value = NO_CHANNELS_ITEM_VALUE, + label = stringResource(Res.string.mesh_beacon_no_channels), + enabled = false, + ), + ) + + else -> channelItems + } + val selectedChannelIndex = + when { + matchedIndex != null -> matchedIndex + offerChannel != null -> -1 + noChannels -> NO_CHANNELS_ITEM_VALUE + else -> 0 + } + DropDownPreference( + title = stringResource(Res.string.mesh_beacon_offer_channel_name), + items = offerItems, + selectedItem = selectedChannelIndex, + enabled = enabled, + onItemSelected = { index -> channelList.getOrNull(index)?.let(onChannelSelect) }, + ) +} + +/** + * Editor for the repeated `broadcast_targets` list: extra beacon destinations beyond the offered channel. Each row + * picks one of the radio's own channels ([channelItems]) and a preset filtered by [presetConstraint] (design#140 + * behaviors 2 and 7); region is no longer a row concept (behavior 1), the radio's own region applies to every target. */ @Composable private fun BroadcastTargetsCard( targets: List, enabled: Boolean, + channelItems: List>, + currentPreset: ModemPreset, + presetConstraint: RegionPresetConstraint, + presetsGated: Boolean, + capabilities: Capabilities, onChange: (List) -> Unit, ) { TitledCard(title = stringResource(Res.string.mesh_beacon_targets)) { targets.forEachIndexed { index, target -> if (index > 0) HorizontalDivider() - DropDownPreference( - title = stringResource(Res.string.mesh_beacon_on_region), - selectedItem = target.region, + BroadcastTargetRow( + index = index, + target = target, enabled = enabled, - onItemSelected = { sel -> - onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(region = sel) else t }) - }, + channelItems = channelItems, + currentPreset = currentPreset, + presetConstraint = presetConstraint, + presetsGated = presetsGated, + capabilities = capabilities, + onChange = { updated -> onChange(targets.mapIndexed { i, t -> if (i == index) updated(t) else t }) }, + onRemove = { onChange(targets.filterIndexed { i, _ -> i != index }) }, ) - DropDownPreference( - title = stringResource(Res.string.mesh_beacon_on_preset), - selectedItem = target.preset ?: ModemPreset.LONG_FAST, - enabled = enabled, - onItemSelected = { sel -> - onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(preset = sel) else t }) - }, - ) - SignedIntegerEditTextPreference( - title = stringResource(Res.string.mesh_beacon_target_channel_index), - value = target.channel_index ?: 0, - enabled = enabled, - keyboardActions = KeyboardActions.Default, - onValueChanged = { v -> - onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(channel_index = v) else t }) - }, - ) - TextButton(onClick = { onChange(targets.filterIndexed { i, _ -> i != index }) }, enabled = enabled) { - Text(stringResource(Res.string.mesh_beacon_target_remove)) - } } HorizontalDivider() TextButton(onClick = { onChange(targets + MeshBeaconConfig.BroadcastTarget()) }, enabled = enabled) { @@ -258,3 +399,61 @@ private fun BroadcastTargetsCard( } } } + +/** + * One row of [BroadcastTargetsCard]: a channel picker (fallback-safe, design#140 Q2) and a constrained preset picker. + */ +@Composable +private fun BroadcastTargetRow( + index: Int, + target: MeshBeaconConfig.BroadcastTarget, + enabled: Boolean, + channelItems: List>, + currentPreset: ModemPreset, + presetConstraint: RegionPresetConstraint, + presetsGated: Boolean, + capabilities: Capabilities, + onChange: (transform: (MeshBeaconConfig.BroadcastTarget) -> MeshBeaconConfig.BroadcastTarget) -> Unit, + onRemove: () -> Unit, +) = Column { + Text( + text = stringResource(Res.string.mesh_beacon_target, index + 1), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.titleSmall, + ) + val rowChannelIndex = target.channel_index ?: 0 + val rowChannelItems = + if (channelItems.none { it.value == rowChannelIndex }) { + val fallback = + DropDownItem( + value = rowChannelIndex, + label = stringResource(Res.string.mesh_beacon_channel_number, rowChannelIndex), + enabled = false, + ) + channelItems + fallback + } else { + channelItems + } + DropDownPreference( + title = stringResource(Res.string.mesh_beacon_target_channel_index), + items = rowChannelItems, + selectedItem = rowChannelIndex, + enabled = enabled, + onItemSelected = { channelIndex -> onChange { selectBeaconTargetChannel(it, channelIndex, currentPreset) } }, + ) + val selectedPreset = target.preset ?: presetConstraint.defaultPreset + val presetItems = + remember(presetConstraint, presetsGated, selectedPreset, capabilities) { + buildPresetItems(presetConstraint, presetsGated, selectedPreset, capabilities) + } + val presetSummary = if (presetsGated) stringResource(Res.string.config_lora_modem_preset_licensed_summary) else null + DropDownPreference( + title = stringResource(Res.string.mesh_beacon_on_preset), + summary = presetSummary, + items = presetItems, + selectedItem = selectedPreset, + enabled = enabled, + onItemSelected = { sel -> onChange { it.copy(preset = sel) } }, + ) + TextButton(onClick = onRemove, enabled = enabled) { Text(stringResource(Res.string.mesh_beacon_target_remove)) } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt new file mode 100644 index 0000000000..266f1bc239 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt @@ -0,0 +1,184 @@ +/* + * 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.feature.settings.radio.component + +import org.meshtastic.core.model.RegionPresetConstraint +import org.meshtastic.core.model.constraintFor +import org.meshtastic.core.model.util.isChannelPlaceholder +import org.meshtastic.feature.settings.util.FixedUpdateIntervals +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.LoRaRegionPresetMap +import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig + +/** + * Presets safe to offer a beacon when the firmware gave no region legality map (design#140 behavior 2): no + * `minFirmware` gate, not deprecated, and a bandwidth narrow enough (<=250 kHz) to fit any region's allocation, + * including the narrowest EU bands. Never the raw, unconstrained preset list. + */ +internal val CONSERVATIVE_BEACON_PRESETS = + listOf( + ModemPreset.LONG_FAST, + ModemPreset.LONG_MODERATE, + ModemPreset.MEDIUM_SLOW, + ModemPreset.MEDIUM_FAST, + ModemPreset.SHORT_SLOW, + ModemPreset.SHORT_FAST, + ) + +/** + * Resolves the preset constraint a beacon's target rows should be filtered by: the firmware's advertised region->preset + * map when it has one, otherwise [CONSERVATIVE_BEACON_PRESETS]. Unlike [constraintFor] alone, this never returns null, + * so a beacon row is never left showing the full unconstrained preset list. + */ +internal fun beaconPresetConstraint( + regionPresetMap: LoRaRegionPresetMap?, + region: Config.LoRaConfig.RegionCode, +): RegionPresetConstraint = regionPresetMap.constraintFor(region) + ?: RegionPresetConstraint( + presets = CONSERVATIVE_BEACON_PRESETS, + defaultPreset = ModemPreset.LONG_FAST, + licensedOnly = false, + ) + +/** + * Returns [storedSeconds] when it matches none of [allowed], so the caller can show it as a disabled fallback item + * (design#140's never-render-blank rule); null when the stored value is already one of the allowed intervals. + */ +internal fun beaconIntervalFallback(storedSeconds: Long, allowed: List): Long? = + storedSeconds.takeUnless { seconds -> allowed.any { it.value == seconds } } + +/** + * Matches [offerChannel] to its true `channel_index` among [selectableChannels] by name and PSK (a channel's identity + * for beacon purposes); null when [offerChannel] is unset, names a channel the radio no longer has, or only matches a + * placeholder slot excluded from [selectableChannels] (design#140 Q2) -- either way the caller falls back to showing it + * as a stale disabled item rather than a spuriously-selected placeholder. + */ +internal fun beaconOfferChannelIndex( + offerChannel: ChannelSettings?, + selectableChannels: List>, +): Int? { + if (offerChannel == null) return null + return selectableChannels + .firstOrNull { (_, settings) -> settings.name == offerChannel.name && settings.psk == offerChannel.psk } + ?.first +} + +/** + * Applies design#140's save-time invariants to [form] before it is written. When [radioLora] uses a standard modem + * preset (`use_preset = true`): the radio's own region (behavior 1) and configured preset (behavior 4) are always + * stamped, never user-chosen; every broadcast target's region is kept in lockstep; and an untouched offered channel + * defaults to the radio's primary channel (behavior 3's required-channel rule), while an already-set offered channel + * (even one that no longer matches a radio channel) is kept. Only the offered channel's name and PSK are carried over, + * never the radio's own channel_index/id/uplink/downlink/module flags, which have no meaning for a channel someone + * else's radio is being invited to join. + * + * When the radio uses custom LoRa parameters (`use_preset = false`), `modem_preset` is meaningless, so every broadcast + * field instead carries over from [stored] verbatim: stamping a stale preset would mint a live on-air lie about what + * the mesh actually runs, and firmware transmits these fields with no validation against the radio's own live + * parameters. Only the flags (listen/broadcast) come from [form]; the editor itself only allows turning broadcast off + * in this state, never on. + */ +internal fun stampBeaconConfigForSave( + form: MeshBeaconConfig, + stored: MeshBeaconConfig, + radioLora: Config.LoRaConfig, + channelList: List, +): MeshBeaconConfig = if (radioLora.use_preset) { + form.copy( + broadcast_offer_region = radioLora.region, + broadcast_offer_preset = radioLora.modem_preset, + broadcast_offer_channel = + (form.broadcast_offer_channel ?: channelList.getOrNull(0))?.let { + ChannelSettings(name = it.name, psk = it.psk) + }, + broadcast_targets = form.broadcast_targets.map { it.copy(region = radioLora.region) }, + ) +} else { + form.copy( + broadcast_message = stored.broadcast_message, + broadcast_interval_secs = stored.broadcast_interval_secs, + broadcast_offer_region = stored.broadcast_offer_region, + broadcast_offer_preset = stored.broadcast_offer_preset, + broadcast_offer_channel = stored.broadcast_offer_channel, + broadcast_targets = stored.broadcast_targets, + ) +} + +/** + * Applies a channel pick to one broadcast target row (design#140 behavior 7): the channel index is always set, and the + * radio's currently-configured preset is preselected only the first time the row gets a channel, never overwriting a + * preset the user already chose. + */ +internal fun selectBeaconTargetChannel( + target: MeshBeaconConfig.BroadcastTarget, + channelIndex: Int, + currentPreset: ModemPreset, +): MeshBeaconConfig.BroadcastTarget = target.copy(channel_index = channelIndex, preset = target.preset ?: currentPreset) + +/** The three broadcast-half gating decisions design#140 Q1 hangs off `radioLora.use_preset` and the STORED flag. */ +internal data class BeaconBroadcastGate( + val sectionsVisible: Boolean, + val sectionsEnabled: Boolean, + val toggleEnabled: Boolean, +) + +/** + * Resolves [BeaconBroadcastGate] for the BROADCAST half of the editor (design#140 Q1). Keyed off + * [storedBroadcastEnabled] (the flag as last saved), not any live form value, so an in-session toggle-off is always + * recoverable: sections stay visible (but non-editable) whenever the radio was already broadcasting, and only fully + * editable when the radio uses a standard modem preset. + */ +internal fun beaconBroadcastGate( + connected: Boolean, + useStandardPreset: Boolean, + storedBroadcastEnabled: Boolean, +): BeaconBroadcastGate { + val visible = useStandardPreset || storedBroadcastEnabled + return BeaconBroadcastGate( + sectionsVisible = visible, + sectionsEnabled = connected && useStandardPreset, + toggleEnabled = connected && visible, + ) +} + +/** + * A beacon needs a channel to offer (behavior 3) and, when broadcasting, an interval of at least one hour; both are + * moot when the broadcast half is gated off (design#140 Q1), since save then preserves the stored broadcast fields + * verbatim regardless of the live channel list or interval. + */ +internal fun meshBeaconSaveEnabled( + connected: Boolean, + useStandardPreset: Boolean, + intervalValid: Boolean, + hasChannels: Boolean, +): Boolean = connected && (!useStandardPreset || (intervalValid && hasChannels)) + +/** + * Filters [channelList] down to the slots a beacon picker should offer (design#140 Q2), pairing each with its true + * `channel_index` so selection keeps firmware slot semantics. Index 0 (primary) is always kept, even blank: a + * blank-name, zero-psk primary is a legal cleartext channel, not padding. Index >= 1 is kept only when it is not + * [isChannelPlaceholder], matching firmware's own slot-liveness test so the picker shows exactly what firmware will + * accept. Placeholders otherwise render as duplicate fake preset names (several identical "LongFast" rows) via + * `Channel.name`'s empty-name fallback, not as blank rows. + */ +internal fun selectableBeaconChannels(channelList: List): List> = + channelList + .withIndex() + .filter { (index, settings) -> index == 0 || !settings.isChannelPlaceholder() } + .map { it.index to it.value } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt index 9bf6625d10..80e1c11216 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt @@ -98,6 +98,7 @@ enum class IntervalConfiguration { BROADCAST_SHORT, BROADCAST_MEDIUM, BROADCAST_LONG, + MESH_BEACON_BROADCAST, NODE_INFO_BROADCAST, DETECTION_SENSOR_MINIMUM, DETECTION_SENSOR_STATE, @@ -165,6 +166,24 @@ enum class IntervalConfiguration { FixedUpdateIntervals.SEVENTY_TWO_HOURS, ) + // Independent from BROADCAST_MEDIUM's list on purpose: a beacon-specific case keeps this screen safe + // from a future edit to BROADCAST_MEDIUM made for its own screen (design#140, one-hour minimum). + MESH_BEACON_BROADCAST -> + listOf( + FixedUpdateIntervals.ONE_HOUR, + FixedUpdateIntervals.TWO_HOURS, + FixedUpdateIntervals.THREE_HOURS, + FixedUpdateIntervals.FOUR_HOURS, + FixedUpdateIntervals.FIVE_HOURS, + FixedUpdateIntervals.SIX_HOURS, + FixedUpdateIntervals.TWELVE_HOURS, + FixedUpdateIntervals.EIGHTEEN_HOURS, + FixedUpdateIntervals.TWENTY_FOUR_HOURS, + FixedUpdateIntervals.THIRTY_SIX_HOURS, + FixedUpdateIntervals.FORTY_EIGHT_HOURS, + FixedUpdateIntervals.SEVENTY_TWO_HOURS, + ) + NODE_INFO_BROADCAST -> listOf( FixedUpdateIntervals.UNSET, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt new file mode 100644 index 0000000000..dc3fcd83e5 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt @@ -0,0 +1,329 @@ +/* + * 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.feature.settings.radio.component + +import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.feature.settings.util.FixedUpdateIntervals +import org.meshtastic.feature.settings.util.IntervalConfiguration +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.LoRaPresetGroup +import org.meshtastic.proto.LoRaRegionPresetMap +import org.meshtastic.proto.LoRaRegionPresets +import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MeshBeaconConfigPolicyTest { + + @Test + fun beaconPresetConstraint_noMap_usesConservativeSet() { + val constraint = beaconPresetConstraint(null, RegionCode.EU_868) + + assertEquals(CONSERVATIVE_BEACON_PRESETS, constraint.presets) + assertTrue(ModemPreset.LONG_FAST in constraint.presets) + assertEquals(false, constraint.licensedOnly) + } + + @Test + fun beaconPresetConstraint_mapPresent_usesFirmwareGroup() { + val map = + LoRaRegionPresetMap( + groups = + listOf( + LoRaPresetGroup( + presets = listOf(ModemPreset.SHORT_FAST, ModemPreset.SHORT_TURBO), + default_preset = ModemPreset.SHORT_FAST, + licensed_only = true, + ), + ), + region_groups = listOf(LoRaRegionPresets(region = RegionCode.US, group_index = 0)), + ) + + val constraint = beaconPresetConstraint(map, RegionCode.US) + + assertEquals(listOf(ModemPreset.SHORT_FAST, ModemPreset.SHORT_TURBO), constraint.presets) + assertEquals(ModemPreset.SHORT_FAST, constraint.defaultPreset) + assertTrue(constraint.licensedOnly) + } + + @Test + fun beaconPresetConstraint_mapPresentButRegionMissing_fallsBackToConservative() { + val map = LoRaRegionPresetMap(groups = emptyList(), region_groups = emptyList()) + + val constraint = beaconPresetConstraint(map, RegionCode.JP) + + assertEquals(CONSERVATIVE_BEACON_PRESETS, constraint.presets) + } + + @Test + fun beaconIntervalFallback_storedValueMatchesAllowed_returnsNull() { + val allowed = IntervalConfiguration.MESH_BEACON_BROADCAST.allowedIntervals + + val fallback = beaconIntervalFallback(FixedUpdateIntervals.ONE_HOUR.value, allowed) + + assertNull(fallback) + } + + @Test + fun beaconIntervalFallback_neverConfiguredSentinelZero_isKeptVisible() { + val allowed = IntervalConfiguration.MESH_BEACON_BROADCAST.allowedIntervals + + val fallback = beaconIntervalFallback(0L, allowed) + + assertEquals(0L, fallback) + } + + @Test + fun beaconIntervalFallback_nonstandardStoredValue_isKeptVisible() { + val allowed = IntervalConfiguration.MESH_BEACON_BROADCAST.allowedIntervals + + val fallback = beaconIntervalFallback(5400L, allowed) + + assertEquals(5400L, fallback) + } + + @Test + fun beaconOfferChannelIndex_nullChannel_returnsNull() { + val channelList = listOf(ChannelSettings(name = "Primary")) + + assertNull(beaconOfferChannelIndex(null, selectableBeaconChannels(channelList))) + } + + @Test + fun beaconOfferChannelIndex_matchByNameAndPsk_returnsIndex() { + val channelList = + listOf( + ChannelSettings(name = "Primary", psk = "a".encodeUtf8()), + ChannelSettings(name = "Secondary", psk = "b".encodeUtf8()), + ) + val offer = ChannelSettings(name = "Secondary", psk = "b".encodeUtf8()) + + assertEquals(1, beaconOfferChannelIndex(offer, selectableBeaconChannels(channelList))) + } + + @Test + fun beaconOfferChannelIndex_noRadioChannelMatches_returnsNull() { + val channelList = listOf(ChannelSettings(name = "Primary", psk = "a".encodeUtf8())) + val offer = ChannelSettings(name = "Stale", psk = "z".encodeUtf8()) + + assertNull(beaconOfferChannelIndex(offer, selectableBeaconChannels(channelList))) + } + + @Test + fun beaconOfferChannelIndex_offerMatchesOnlyAPlaceholderSlot_returnsNull() { + // A blank/empty offer's identity coincidentally matches the placeholder secondary in the RAW list; once + // filtered through selectableBeaconChannels that slot is gone, so the match must not go through (design#140 + // Q2) -- otherwise the offer picker would silently select an excluded, never-rendered item. + val channelList = listOf(ChannelSettings(name = "Primary", psk = "a".encodeUtf8()), ChannelSettings()) + val offer = ChannelSettings() + + assertNull(beaconOfferChannelIndex(offer, selectableBeaconChannels(channelList))) + } + + @Test + fun selectableBeaconChannels_placeholderSecondaryExcluded_followingRealSlotKeepsTrueIndex() { + val channelList = + listOf( + ChannelSettings(name = "Primary", psk = "a".encodeUtf8()), + ChannelSettings(), // placeholder secondary: blank name, empty psk + ChannelSettings(name = "Real", psk = "b".encodeUtf8()), + ) + + val selectable = selectableBeaconChannels(channelList) + + assertEquals(listOf(0, 2), selectable.map { it.first }) + } + + @Test + fun selectableBeaconChannels_blankPrimaryKept() { + val channelList = listOf(ChannelSettings()) + + val selectable = selectableBeaconChannels(channelList) + + assertEquals(listOf(0), selectable.map { it.first }) + } + + @Test + fun selectableBeaconChannels_nameOnlySecondaryKept() { + val channelList = listOf(ChannelSettings(name = "Primary"), ChannelSettings(name = "Named")) + + val selectable = selectableBeaconChannels(channelList) + + assertEquals(listOf(0, 1), selectable.map { it.first }) + } + + @Test + fun selectableBeaconChannels_pskOnlySecondaryKept() { + val channelList = listOf(ChannelSettings(name = "Primary"), ChannelSettings(psk = "b".encodeUtf8())) + + val selectable = selectableBeaconChannels(channelList) + + assertEquals(listOf(0, 1), selectable.map { it.first }) + } + + @Test + fun selectableBeaconChannels_oneBytePskCleartextSentinelSecondaryKept() { + // A raw ChannelSettings.psk of size 1 (firmware's cleartext sentinel is a single 0x00 byte) is not padding: + // isChannelPlaceholder only treats size == 0 as placeholder. + val channelList = listOf(ChannelSettings(name = "Primary"), ChannelSettings(psk = "\u0000".encodeUtf8())) + + val selectable = selectableBeaconChannels(channelList) + + assertEquals(listOf(0, 1), selectable.map { it.first }) + } + + @Test + fun stampBeaconConfigForSave_stampsRegionAndPresetOnConfigAndTargets() { + val radioLora = + Config.LoRaConfig(region = RegionCode.EU_868, modem_preset = ModemPreset.MEDIUM_FAST, use_preset = true) + val config = + MeshBeaconConfig( + broadcast_offer_region = RegionCode.US, + broadcast_offer_preset = ModemPreset.LONG_FAST, + broadcast_targets = + listOf( + MeshBeaconConfig.BroadcastTarget(region = RegionCode.JP), + MeshBeaconConfig.BroadcastTarget(region = RegionCode.CN, preset = ModemPreset.SHORT_FAST), + ), + ) + + val stamped = stampBeaconConfigForSave(config, config, radioLora, channelList = emptyList()) + + assertEquals(RegionCode.EU_868, stamped.broadcast_offer_region) + assertEquals(ModemPreset.MEDIUM_FAST, stamped.broadcast_offer_preset) + assertTrue(stamped.broadcast_targets.all { it.region == RegionCode.EU_868 }) + // Preset stamping applies to the offer, not to individual target rows: a target's own preset survives. + assertEquals(ModemPreset.SHORT_FAST, stamped.broadcast_targets[1].preset) + } + + @Test + fun stampBeaconConfigForSave_untouchedOfferChannel_defaultsToPrimary() { + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.LONG_FAST, use_preset = true) + val primary = ChannelSettings(name = "Primary", psk = "a".encodeUtf8()) + val config = MeshBeaconConfig(broadcast_offer_channel = null) + + val stamped = stampBeaconConfigForSave(config, config, radioLora, channelList = listOf(primary)) + + assertEquals("Primary", stamped.broadcast_offer_channel?.name) + assertEquals("a".encodeUtf8(), stamped.broadcast_offer_channel?.psk) + } + + @Test + fun stampBeaconConfigForSave_alreadySetOfferChannel_isKeptEvenIfStale() { + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.LONG_FAST, use_preset = true) + val stale = ChannelSettings(name = "Stale", psk = "z".encodeUtf8()) + val config = MeshBeaconConfig(broadcast_offer_channel = stale) + + val stamped = + stampBeaconConfigForSave(config, config, radioLora, channelList = listOf(ChannelSettings(name = "Primary"))) + + assertEquals("Stale", stamped.broadcast_offer_channel?.name) + assertEquals("z".encodeUtf8(), stamped.broadcast_offer_channel?.psk) + } + + @Test + fun stampBeaconConfigForSave_onlyNameAndPskCarryOverToOfferChannel() { + // A beacon invites a stranger's radio to join this channel: the radio's own index/id/uplink/downlink/module + // flags have no meaning there and must never leak onto someone else's node. + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.LONG_FAST, use_preset = true) + val fullChannel = + ChannelSettings( + name = "Primary", + psk = "a".encodeUtf8(), + channel_num = 5, + id = 42, + uplink_enabled = true, + downlink_enabled = true, + ) + val config = MeshBeaconConfig(broadcast_offer_channel = null) + + val stamped = stampBeaconConfigForSave(config, config, radioLora, channelList = listOf(fullChannel)) + + assertEquals(ChannelSettings(name = "Primary", psk = "a".encodeUtf8()), stamped.broadcast_offer_channel) + } + + @Test + fun stampBeaconConfigForSave_customParams_preservesStoredBroadcastFieldsVerbatim() { + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.LONG_FAST, use_preset = false) + val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value + val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value + val stored = + MeshBeaconConfig( + flags = listenFlag or broadcastFlag, + broadcast_message = "Stored message", + broadcast_interval_secs = 3600, + broadcast_offer_region = RegionCode.EU_868, + broadcast_offer_preset = ModemPreset.MEDIUM_FAST, + broadcast_offer_channel = ChannelSettings(name = "Stored", psk = "s".encodeUtf8()), + broadcast_targets = + listOf(MeshBeaconConfig.BroadcastTarget(region = RegionCode.EU_868, channel_index = 1)), + ) + // The form differs on every broadcast field, plus clears the BROADCAST flag (the only edit the gated UI + // actually allows) -- none of the broadcast field edits should survive the save. + val form = + stored.copy( + flags = listenFlag, + broadcast_message = "Edited but discarded", + broadcast_interval_secs = 7200, + broadcast_offer_region = RegionCode.JP, + broadcast_offer_preset = ModemPreset.SHORT_FAST, + broadcast_offer_channel = ChannelSettings(name = "Different"), + broadcast_targets = emptyList(), + ) + + val stamped = + stampBeaconConfigForSave(form, stored, radioLora, channelList = listOf(ChannelSettings(name = "Primary"))) + + assertEquals(stored.broadcast_message, stamped.broadcast_message) + assertEquals(stored.broadcast_interval_secs, stamped.broadcast_interval_secs) + assertEquals(stored.broadcast_offer_region, stamped.broadcast_offer_region) + assertEquals(stored.broadcast_offer_preset, stamped.broadcast_offer_preset) + assertEquals(stored.broadcast_offer_channel, stamped.broadcast_offer_channel) + assertEquals(stored.broadcast_targets, stamped.broadcast_targets) + // Flags come from the form, not the stored config: the listen/broadcast toggle edits are honored. + assertEquals(form.flags, stamped.flags) + } + + @Test + fun selectBeaconTargetChannel_noPresetYet_preselectsCurrentPreset() { + val target = MeshBeaconConfig.BroadcastTarget(preset = null) + + val updated = selectBeaconTargetChannel(target, channelIndex = 2, currentPreset = ModemPreset.SHORT_FAST) + + assertEquals(2, updated.channel_index) + assertEquals(ModemPreset.SHORT_FAST, updated.preset) + } + + @Test + fun selectBeaconTargetChannel_presetAlreadyChosen_isNotOverwritten() { + val target = MeshBeaconConfig.BroadcastTarget(preset = ModemPreset.LONG_MODERATE) + + val updated = selectBeaconTargetChannel(target, channelIndex = 3, currentPreset = ModemPreset.SHORT_FAST) + + assertEquals(3, updated.channel_index) + assertEquals(ModemPreset.LONG_MODERATE, updated.preset) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt new file mode 100644 index 0000000000..50c49a6278 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt @@ -0,0 +1,332 @@ +/* + * 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.feature.settings.radio.component + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.mesh_beacon_broadcast_requires_preset +import org.meshtastic.core.resources.mesh_beacon_no_channels +import org.meshtastic.core.resources.mesh_beacon_region_required +import org.meshtastic.core.resources.save_changes +import org.meshtastic.core.ui.component.DropDownItem +import org.meshtastic.core.ui.component.EditTextPreference +import org.meshtastic.core.ui.component.SwitchPreference +import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Mirrors [LoRaBandwidthUiTest]'s shape: composes [RadioConfigScreenList] directly with a hand-built [ConfigState], + * exercising the same policy functions the real [MeshBeaconConfigScreen] uses (design#140 behaviors 1, 3, 4), without + * needing a full [org.meshtastic.feature.settings.radio.RadioConfigViewModel]. + */ +@OptIn(ExperimentalTestApi::class) +class MeshBeaconConfigUiTest { + + @Test + fun regionUnset_showsBlockedMessageInsteadOfEditor() = runComposeUiTest { + setContent { + AppTheme { + RadioConfigScreenList( + title = "Mesh Beacon", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = rememberConfigState(MeshBeaconConfig()), + enabled = false, + onSave = {}, + ) { + item { + TitledCard(title = "Mesh Beacon") { Text(getString(Res.string.mesh_beacon_region_required)) } + } + } + } + } + + onNodeWithText(getString(Res.string.mesh_beacon_region_required)).assertIsDisplayed() + } + + @Test + fun requiredOfferedChannel_defaultsToPrimaryAndStampsRegionAndPresetOnSave() = runComposeUiTest { + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.MEDIUM_FAST, use_preset = true) + // channel_num deliberately set: proves the save path narrows to name+psk, not the whole ChannelSettings. + val primary = ChannelSettings(name = "Primary", channel_num = 7) + val channelList = listOf(primary) + val initialConfig = MeshBeaconConfig() + lateinit var configState: ConfigState + var savedConfig: MeshBeaconConfig? = null + + setContent { + AppTheme { + configState = rememberConfigState(initialConfig) + RadioConfigScreenList( + title = "Mesh Beacon", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = configState, + enabled = true, + saveEnabled = channelList.isNotEmpty(), + onSave = { savedConfig = stampBeaconConfigForSave(it, initialConfig, radioLora, channelList) }, + ) { + item { + OfferChannelPreference( + offerChannel = configState.value.broadcast_offer_channel, + channelList = channelList, + selectableChannels = selectableBeaconChannels(channelList), + radioLora = radioLora, + channelItems = channelList.mapIndexed { index, s -> DropDownItem(index, s.name) }, + enabled = true, + onChannelSelect = { + configState.value = configState.value.copy(broadcast_offer_channel = it) + }, + ) + } + } + } + } + + // Never touched the picker: mark the form dirty another way, then save with the untouched offer channel. + runOnIdle { configState.value = configState.value.copy(broadcast_message = "hi") } + + onNodeWithText(getString(Res.string.save_changes)).assertIsEnabled().performClick() + + runOnIdle { + assertEquals(ChannelSettings(name = "Primary"), savedConfig?.broadcast_offer_channel) + assertEquals(RegionCode.US, savedConfig?.broadcast_offer_region) + assertEquals(ModemPreset.MEDIUM_FAST, savedConfig?.broadcast_offer_preset) + } + } + + @Test + fun customParamsRadio_storedBroadcastOff_toggleDisabledAndSectionsHidden() = runComposeUiTest { + val radioLora = Config.LoRaConfig(region = RegionCode.US, use_preset = false) + val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value + val storedConfig = MeshBeaconConfig(flags = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value) + lateinit var configState: ConfigState + + setContent { + AppTheme { + configState = rememberConfigState(storedConfig) + val storedBroadcastEnabled = (storedConfig.flags and broadcastFlag) != 0 + val gate = beaconBroadcastGate(connected = true, radioLora.use_preset, storedBroadcastEnabled) + RadioConfigScreenList( + title = "Mesh Beacon", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = configState, + enabled = true, + onSave = {}, + ) { + item { + SwitchPreference( + title = "Broadcast a beacon", + checked = (configState.value.flags and broadcastFlag) != 0, + enabled = gate.toggleEnabled, + onCheckedChange = { + configState.value = + configState.value.copy( + flags = + if (it) { + configState.value.flags or broadcastFlag + } else { + configState.value.flags and broadcastFlag.inv() + }, + ) + }, + ) + Text(getString(Res.string.mesh_beacon_broadcast_requires_preset)) + } + if (gate.sectionsVisible) { + item { Text("Beacon message field") } + } + } + } + } + + onNodeWithText("Broadcast a beacon").assertIsNotEnabled() + onNodeWithText(getString(Res.string.mesh_beacon_broadcast_requires_preset)).assertIsDisplayed() + onNodeWithText("Beacon message field").assertDoesNotExist() + } + + @Test + fun customParamsRadio_storedBroadcastOn_sectionsVisibleButDisabledAndToggleCanSwitchOff() = runComposeUiTest { + val radioLora = Config.LoRaConfig(region = RegionCode.US, use_preset = false) + val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value + val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value + val storedConfig = MeshBeaconConfig(flags = listenFlag or broadcastFlag) + lateinit var configState: ConfigState + + setContent { + AppTheme { + configState = rememberConfigState(storedConfig) + val storedBroadcastEnabled = (storedConfig.flags and broadcastFlag) != 0 + val gate = beaconBroadcastGate(connected = true, radioLora.use_preset, storedBroadcastEnabled) + RadioConfigScreenList( + title = "Mesh Beacon", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = configState, + enabled = true, + onSave = {}, + ) { + item { + SwitchPreference( + title = "Broadcast a beacon", + checked = (configState.value.flags and broadcastFlag) != 0, + enabled = gate.toggleEnabled, + onCheckedChange = { + configState.value = + configState.value.copy( + flags = + if (it) { + configState.value.flags or broadcastFlag + } else { + configState.value.flags and broadcastFlag.inv() + }, + ) + }, + ) + } + if (gate.sectionsVisible) { + item { + EditTextPreference( + title = "Beacon message", + value = configState.value.broadcast_message, + maxSize = 100, + enabled = gate.sectionsEnabled, + isError = false, + keyboardOptions = KeyboardOptions.Default, + keyboardActions = KeyboardActions.Default, + onValueChanged = { configState.value = configState.value.copy(broadcast_message = it) }, + ) + } + } + } + } + } + + onNodeWithText("Broadcast a beacon").assertIsEnabled() + onNodeWithText("Beacon message").assertIsNotEnabled() + + onNodeWithText("Broadcast a beacon").performClick() + + runOnIdle { assertEquals(listenFlag, configState.value.flags) } + } + + @Test + fun emptyChannelList_offerPickerShowsDisabledPlaceholderRow() = runComposeUiTest { + val radioLora = + Config.LoRaConfig(region = RegionCode.US, modem_preset = ModemPreset.LONG_FAST, use_preset = true) + lateinit var configState: ConfigState + + setContent { + AppTheme { + configState = rememberConfigState(MeshBeaconConfig()) + OfferChannelPreference( + offerChannel = configState.value.broadcast_offer_channel, + channelList = emptyList(), + selectableChannels = emptyList(), + radioLora = radioLora, + channelItems = emptyList(), + enabled = true, + onChannelSelect = { configState.value = configState.value.copy(broadcast_offer_channel = it) }, + ) + } + } + + onNodeWithText(getString(Res.string.mesh_beacon_no_channels)).assertIsDisplayed() + } + + @Test + fun customParamsRadio_emptyChannelListListenOnlyEdit_saveButtonEnabled() = runComposeUiTest { + val radioLora = Config.LoRaConfig(region = RegionCode.US, use_preset = false) + val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value + val initialConfig = MeshBeaconConfig() + val emptyChannelList = emptyList() + lateinit var configState: ConfigState + var savedConfig: MeshBeaconConfig? = null + + setContent { + AppTheme { + configState = rememberConfigState(initialConfig) + val intervalValid = true + RadioConfigScreenList( + title = "Mesh Beacon", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = configState, + enabled = true, + saveEnabled = + meshBeaconSaveEnabled( + connected = true, + radioLora.use_preset, + intervalValid, + hasChannels = emptyChannelList.isNotEmpty(), + ), + onSave = { savedConfig = stampBeaconConfigForSave(it, initialConfig, radioLora, emptyChannelList) }, + ) { + item { + SwitchPreference( + title = "Listen for beacons", + checked = (configState.value.flags and listenFlag) != 0, + enabled = true, + onCheckedChange = { + configState.value = + configState.value.copy( + flags = + if (it) { + configState.value.flags or listenFlag + } else { + configState.value.flags and listenFlag.inv() + }, + ) + }, + ) + } + } + } + } + + onNodeWithText("Listen for beacons").performClick() + + onNodeWithText(getString(Res.string.save_changes)).assertIsEnabled().performClick() + + runOnIdle { assertEquals(listenFlag, savedConfig?.flags) } + } +}