feat(settings): align the Mesh Beacon config editor with design#140 (#6963)

This commit is contained in:
James Rich authored and GitHub committed 2026-08-30 18:04:46 +00:00
1 parent 00f84f7181
commit 431f78c4e1
8 files changed
+1204 -137

No files matched your search

+5 -4
View File
@@ -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
@@ -1124,38 +1124,39 @@
<!-- MESH -->
<string name="mesh_beacon">Mesh Beacon</string>
<string name="mesh_beacon_broadcast">Broadcast a beacon</string>
<string name="mesh_beacon_broadcast_requires_preset">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.</string>
<string name="mesh_beacon_broadcast_summary">Periodically advertise this mesh to nearby nodes</string>
<string name="mesh_beacon_channel_number">Channel %1$d</string>
<string name="mesh_beacon_channels_title">Beacon channels</string>
<string name="mesh_beacon_interval">Broadcast interval (seconds)</string>
<string name="mesh_beacon_interval_error">Minimum %1$d seconds</string>
<string name="mesh_beacon_interval">Broadcast interval</string>
<string name="mesh_beacon_interval_error">Choose an interval of at least one hour</string>
<string name="mesh_beacon_invitations_title">Mesh invitations</string>
<string name="mesh_beacon_listen">Listen for beacons</string>
<string name="mesh_beacon_listen_summary">Capture invitations advertised by nearby meshes</string>
<string name="mesh_beacon_message">Beacon message</string>
<string name="mesh_beacon_message_error">Maximum %1$d bytes</string>
<string name="mesh_beacon_no_channels">No channels available</string>
<string name="mesh_beacon_notification_body">A nearby mesh invited you to join</string>
<string name="mesh_beacon_notification_title">Mesh invitation</string>
<string name="mesh_beacon_offer_add">Add channel</string>
<string name="mesh_beacon_offer_channel">Channel: %1$s</string>
<string name="mesh_beacon_offer_channel_key">Offered channel key (base64)</string>
<string name="mesh_beacon_offer_channel_name">Offered channel name</string>
<string name="mesh_beacon_offer_channel_name">Offered channel</string>
<string name="mesh_beacon_offer_discover">Discover</string>
<string name="mesh_beacon_offer_dismiss">Dismiss</string>
<string name="mesh_beacon_offer_from_unknown">From an unknown node</string>
<string name="mesh_beacon_offer_join">Join</string>
<string name="mesh_beacon_offer_preset">Preset: %1$s</string>
<string name="mesh_beacon_offer_preset_setting">Offered preset</string>
<string name="mesh_beacon_offer_region">Region: %1$s</string>
<string name="mesh_beacon_offer_region_setting">Offered region</string>
<string name="mesh_beacon_offer_signal">Signal: %1$s / %2$s</string>
<string name="mesh_beacon_offer_title">Mesh invitation</string>
<string name="mesh_beacon_on_preset">Transmit preset</string>
<string name="mesh_beacon_on_region">Transmit region</string>
<string name="mesh_beacon_preset_indicator">Advertised by a nearby beacon</string>
<string name="mesh_beacon_preset_none">None</string>
<string name="mesh_beacon_region_label">Region</string>
<string name="mesh_beacon_region_required">Set your radio's region before setting up a beacon.</string>
<string name="mesh_beacon_target">Target %1$d</string>
<string name="mesh_beacon_target_add">Add target</string>
<string name="mesh_beacon_target_channel_index">Channel index</string>
<string name="mesh_beacon_target_channel_index">Channel</string>
<string name="mesh_beacon_target_remove">Remove target</string>
<string name="mesh_beacon_targets">Broadcast targets</string>
<string name="mesh_map_location">Mesh Map Location</string>
@@ -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,
@@ -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-009FR-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<ChannelSettings>,
selectableChannels: List<Pair<Int, ChannelSettings>>,
radioLora: Config.LoRaConfig,
channelItems: List<DropDownItem<Int>>,
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<MeshBeaconConfig.BroadcastTarget>,
enabled: Boolean,
channelItems: List<DropDownItem<Int>>,
currentPreset: ModemPreset,
presetConstraint: RegionPresetConstraint,
presetsGated: Boolean,
capabilities: Capabilities,
onChange: (List<MeshBeaconConfig.BroadcastTarget>) -> 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<DropDownItem<Int>>,
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)) }
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<FixedUpdateIntervals>): 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<Pair<Int, ChannelSettings>>,
): 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<ChannelSettings>,
): 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<ChannelSettings>): List<Pair<Int, ChannelSettings>> =
channelList
.withIndex()
.filter { (index, settings) -> index == 0 || !settings.isChannelPlaceholder() }
.map { it.index to it.value }
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<MeshBeaconConfig>
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<MeshBeaconConfig>
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<MeshBeaconConfig>
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<MeshBeaconConfig>
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<ChannelSettings>()
lateinit var configState: ConfigState<MeshBeaconConfig>
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) }
}
}