feat(discovery): Mesh Beacon client with iOS 014-mesh-beacons parity (#6097)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 19:15:56 -05:00
committed by GitHub
parent 005ad1a9d4
commit bf929c20f2
27 changed files with 1289 additions and 76 deletions

View File

@@ -52,6 +52,8 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -101,6 +103,12 @@ class DiscoveryScanEngine(
private var scanScope: CoroutineScope? = null
private var dwellJob: Job? = null
private var originalLoRaConfig: Config.LoRaConfig? = null
/** The radio's primary channel at scan start; restored after a custom-channel target overwrites it (FR-005). */
private var originalPrimaryChannel: ChannelSettings? = null
/** True once a custom-channel target has retuned the primary channel, so restore only writes when needed. */
private var tunedPrimaryChannel: Boolean = false
private var sessionId: Long = 0
/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
@@ -138,14 +146,19 @@ class DiscoveryScanEngine(
// region Public API
/** Convenience overload: scan a list of public presets (each becomes a preset [ScanTarget] labelled by name). */
suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) =
startScanTargets(presets.map { ScanTarget(preset = it, label = it.name) }, dwellDurationSeconds)
/**
* Starts a discovery scan across the given [presets].
* Starts a discovery scan across the given [targets] — public presets and/or beacon-advertised custom channels.
* (Distinct name from the [ChannelOption] overload: both would erase to the same JVM signature otherwise.)
*
* @param presets The LoRa presets to cycle through.
* @param dwellDurationSeconds How long to listen on each preset.
* @param targets The scan queue ([ScanTarget]).
* @param dwellDurationSeconds How long to listen on each target.
*/
suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
require(presets.isNotEmpty()) { "At least one preset is required" }
suspend fun startScanTargets(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
require(targets.isNotEmpty()) { "At least one scan target is required" }
require(dwellDurationSeconds > 0) { "Dwell duration must be positive" }
mutex.withLock {
@@ -156,9 +169,19 @@ class DiscoveryScanEngine(
_scanState.value = DiscoveryScanState.Preparing
// Capture the entire original LoRa config to restore it accurately later
// Capture the entire original LoRa config and primary channel to restore them accurately later.
val initialLoraConfig = radioConfigRepository.localConfigFlow.first().lora
originalLoRaConfig = initialLoraConfig
originalPrimaryChannel = radioConfigRepository.channelSetFlow.first().settings.firstOrNull()
tunedPrimaryChannel = false
// A custom-channel target overwrites the primary channel; without a captured original we could not restore
// it and would strand the radio on the beacon's channel. Abort rather than proceed silently.
if (originalPrimaryChannel == null && targets.any { it.channel != null }) {
Logger.w { "DiscoveryScanEngine: primary channel not captured; aborting custom-channel scan" }
_scanState.value = DiscoveryScanState.Idle
return
}
val homePresetStr =
if (initialLoraConfig?.use_preset == true) {
@@ -176,7 +199,7 @@ class DiscoveryScanEngine(
val session =
DiscoverySessionEntity(
timestamp = nowMillis,
presetsScanned = presets.joinToString(",") { it.name },
presetsScanned = targets.joinToString(",") { it.label },
homePreset = homePresetStr,
completionStatus = "in_progress",
userLatitude = latDouble,
@@ -189,14 +212,14 @@ class DiscoveryScanEngine(
collectorRegistry.collector = this
// Set initial state so the scan loop's isActive guard succeeds
_scanState.value = DiscoveryScanState.Shifting(presets.first().name)
currentPresetName = presets.first().name
_scanState.value = DiscoveryScanState.Shifting(targets.first().label)
currentPresetName = targets.first().label
totalDwellSeconds = dwellDurationSeconds
// Launch scan coroutine
val scope = CoroutineScope(dispatchers.io + SupervisorJob())
scanScope = scope
scope.launch { runScanLoop(presets, dwellDurationSeconds) }
scope.launch { runScanLoop(targets, dwellDurationSeconds) }
}
}
@@ -272,11 +295,11 @@ class DiscoveryScanEngine(
// region Scan loop
@Suppress("ReturnCount")
private suspend fun runScanLoop(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
for (preset in presets) {
private suspend fun runScanLoop(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
for (target in targets) {
if (!isActive) return
currentPresetName = preset.name
currentPresetName = target.label
mutex.withLock {
collectedNodes.clear()
deviceMetricsLog.clear()
@@ -284,12 +307,12 @@ class DiscoveryScanEngine(
}
totalDwellSeconds = dwellDurationSeconds
// Shift to the new preset
_scanState.value = DiscoveryScanState.Shifting(preset.name)
shiftPreset(preset)
// Shift to the new target (preset, plus a custom primary channel for beacon-channel targets)
_scanState.value = DiscoveryScanState.Shifting(target.label)
shiftTarget(target)
// Wait for reconnection
_scanState.value = DiscoveryScanState.Reconnecting(preset.name)
_scanState.value = DiscoveryScanState.Reconnecting(target.label)
if (!waitForConnection()) {
pauseAndAbort()
return
@@ -299,13 +322,13 @@ class DiscoveryScanEngine(
requestNeighborInfoAtDwellBoundary()
// Dwell
if (!runDwell(preset.name, dwellDurationSeconds)) {
if (!runDwell(target.label, dwellDurationSeconds)) {
pauseAndAbort()
return
}
if (!isActive) return
// Persist this preset's results
// Persist this target's results
persistCurrentDwellResults()
}
@@ -333,11 +356,36 @@ class DiscoveryScanEngine(
cancelScanInternal()
}
private suspend fun shiftPreset(preset: ChannelOption) {
val loraConfig = Config.LoRaConfig(use_preset = true, modem_preset = preset.modemPreset)
val config = Config(lora = loraConfig)
radioController.setLocalConfig(config)
Logger.i { "DiscoveryScanEngine: shifted to ${preset.name} (use_preset=true)" }
private suspend fun shiftTarget(target: ScanTarget) {
// Start from the captured original config so unrelated fields (hop_limit, tx_power, tx_enabled, …) are carried
// over instead of zeroed by a fresh LoRaConfig — a from-scratch config can e.g. break the dwell-boundary
// NeighborInfo request. Only the preset (and, for custom channels, region + channel_num) is overridden.
val base = originalLoRaConfig ?: Config.LoRaConfig()
if (target.channel == null) {
// Public-preset target — dwell on the preset using the radio's existing primary channel (unchanged).
radioController.setLocalConfig(
Config(lora = base.copy(use_preset = true, modem_preset = target.preset.modemPreset)),
)
Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (use_preset=true)" }
} else {
// Beacon custom-channel target: apply the offered preset+region, reset channel_num so firmware derives the
// frequency from the new name, then tune the primary channel to the offered name+PSK so nodes on that mesh
// are heard. The original primary channel is restored after the scan.
radioController.setLocalConfig(
Config(
lora =
base.copy(
use_preset = true,
modem_preset = target.preset.modemPreset,
region = target.region ?: base.region,
channel_num = 0,
),
),
)
radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = target.channel))
tunedPrimaryChannel = true
Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (custom channel)" }
}
// The firmware often restarts the radio or reboots after a LoRa config change.
// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
delay(3000)
@@ -642,8 +690,14 @@ class DiscoveryScanEngine(
private suspend fun restoreHomePreset() {
val config = originalLoRaConfig ?: return
val fullConfig = Config(lora = config)
radioController.setLocalConfig(fullConfig)
// Restore the primary channel first (only when a custom-channel target overwrote it), then the LoRa config
// both are no-ops on a public-only scan, so that path stays unchanged (FR-005 no-regression).
if (tunedPrimaryChannel) {
originalPrimaryChannel?.let {
radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = it))
}
}
radioController.setLocalConfig(Config(lora = config))
Logger.i { "DiscoveryScanEngine: restored original LoRa config" }
// The firmware often restarts the radio or reboots after a LoRa config change.
delay(3000)

View File

@@ -36,6 +36,8 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
@KoinViewModel
@@ -56,6 +58,45 @@ class DiscoveryViewModel(
/** Mesh Beacon invitations received from other meshes, newest first. */
val beaconOffers: StateFlow<List<MeshBeaconOffer>> = meshBeaconRepository.offers
/** Modem presets advertised by any received beacon — flagged in the scan-setup picker (FR-004). */
val beaconPresets: StateFlow<Set<ChannelOption>> =
meshBeaconRepository.offers
.map { offers -> offers.mapNotNull { ChannelOption.from(it.beacon.offer_preset) }.toSet() }
.stateInWhileSubscribed(initialValue = emptySet())
/** Distinct custom channels advertised by beacons — offered as selectable scan targets (FR-007). */
val beaconChannels: StateFlow<List<BeaconChannel>> =
meshBeaconRepository.offers
.map { offers ->
offers
.mapNotNull { offer ->
val ch = offer.beacon.offer_channel ?: return@mapNotNull null
val name =
ch.name.ifBlank {
return@mapNotNull null
}
BeaconChannel(
name = name,
psk = ch.psk,
preset = ChannelOption.from(offer.beacon.offer_preset),
region = offer.beacon.offer_region,
)
}
.distinctBy { it.id }
}
.stateInWhileSubscribed(initialValue = emptyList())
private val _selectedBeaconChannels = MutableStateFlow<Set<String>>(emptySet())
val selectedBeaconChannels: StateFlow<Set<String>> = _selectedBeaconChannels.asStateFlow()
/** The radio's current LoRa config — drives the add-vs-switch decision for a beacon join. */
val currentLora: StateFlow<LoRaConfig?> =
radioConfigRepository.localConfigFlow.map { it.lora }.stateInWhileSubscribed(initialValue = null)
/** The radio's current channel settings (index 0 = primary) — drives the add-vs-switch decision. */
val currentChannels: StateFlow<List<ChannelSettings>> =
radioConfigRepository.channelSetFlow.map { it.settings }.stateInWhileSubscribed(initialValue = emptyList())
val homePreset: StateFlow<ChannelOption> =
radioConfigRepository.localConfigFlow
.map { localConfig -> ChannelOption.from(localConfig.lora?.modem_preset) ?: ChannelOption.DEFAULT }
@@ -94,6 +135,12 @@ class DiscoveryViewModel(
val sessions: StateFlow<List<DiscoverySessionEntity>> =
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
/** Beacon presets we've already auto-selected once, so a user's later deselection is never undone (FR-004). */
private val autoSelectedBeaconPresets = mutableSetOf<ChannelOption>()
/** Beacon channels we've already auto-selected once (union-only, FR-007). */
private val autoSelectedBeaconChannels = mutableSetOf<String>()
init {
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
safeLaunch(tag = "check24GhzCapability") {
@@ -101,26 +148,57 @@ class DiscoveryViewModel(
_is24GhzBlocked.value =
result is HardwareCapabilityResult.Unsupported || result is HardwareCapabilityResult.Unknown
}
// Pre-select each beacon-advertised preset once (union-only) as beacons arrive — never clearing user choices.
safeLaunch(tag = "preselectBeaconPresets") {
beaconPresets.collect { presets ->
val fresh = presets - autoSelectedBeaconPresets
if (fresh.isNotEmpty()) {
autoSelectedBeaconPresets += fresh
updateSelectedPresets { it + fresh }
}
}
}
// Pre-select each beacon custom channel once (union-only), never clearing user choices (FR-007).
safeLaunch(tag = "preselectBeaconChannels") {
beaconChannels.collect { channels ->
val fresh = channels.map { it.id }.toSet() - autoSelectedBeaconChannels
if (fresh.isNotEmpty()) {
autoSelectedBeaconChannels += fresh
_selectedBeaconChannels.update { it + fresh }
}
}
}
}
fun togglePreset(preset: ChannelOption) {
fun toggleBeaconChannel(id: String) {
_selectedBeaconChannels.update { if (id in it) it - id else it + id }
}
fun togglePreset(preset: ChannelOption) = updateSelectedPresets { current ->
if (preset in current) current - preset else current + preset
}
/**
* Atomically mutates the selected-preset set and mirrors it to prefs, so the shown selection and saved prefs never
* diverge. Shared by [togglePreset], [discoverOffer], and the beacon-preset pre-select.
*/
private fun updateSelectedPresets(transform: (Set<ChannelOption>) -> Set<ChannelOption>) {
_selectedPresets.update { current ->
val updated = if (preset in current) current - preset else current + preset
val updated = transform(current)
discoveryPrefs.setSelectedPresets(updated.map { it.name }.toSet())
updated
}
}
/**
* Seeds the scan with just the preset an invitation advertised, so the user can survey the advertised mesh before
* joining. Persists like [togglePreset] (not a bare [_selectedPresets] write) so the shown selection and saved
* prefs never diverge — otherwise the next [togglePreset] would silently persist prefs derived from this seed.
* No-op if the offer carries no preset, or a preset with no matching [ChannelOption].
* Adds the preset an invitation advertised to the scan selection (union, never clearing the user's existing choices
* — matches Apple 014-mesh-beacons FR-003), so the user can survey the advertised mesh before joining. Persists
* like [togglePreset] so the shown selection and saved prefs never diverge. No-op if the offer carries no preset,
* or a preset with no matching [ChannelOption].
*/
fun discoverOffer(offer: MeshBeaconOffer) {
val preset = ChannelOption.from(offer.beacon.offer_preset) ?: return
_selectedPresets.value = setOf(preset)
discoveryPrefs.setSelectedPresets(setOf(preset.name))
updateSelectedPresets { it + preset }
}
fun dismissOffer(offer: MeshBeaconOffer) {
@@ -134,8 +212,24 @@ class DiscoveryViewModel(
fun startScan() {
safeLaunch(tag = "startScan") {
scanEngine.startScan(
presets = selectedPresets.value.toList(),
val presetTargets = selectedPresets.value.map { ScanTarget(preset = it, label = it.name) }
val selectedIds = selectedBeaconChannels.value
val channelTargets =
beaconChannels.value
.filter { it.id in selectedIds }
.map { bc ->
val preset = bc.preset ?: homePreset.value
ScanTarget(
preset = preset,
label = "${preset.name} · ${bc.name}",
channel = ChannelSettings(name = bc.name, psk = bc.psk),
region = bc.region.takeIf { it != RegionCode.UNSET },
)
}
val targets = presetTargets + channelTargets
if (targets.isEmpty()) return@safeLaunch
scanEngine.startScanTargets(
targets = targets,
dwellDurationSeconds = dwellDurationMinutes.value.toLong() * SECONDS_PER_MINUTE,
)
}

View File

@@ -0,0 +1,49 @@
/*
* 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.discovery
import okio.ByteString
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
/**
* One entry in the discovery scan queue. A [channel] of `null` is a public-preset target (the original scan behavior —
* dwell on [preset] using the radio's existing primary channel). A non-null [channel] is a beacon-advertised custom
* channel: during its dwell the engine tunes the radio's primary channel to that name+PSK (and [region]) so nodes on
* that mesh are heard, then restores the original primary channel afterwards (Apple 014-mesh-beacons FR-005).
*
* @param label Result label persisted with the dwell (e.g. `"LONG_FAST"` or `"LONG_FAST · PartyNet"`), so a custom
* channel's results never collide with the same preset's public results.
*/
data class ScanTarget(
val preset: ChannelOption,
val label: String,
val channel: ChannelSettings? = null,
val region: RegionCode? = null,
)
/**
* A distinct custom channel a beacon advertised, presented as a selectable row in scan setup (FR-007). Deduped by [id]
* (name + preset + PSK), since the same channel on the same preset is one row regardless of how many nodes beaconed it
* — but a different PSK is a different network, so it must not collapse into another row (that would send the user to
* the wrong mesh).
*/
data class BeaconChannel(val name: String, val psk: ByteString, val preset: ChannelOption?, val region: RegionCode) {
val id: String
get() = "$name|${preset?.name.orEmpty()}|${psk.hex()}"
}

View File

@@ -25,7 +25,6 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -81,9 +80,11 @@ fun EntryProviderScope<NavKey>.discoveryGraph(backStack: NavBackStack<NavKey>) {
private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
val viewModel = koinViewModel<DiscoveryViewModel>()
// Join reuses the QR channel-import flow (same ADD/REPLACE + "this retunes your radio" confirmation a scanned
// channel URL shows). UIViewModel is ViewModel-store-scoped per nav entry (MeshtasticNavDisplay uses
// rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a *different* instance —
// we must render the dialog here, against this entry's instance, or the offer never surfaces.
// channel URL shows). The screen decides add-vs-switch and hands us the ready ChannelSet (add = no lora_config →
// no reboot; switch = lora_config present → replace + reboot). UIViewModel is ViewModel-store-scoped per nav entry
// (MeshtasticNavDisplay uses rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a
// *different* instance — we must render the dialog here, against this entry's instance, or the offer never
// surfaces.
val uiViewModel = koinViewModel<UIViewModel>()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
DiscoveryScanScreen(
@@ -91,7 +92,7 @@ private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) },
onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) },
onJoinOffer = { beacon -> beacon.toChannelSet()?.let(uiViewModel::setRequestChannelSet) },
onJoinOffer = uiViewModel::setRequestChannelSet,
)
requestChannelSet?.let { ScannedQrCodeDialog(incoming = it, onDismiss = { uiViewModel.clearRequestChannelUrl() }) }
}

View File

@@ -61,6 +61,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.beaconJoinOption
import org.meshtastic.core.model.util.toJoinChannelSet
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.discovery_analysing_results
@@ -99,10 +101,11 @@ import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.feature.discovery.DiscoveryScanState
import org.meshtastic.feature.discovery.DiscoveryViewModel
import org.meshtastic.feature.discovery.ui.component.BeaconChannelsCard
import org.meshtastic.feature.discovery.ui.component.DwellProgressIndicator
import org.meshtastic.feature.discovery.ui.component.MeshBeaconInvitationCard
import org.meshtastic.feature.discovery.ui.component.PresetPickerCard
import org.meshtastic.proto.MeshBeacon
import org.meshtastic.proto.ChannelSet
private val CONTENT_PADDING = 16.dp
private val SECTION_SPACING = 16.dp
@@ -119,11 +122,16 @@ fun DiscoveryScanScreen(
onNavigateToSummary: (sessionId: Long) -> Unit,
onNavigateToHistory: () -> Unit,
modifier: Modifier = Modifier,
onJoinOffer: (MeshBeacon) -> Unit = {},
onJoinOffer: (ChannelSet) -> Unit = {},
) {
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
val beaconOffers by viewModel.beaconOffers.collectAsStateWithLifecycle()
val beaconPresets by viewModel.beaconPresets.collectAsStateWithLifecycle()
val beaconChannels by viewModel.beaconChannels.collectAsStateWithLifecycle()
val selectedBeaconChannels by viewModel.selectedBeaconChannels.collectAsStateWithLifecycle()
val currentLora by viewModel.currentLora.collectAsStateWithLifecycle()
val currentChannels by viewModel.currentChannels.collectAsStateWithLifecycle()
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
@@ -184,7 +192,7 @@ fun DiscoveryScanScreen(
ScanButton(
scanState = scanState,
isConnected = isConnected,
hasPresetsSelected = selectedPresets.isNotEmpty(),
hasPresetsSelected = selectedPresets.isNotEmpty() || selectedBeaconChannels.isNotEmpty(),
usesDefaultKey = usesDefaultKey,
is24GhzUnsupported = isLora24Region && is24GhzBlocked,
onStart = viewModel::startScan,
@@ -215,9 +223,14 @@ fun DiscoveryScanScreen(
)
}
items(beaconOffers, key = { "invitation_${it.key}" }) { offer ->
val joinOption =
remember(offer, currentLora, currentChannels) {
offer.beacon.beaconJoinOption(currentLora, currentChannels)
}
MeshBeaconInvitationCard(
offer = offer,
onJoin = { onJoinOffer(offer.beacon) },
joinOption = joinOption,
onJoin = { offer.beacon.toJoinChannelSet(joinOption, currentLora)?.let(onJoinOffer) },
onDiscover = { viewModel.discoverOffer(offer) },
onDismiss = { viewModel.dismissOffer(offer) },
)
@@ -231,9 +244,22 @@ fun DiscoveryScanScreen(
homePreset = homePreset,
onTogglePreset = viewModel::togglePreset,
enabled = true,
beaconPresets = beaconPresets,
)
}
// Beacon channels — custom channels advertised by beacons (hidden when none recorded)
if (beaconChannels.isNotEmpty()) {
item(key = "beacon_channels") {
BeaconChannelsCard(
channels = beaconChannels,
selectedIds = selectedBeaconChannels,
onToggle = viewModel::toggleBeaconChannel,
enabled = true,
)
}
}
// Dwell time picker
item(key = "dwell_picker") {
DwellTimePicker(

View File

@@ -0,0 +1,93 @@
/*
* 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.discovery.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mesh_beacon_channels_title
import org.meshtastic.core.resources.mesh_beacon_preset_indicator
import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.discovery.BeaconChannel
private val CARD_PADDING = 16.dp
private val ROW_SPACING = 8.dp
/**
* Scan-setup section listing distinct custom channels that beacons advertised (Apple 014-mesh-beacons FR-007).
* Selecting a row adds a custom-channel scan target that tunes the radio's primary channel to that name during its
* dwell.
*/
@Composable
fun BeaconChannelsCard(
channels: List<BeaconChannel>,
selectedIds: Set<String>,
onToggle: (String) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(CARD_PADDING), verticalArrangement = Arrangement.spacedBy(ROW_SPACING)) {
Text(
text = stringResource(Res.string.mesh_beacon_channels_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
channels.forEach { channel ->
val selected = channel.id in selectedIds
Row(
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { onToggle(channel.id) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ROW_SPACING),
) {
Checkbox(checked = selected, onCheckedChange = { onToggle(channel.id) }, enabled = enabled)
Icon(
imageVector = MeshtasticIcons.CellTower,
contentDescription = stringResource(Res.string.mesh_beacon_preset_indicator),
)
Column {
Text(text = channel.name, style = MaterialTheme.typography.bodyMedium)
channel.preset?.let {
Text(
text = it.displayName(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}

View File

@@ -33,9 +33,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.util.BeaconJoinOption
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mesh_beacon_offer_add
import org.meshtastic.core.resources.mesh_beacon_offer_channel
import org.meshtastic.core.resources.mesh_beacon_offer_discover
import org.meshtastic.core.resources.mesh_beacon_offer_dismiss
@@ -43,6 +46,7 @@ import org.meshtastic.core.resources.mesh_beacon_offer_from_unknown
import org.meshtastic.core.resources.mesh_beacon_offer_join
import org.meshtastic.core.resources.mesh_beacon_offer_preset
import org.meshtastic.core.resources.mesh_beacon_offer_region
import org.meshtastic.core.resources.mesh_beacon_offer_signal
import org.meshtastic.core.resources.mesh_beacon_offer_title
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
@@ -50,9 +54,11 @@ import org.meshtastic.proto.Config.LoRaConfig.RegionCode
* A single received Mesh Beacon invitation. Presents the advertised channel/region/preset and lets the user survey the
* mesh first ([onDiscover], shown only when a preset is offered), join it ([onJoin]), or dismiss the invitation.
*/
@Suppress("LongMethod")
@Composable
internal fun MeshBeaconInvitationCard(
offer: MeshBeaconOffer,
joinOption: BeaconJoinOption,
onJoin: () -> Unit,
onDiscover: () -> Unit,
onDismiss: () -> Unit,
@@ -93,6 +99,18 @@ internal fun MeshBeaconInvitationCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (offer.rssi != 0 || offer.snr != 0f) {
Text(
text =
stringResource(
Res.string.mesh_beacon_offer_signal,
MetricFormatter.snr(offer.snr),
MetricFormatter.rssi(offer.rssi),
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -104,7 +122,17 @@ internal fun MeshBeaconInvitationCard(
if (presetOption != null) {
OutlinedButton(onClick = onDiscover) { Text(stringResource(Res.string.mesh_beacon_offer_discover)) }
}
Button(onClick = onJoin) { Text(stringResource(Res.string.mesh_beacon_offer_join)) }
// "Add channel" joins with no reboot (same frequency slot); otherwise "Join" retunes + reboots.
val joinLabel =
if (joinOption == BeaconJoinOption.ADD) {
Res.string.mesh_beacon_offer_add
} else {
Res.string.mesh_beacon_offer_join
}
// NONE (no offered channel) can't be joined — toJoinChannelSet returns null — so disable the action.
Button(onClick = onJoin, enabled = joinOption != BeaconJoinOption.NONE) {
Text(stringResource(joinLabel))
}
}
}
}

View File

@@ -43,6 +43,8 @@ import org.meshtastic.core.resources.discovery_lora_presets_description
import org.meshtastic.core.resources.discovery_preset_home_label
import org.meshtastic.core.resources.discovery_stat_selected
import org.meshtastic.core.resources.discovery_stat_unselected
import org.meshtastic.core.resources.mesh_beacon_preset_indicator
import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -58,6 +60,7 @@ internal fun ChannelOption.displayName(): String =
private val DEPRECATED_PRESETS = setOf(ChannelOption.VERY_LONG_SLOW, ChannelOption.LONG_SLOW)
/** A card containing a [FlowRow] of [FilterChip] items for preset selection. */
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PresetPickerCard(
@@ -66,6 +69,7 @@ fun PresetPickerCard(
onTogglePreset: (ChannelOption) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
beaconPresets: Set<ChannelOption> = emptySet(),
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(CARD_PADDING)) {
@@ -98,6 +102,8 @@ fun PresetPickerCard(
}
val selectedDesc = stringResource(Res.string.discovery_stat_selected)
val unselectedDesc = stringResource(Res.string.discovery_stat_unselected)
val fromBeacon = preset in beaconPresets
val beaconDesc = stringResource(Res.string.mesh_beacon_preset_indicator)
FilterChip(
selected = selected,
onClick = { onTogglePreset(preset) },
@@ -119,6 +125,19 @@ fun PresetPickerCard(
} else {
null
},
// A tower badge marks presets a nearby beacon advertised (Apple 014-mesh-beacons FR-004).
trailingIcon =
if (fromBeacon) {
{
Icon(
imageVector = MeshtasticIcons.CellTower,
contentDescription = beaconDesc,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
} else {
null
},
)
}
}

View File

@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_speed
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_volume_up
import org.meshtastic.core.resources.mesh_beacon
import org.meshtastic.core.resources.mqtt
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.paxcounter
@@ -61,6 +62,9 @@ enum class ModuleRoute(
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
// False when the firmware has no ModuleConfigType to request this module per-request; the editor then relies on the
// connect-time config sync instead of a get (MeshBeacon: MeshBeaconConfig is in ModuleConfig but not in the enum).
val refreshable: Boolean = true,
) {
MQTT(Res.string.mqtt, SettingsRoute.MQTT, Res.drawable.ic_cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -150,6 +154,16 @@ enum class ModuleRoute(
isSupported = { it.supportsTakConfig },
isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
),
// MeshBeaconConfig has no AdminMessage.ModuleConfigType value upstream — the editor reads from the connect-time
// config sync, so refreshable=false (no per-module get is issued). Gated to firmware that ships the beacon module.
MESH_BEACON(
Res.string.mesh_beacon,
SettingsRoute.MeshBeacon,
Res.drawable.ic_perm_scan_wifi,
isSupported = { it.supportsMeshBeacon },
refreshable = false,
),
;
val bitfield: Int
@@ -184,7 +198,11 @@ enum class ModuleRoute(
STATUS_MESSAGE -> 0x0000
// Not excludable yet
TAK -> 0x0000 // Not excludable yet
TAK -> 0x0000
// Not excludable yet
MESH_BEACON -> 0x0000 // Not excludable yet
}
companion object {

View File

@@ -57,6 +57,7 @@ import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreenCommon
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.MeshBeaconConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
@@ -214,6 +215,9 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
ModuleRoute.TAK ->
TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
ModuleRoute.MESH_BEACON ->
MeshBeaconConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
}
}
}

View File

@@ -585,6 +585,13 @@ open class RadioConfigViewModel(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNum ?: destNode.value?.num ?: return
// A module without a per-module get (no ModuleConfigType, e.g. MeshBeacon) reads from the connect-time config
// sync — just select the route and render, skipping the loading/request round-trip that would never complete.
if (route is ModuleRoute && !route.refreshable) {
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Empty) }
return
}
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {

View File

@@ -0,0 +1,292 @@
/*
* 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.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString.Companion.decodeBase64
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mesh_beacon
import org.meshtastic.core.resources.mesh_beacon_broadcast
import org.meshtastic.core.resources.mesh_beacon_broadcast_summary
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_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_send_as_node
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.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
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 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.
*
* The repeated `broadcast_targets` list is editable ([BroadcastTargetsCard]); the single-target `broadcast_on_*`
* scalars are shown only as the fallback when no targets are set (Apple parity). `broadcast_on_channel` is preserved
* verbatim through the Wire `copy()` round-trip (no scalar editor — the targets list supersedes it).
*/
@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 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).
val broadcastEnabled = formState.value.flags.hasFlag(broadcastFlag)
val intervalValid = !broadcastEnabled || formState.value.broadcast_interval_secs >= MIN_INTERVAL_SECS
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,
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)))
},
) {
item {
TitledCard(title = stringResource(Res.string.mesh_beacon)) {
SwitchPreference(
title = stringResource(Res.string.mesh_beacon_listen),
summary = stringResource(Res.string.mesh_beacon_listen_summary),
checked = formState.value.flags.hasFlag(listenFlag),
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy(flags = formState.value.flags.withFlag(listenFlag, it))
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.mesh_beacon_broadcast),
summary = stringResource(Res.string.mesh_beacon_broadcast_summary),
checked = formState.value.flags.hasFlag(broadcastFlag),
enabled = state.connected,
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) },
)
}
}
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) },
)
}
}
item {
BroadcastTargetsCard(
targets = formState.value.broadcast_targets,
enabled = state.connected,
onChange = { formState.value = formState.value.copy(broadcast_targets = it) },
)
}
item {
TitledCard(title = stringResource(Res.string.mesh_beacon_on_region)) {
// Single-target transmit scalars are the fallback used only when no multi-target list is set (Apple
// shows these two only when broadcast_targets is empty). broadcast_send_as_node applies either way.
if (formState.value.broadcast_targets.isEmpty()) {
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_region),
selectedItem = formState.value.broadcast_on_region,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_on_region = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_preset),
selectedItem = formState.value.broadcast_on_preset ?: ModemPreset.LONG_FAST,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_on_preset = it) },
)
HorizontalDivider()
}
SignedIntegerEditTextPreference(
title = stringResource(Res.string.mesh_beacon_send_as_node),
value = formState.value.broadcast_send_as_node,
enabled = state.connected,
keyboardActions = KeyboardActions.Default,
onValueChanged = { formState.value = formState.value.copy(broadcast_send_as_node = it) },
)
}
}
}
}
/**
* Editor for the repeated `broadcast_targets` list (Apple FR-014 multi-target). Each target carries its own
* region/preset and an optional `channel_index`; rows can be added and removed. When the list is non-empty it
* supersedes the single-target `broadcast_on_*` scalars.
*/
@Composable
private fun BroadcastTargetsCard(
targets: List<MeshBeaconConfig.BroadcastTarget>,
enabled: Boolean,
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,
enabled = enabled,
onItemSelected = { sel ->
onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(region = sel) else t })
},
)
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) {
Text(stringResource(Res.string.mesh_beacon_target_add))
}
}
}