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) }
+ }
+}