mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-22 19:55:06 -04:00
feat(settings): align the Mesh Beacon config editor with design#140 (#6963)
This commit is contained in:
1 parent
00f84f7181
commit
431f78c4e1
8 files changed
+1204
-137
No files matched your search
Generated
+5
-4
@@ -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>
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+323
-124
@@ -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<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)) }
|
||||
}
|
||||
+184
@@ -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 }
|
||||
+19
@@ -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,
|
||||
|
||||
+329
@@ -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)
|
||||
}
|
||||
}
|
||||
+332
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user