From bf929c20f274232e0eb8ddc19b2dc82403e99e5b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:15:56 -0500 Subject: [PATCH] feat(discovery): Mesh Beacon client with iOS 014-mesh-beacons parity (#6097) Co-authored-by: Claude Opus 4.8 --- .skills/compose-ui/strings-index.txt | 27 ++ .../core/data/manager/MeshDataHandlerImpl.kt | 10 +- .../core/data/manager/MeshDataHandlerTest.kt | 32 +- .../core/datastore/ModuleConfigDataSource.kt | 2 + .../org/meshtastic/core/model/Capabilities.kt | 7 + .../meshtastic/core/model/MeshBeaconOffer.kt | 34 +- .../meshtastic/core/model/util/ChannelSet.kt | 102 +++++- .../core/model/util/MeshBeaconOfferTest.kt | 196 ++++++++++++ .../org/meshtastic/core/navigation/Routes.kt | 2 + .../prefs/discovery/MeshBeaconPrefsImpl.kt | 77 +++++ .../core/repository/AppPreferences.kt | 10 + .../core/repository/MeshBeaconRepository.kt | 34 +- .../repository/di/CoreRepositoryModule.kt | 8 +- .../composeResources/values/strings.xml | 30 +- .../core/ui/qr/ScannedQrCodeDialog.kt | 15 +- .../feature/discovery/DiscoveryScanEngine.kt | 106 +++++-- .../feature/discovery/DiscoveryViewModel.kt | 114 ++++++- .../feature/discovery/ScanTarget.kt | 49 +++ .../navigation/DiscoveryNavigation.kt | 11 +- .../discovery/ui/DiscoveryScanScreen.kt | 34 +- .../ui/component/BeaconChannelsCard.kt | 93 ++++++ .../ui/component/MeshBeaconInvitationCard.kt | 30 +- .../ui/component/PresetPickerCard.kt | 19 ++ .../settings/navigation/ModuleRoute.kt | 20 +- .../settings/navigation/SettingsNavigation.kt | 4 + .../settings/radio/RadioConfigViewModel.kt | 7 + .../component/MeshBeaconConfigItemList.kt | 292 ++++++++++++++++++ 27 files changed, 1289 insertions(+), 76 deletions(-) create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt create mode 100644 core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt create mode 100644 feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt create mode 100644 feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 87e70bf4f..2adc314aa 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -578,6 +578,7 @@ firmware firmware_edition firmware_old firmware_recovery_banner +firmware_recovery_ble_failed firmware_recovery_button firmware_recovery_dismiss firmware_recovery_explanation @@ -892,17 +893,43 @@ match_all match_any max ### MESH ### +mesh_beacon +mesh_beacon_broadcast +mesh_beacon_broadcast_summary +mesh_beacon_channels_title +mesh_beacon_interval +mesh_beacon_interval_error mesh_beacon_invitations_title +mesh_beacon_listen +mesh_beacon_listen_summary +mesh_beacon_message +mesh_beacon_message_error 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_send_as_node +mesh_beacon_target +mesh_beacon_target_add +mesh_beacon_target_channel_index +mesh_beacon_target_remove +mesh_beacon_targets mesh_map_location mesh_map_location_description ### MESHTASTIC ### diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index d9d207056..5037b00de 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -206,7 +206,7 @@ class MeshDataHandlerImpl( } PortNum.MESH_BEACON_APP -> { - handleMeshBeacon(packet) + handleMeshBeacon(packet, myNodeNum) } else -> {} @@ -224,12 +224,16 @@ class MeshDataHandlerImpl( * low-priority notification when the invitation is first seen (not on every periodic re-broadcast). Only beacons * carrying a join offer (a channel) are actionable; message-only beacons are ignored. */ - private fun handleMeshBeacon(packet: MeshPacket) { + @Suppress("ReturnCount") + private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int) { + // Ignore our own beacons (spec FR-001) — once broadcast is enabled a node that also listens would self-notify. + if (packet.from == myNodeNum) return val payload = packet.decoded?.payload ?: return val beacon = MeshBeacon.ADAPTER.decodeOrNull(payload, Logger) // Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification. if (beacon?.offer_channel == null) return - val offer = MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon) + val offer = + MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi) if (meshBeaconRepository.add(offer)) { scope.launch { notificationManager.dispatch( diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 5da583d76..eeb13d0a5 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -40,6 +41,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.MeshDataMapper import org.meshtastic.core.repository.AdminPacketHandler +import org.meshtastic.core.repository.MeshBeaconPrefs import org.meshtastic.core.repository.MeshBeaconRepository import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.MessageFilter @@ -91,7 +93,6 @@ class MeshDataHandlerTest { private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill) private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill) private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill) - private val meshBeaconRepository = MeshBeaconRepository() private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -101,6 +102,18 @@ class MeshDataHandlerTest { // still drives it; cancelled in tearDown. private val geofenceScope = CoroutineScope(testDispatcher) + // Real repository over an in-memory prefs fake — its persistence write-through is exercised without a DataStore. + private val fakeBeaconPrefs = + object : MeshBeaconPrefs { + private val flow = MutableStateFlow>(emptyList()) + override val storedBeacons: StateFlow> = flow + + override fun setStoredBeacons(records: List) { + flow.value = records + } + } + private val meshBeaconRepository = MeshBeaconRepository(fakeBeaconPrefs, geofenceScope) + @AfterTest fun tearDown() { geofenceScope.cancel() @@ -416,6 +429,23 @@ class MeshDataHandlerTest { assertEquals(0, meshBeaconRepository.offers.value.size) } + @Test + fun `our own mesh beacon is ignored`() { + // Spec FR-001: ignore beacons from the scanning node itself (else a listen+broadcast node self-notifies). + val beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet")) + val packet = + MeshPacket( + from = 123, // == myNodeNum below + decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()), + ) + every { dataMapper.toDataPacket(packet) } returns + DataPacket(from = "!self", bytes = beacon.encode().toByteString(), dataType = PortNum.MESH_BEACON_APP.value) + + handler.handleReceivedData(packet, 123) + + assertEquals(0, meshBeaconRepository.offers.value.size) + } + // --- Store-and-Forward handling --- @Test diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt index 09b18f2a4..4f9778bcf 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt @@ -81,6 +81,8 @@ class ModuleConfigDataSource( config.tak != null -> current.copy(tak = config.tak) + config.mesh_beacon != null -> current.copy(mesh_beacon = config.mesh_beacon) + else -> current } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 874e38b99..ad0a60d3d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -81,6 +81,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl */ val supportsLockdown = atLeast(V2_8_0) + /** + * Support for the Mesh Beacon module (`ModuleConfig.MeshBeaconConfig` broadcast/listen). The proto is upstream but + * the firmware module traces to a community fork; gate the config editor to 2.8.0+ so older radios don't show a + * config they'd silently ignore. + */ + val supportsMeshBeacon = atLeast(V2_8_0) + companion object { private val V2_6_8 = DeviceVersion("2.6.8") private val V2_6_9 = DeviceVersion("2.6.9") diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt index 15fc21582..6ae7046ff 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.core.model +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshBeacon /** @@ -25,8 +27,10 @@ import org.meshtastic.proto.MeshBeacon * * @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned). * @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer. + * @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown). + * @param rssi Received signal strength of the beacon packet, in dBm (0 when unknown). */ -data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) { +data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int = 0) { /** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */ val key: String get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}" @@ -36,4 +40,32 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) { val channelName: String? get() = beacon.offer_channel?.name?.ifBlank { null } + + /** + * Serializes to a single-line record `fromNodeNum|snr|rssi|` for lightweight prefs persistence. + * The base64 alphabet never contains `|`, so the delimiter is unambiguous. + */ + fun encode(): String { + val beaconB64 = MeshBeacon.ADAPTER.encode(beacon).toByteString().base64() + return "$fromNodeNum|$snr|$rssi|$beaconB64" + } + + companion object { + private const val RECORD_FIELD_COUNT = 4 + + /** + * Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node + * number, or an undecodable beacon payload). An unparseable snr/rssi falls back to 0 — they are non-critical + * display metrics, not identity, so a bad numeric there does not discard an otherwise-valid invitation. + */ + @Suppress("ReturnCount") + fun decode(record: String): MeshBeaconOffer? { + val parts = record.split('|', limit = RECORD_FIELD_COUNT) + if (parts.size != RECORD_FIELD_COUNT) return null + val node = parts[0].toIntOrNull() ?: return null + val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null + val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null + return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull() ?: 0) + } + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index c361009c7..e4d51f970 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt @@ -22,9 +22,14 @@ import okio.ByteString.Companion.decodeBase64 import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.Channel +import org.meshtastic.core.model.channelNum +import org.meshtastic.core.model.numChannels import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.LoRaConfig.RegionCode import org.meshtastic.proto.MeshBeacon +import org.meshtastic.proto.ModuleSettings /** * Return a [ChannelSet] that represents the ChannelSet encoded by the URL. @@ -79,19 +84,94 @@ val ChannelSet.primaryChannel: Channel? fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null -/** - * Converts a received [MeshBeacon] join offer into a [ChannelSet], so it can be routed through the existing QR-code - * channel-import flow ([org.meshtastic.core.ui.qr.ScannedQrCodeViewModel]). Returns null when the beacon carries no - * join offer (an ambient message-only beacon). - */ -fun MeshBeacon.toChannelSet(): ChannelSet? { - val offerChannel = offer_channel ?: return null - // offer_region always has a value (proto default), but it's only meaningful paired with an explicit preset — - // without a preset we'd otherwise ship a LoRaConfig with use_preset=false and no custom radio fields set. - val loraConfig = offer_preset?.let { LoRaConfig(use_preset = true, modem_preset = it, region = offer_region) } - return ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig) +/** How a received Mesh Beacon invitation can be joined, given the connected radio's current settings. */ +enum class BeaconJoinOption { + /** Add the offered channel to a free secondary slot with no retune/reboot (mesh shares the radio's frequency). */ + ADD, + + /** Retune the radio's primary channel (and LoRa preset/region) onto the offered mesh — reboots the radio. */ + SWITCH, + + /** The beacon carries no join offer (message-only) — nothing to join. */ + NONE, } +/** + * Decides whether a beacon can be joined by simply **adding** its channel (no reboot) or requires a **switch** + * (retune + reboot). Adding works only when the offered mesh sits on the radio's *current* frequency slot — Meshtastic + * secondary channels ride the primary channel's frequency, so the offered channel must resolve (name-hash) to the same + * slot the radio's primary is on, under a matching preset and region. Mirrors the Apple `014-mesh-beacons` + * FR-016/FR-017 logic. + * + * @param currentLora The radio's current [LoRaConfig] (`null` → can't reason, so [SWITCH]). + * @param currentChannels The radio's current channel settings, index 0 = primary. + */ +@Suppress("ReturnCount") +fun MeshBeacon.beaconJoinOption(currentLora: LoRaConfig?, currentChannels: List): BeaconJoinOption { + val offer = offer_channel ?: return BeaconJoinOption.NONE + val lora = currentLora ?: return BeaconJoinOption.SWITCH + // An offered preset must match the radio's; an omitted preset (null) means "ride the current preset" → matches. + val presetMatches = offer_preset == null || (lora.use_preset && offer_preset == lora.modem_preset) + // offer_region == UNSET (0) means "not offered"; only a set, differing region forces a switch. + val regionMatches = offer_region == RegionCode.UNSET || offer_region == lora.region + if (!presetMatches || !regionMatches) return BeaconJoinOption.SWITCH + // With an explicit slot override we can't compare the offered mesh's slot; be safe and switch. + if (lora.channel_num != 0 || lora.numChannels <= 0) return BeaconJoinOption.SWITCH + // Hash the *effective* names: an empty channel name resolves to its preset display name ("LongFast", …), which is + // what firmware hashes for the slot — comparing raw "" on both sides would misclassify an unnamed primary. + val currentSlot = lora.channelNum(Channel(currentChannels.firstOrNull() ?: ChannelSettings(), lora).name) + val offeredSlot = lora.channelNum(Channel(offer, lora).name) + return if (offeredSlot == currentSlot) BeaconJoinOption.ADD else BeaconJoinOption.SWITCH +} + +/** + * Builds the [ChannelSet] to hand the QR channel-import dialog for a given [option]. + * + * [ADD][BeaconJoinOption.ADD] omits `lora_config` so the dialog merges the offered channel into a free secondary slot + * with no reboot. [SWITCH][BeaconJoinOption.SWITCH] carries a `lora_config` derived from [currentLora] with only the + * advertised preset+region applied and `channel_num` reset to 0 (firmware derives the frequency from the offered + * channel name — Apple FR-006). Every other radio setting (`hop_limit`, `tx_power`, `tx_enabled`, …) is preserved from + * [currentLora], so joining never silently zeroes them — the beacon proto advertises no such fields. Returns `null` for + * [NONE][BeaconJoinOption.NONE] or a beacon with no offered channel. + * + * Both paths strip position sharing from the offered channel ([withoutPositionSharing]) so joining a stranger's mesh + * never leaks our location — matching Apple's `joinBeaconMesh`/`addBeaconChannel`. + * + * @param currentLora The radio's current [LoRaConfig]; a switch alters only preset/region/channel_num. + */ +fun MeshBeacon.toJoinChannelSet(option: BeaconJoinOption, currentLora: LoRaConfig?): ChannelSet? { + val offerChannel = (offer_channel ?: return null).withoutPositionSharing() + return when (option) { + BeaconJoinOption.ADD -> ChannelSet(settings = listOf(offerChannel)) + + BeaconJoinOption.SWITCH -> { + // Always ship a LoRaConfig so channel_num resets to 0 and firmware re-derives the frequency from the + // offered + // channel name (Apple FR-006) — even when no preset is offered (else an explicit channel_num override would + // strand the radio on the old slot). Preserve every current field; override only the advertised + // preset/region. + val base = currentLora ?: LoRaConfig() + val loraConfig = + base.copy( + use_preset = true, + channel_num = 0, + modem_preset = offer_preset ?: base.modem_preset, + region = if (offer_region != RegionCode.UNSET) offer_region else base.region, + ) + ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig) + } + + BeaconJoinOption.NONE -> null + } +} + +/** + * Zeroes `position_precision` on the offered channel so joining a beaconed mesh never broadcasts our location to a mesh + * of strangers (privacy-first; Apple sets `positionPrecision = 0` on both add and switch). + */ +private fun ChannelSettings.withoutPositionSharing(): ChannelSettings = + copy(module_settings = (module_settings ?: ModuleSettings()).copy(position_precision = 0)) + /** * Return a URL that represents the [ChannelSet] * diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt new file mode 100644 index 000000000..768249256 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.core.model.MeshBeaconOffer +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.MeshBeacon +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MeshBeaconOfferTest { + + private val radioLora = + LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, region = RegionCode.US, channel_num = 0) + private val radioChannels = listOf(ChannelSettings(name = "HomeMesh")) + + @Test + fun `no offer channel yields NONE`() { + val beacon = MeshBeacon(message = "hi") + assertEquals(BeaconJoinOption.NONE, beacon.beaconJoinOption(radioLora, radioChannels)) + } + + @Test + fun `matching preset region and slot yields ADD`() { + // Offering the radio's own primary channel name forces an identical name-hash slot → addable with no reboot. + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "HomeMesh"), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ) + assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, radioChannels)) + } + + @Test + fun `unnamed primary matches a beacon that names the preset explicitly yielding ADD`() { + // The radio's primary has an empty name (resolves to the preset display name "LongFast" for the slot hash); a + // beacon offering a channel literally named "LongFast" on the same preset+region is the same slot -> ADD. + // Without effective-name resolution the empty primary would hash "" and misclassify as SWITCH. + val emptyPrimary = listOf(ChannelSettings(name = "")) + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "LongFast"), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ) + assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, emptyPrimary)) + } + + @Test + fun `different preset forces SWITCH`() { + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "HomeMesh"), + offer_preset = ModemPreset.SHORT_FAST, + offer_region = RegionCode.US, + ) + assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels)) + } + + @Test + fun `different region forces SWITCH`() { + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "HomeMesh"), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.EU_868, + ) + assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels)) + } + + @Test + fun `null lora config forces SWITCH`() { + val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "HomeMesh")) + assertEquals( + BeaconJoinOption.SWITCH, + beacon.beaconJoinOption(currentLora = null, currentChannels = emptyList()), + ) + } + + @Test + fun `toJoinChannelSet omits lora for ADD and includes it for SWITCH`() { + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "PartyNet"), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ) + assertNull(beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.lora_config, "ADD must not retune") + assertNotNull(beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.lora_config, "SWITCH carries lora") + assertNull(beacon.toJoinChannelSet(BeaconJoinOption.NONE, radioLora)) + } + + @Test + fun `SWITCH preserves current lora fields and only overrides preset region and channel_num`() { + // Regression: the beacon proto advertises no hop_limit/tx_power/tx_enabled — a switch must not zero them. + val current = + radioLora.copy( + modem_preset = ModemPreset.MEDIUM_FAST, + region = RegionCode.EU_868, + hop_limit = 3, + tx_power = 27, + tx_enabled = true, + channel_num = 5, + ) + val beacon = + MeshBeacon( + offer_channel = ChannelSettings(name = "PartyNet"), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ) + val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config + assertNotNull(lora) + assertEquals(3, lora.hop_limit, "hop_limit must be preserved, not zeroed") + assertEquals(27, lora.tx_power, "tx_power must be preserved") + assertEquals(true, lora.tx_enabled, "tx_enabled must be preserved") + assertEquals(ModemPreset.LONG_FAST, lora.modem_preset, "offered preset applied") + assertEquals(RegionCode.US, lora.region, "offered region applied") + assertEquals(0, lora.channel_num, "channel_num reset to 0 for frequency derivation") + } + + @Test + fun `SWITCH without an offered preset still resets channel_num and keeps the current preset`() { + // A channel-only beacon (no preset) must still reset channel_num=0 so firmware re-derives the frequency from + // the new primary name — otherwise a radio with an explicit channel_num override lands on the wrong slot. + val current = radioLora.copy(modem_preset = ModemPreset.MEDIUM_FAST, channel_num = 5) + val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "PartyNet")) + val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config + assertNotNull(lora, "SWITCH must always carry lora so channel_num resets") + assertEquals(0, lora.channel_num, "channel_num reset to 0 even without an offered preset") + assertEquals(ModemPreset.MEDIUM_FAST, lora.modem_preset, "current preset kept when none offered") + } + + @Test + fun `join strips position sharing from the offered channel`() { + // Privacy: joining a stranger's mesh must never broadcast our location (Apple sets positionPrecision=0). + val beacon = + MeshBeacon( + offer_channel = + ChannelSettings( + name = "PartyNet", + module_settings = org.meshtastic.proto.ModuleSettings(position_precision = 32), + ), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ) + val added = beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.settings?.first() + val switched = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.settings?.first() + assertEquals(0, added?.module_settings?.position_precision, "ADD zeroes position precision") + assertEquals(0, switched?.module_settings?.position_precision, "SWITCH zeroes position precision") + } + + @Test + fun `encode then decode round-trips a beacon offer`() { + val offer = + MeshBeaconOffer( + fromNodeNum = 42, + beacon = + MeshBeacon( + message = "Join us", + offer_channel = ChannelSettings(name = "PartyNet", psk = "secret".encodeUtf8()), + offer_preset = ModemPreset.LONG_FAST, + offer_region = RegionCode.US, + ), + snr = 6.5f, + rssi = -70, + ) + val restored = MeshBeaconOffer.decode(offer.encode()) + assertEquals(offer, restored) + } + + @Test + fun `decode returns null for a malformed record`() { + assertNull(MeshBeaconOffer.decode("not-a-valid-record")) + } +} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 6450727d7..bb65c4f04 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -158,6 +158,8 @@ sealed interface SettingsRoute : Route { @Serializable data object TAK : SettingsRoute + @Serializable data object MeshBeacon : SettingsRoute + // endregion // region advanced config routes diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt new file mode 100644 index 000000000..f886e7eea --- /dev/null +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.prefs.discovery + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.MeshBeaconPrefs + +@Single +class MeshBeaconPrefsImpl( + @Named("UiDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) : MeshBeaconPrefs { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + + override val storedBeacons: StateFlow> = + dataStore.data + .map { prefs -> + val raw = prefs[KEY_STORED_BEACONS] ?: "" + if (raw.isBlank()) emptyList() else raw.split(RECORD_DELIMITER) + } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + // Single conflated writer: rapid add()/dismiss() calls each publish the full latest list here, and one collector + // serializes the DataStore writes (latest-wins). Avoids launch-per-call races that could persist a stale snapshot. + // runCatching keeps a best-effort write failure (e.g. disk I/O) from escaping as an uncaught coroutine exception — + // the next add/dismiss retries, and on restart we hydrate from the last successful write. + private val pendingWrite = MutableStateFlow?>(null) + + init { + scope.launch { + pendingWrite.filterNotNull().collectLatest { records -> + runCatching { dataStore.edit { it[KEY_STORED_BEACONS] = records.joinToString(RECORD_DELIMITER) } } + } + } + } + + override fun setStoredBeacons(records: List) { + pendingWrite.value = records + } + + private companion object { + val KEY_STORED_BEACONS = stringPreferencesKey("mesh_beacon_stored_offers") + + // Newline can never appear in a beacon record (fields are numeric + base64), so it is a safe row separator. + const val RECORD_DELIMITER = "\n" + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index e026e78f1..da9b01218 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -370,3 +370,13 @@ interface DiscoveryPrefs { const val DEFAULT_DWELL_MINUTES = 15 } } + +/** + * Reactive persistence for received Mesh Beacon invitations. Records are opaque, self-describing strings (see + * `MeshBeaconOffer.encode`) so this prefs layer stays free of proto/model types. + */ +interface MeshBeaconPrefs { + val storedBeacons: StateFlow> + + fun setStoredBeacons(records: List) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt index 3921b4f11..ac5bfb1a6 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt @@ -16,22 +16,42 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.meshtastic.core.model.MeshBeaconOffer /** - * Holds Mesh Beacon invitations received during this app session. Beacons are advisory, ephemeral, zero-hop - * advertisements from other meshes — not messages or contacts — so they're kept in memory only (capped, no persistence) - * and naturally age out on app restart. Consumed by the Discovery surface, which presents them for the user to Discover - * / Join / Dismiss. Room persistence can be added later if users want invitations to survive restarts. + * Holds Mesh Beacon invitations received from other meshes. Beacons are advisory, zero-hop advertisements — not + * messages or contacts — so they live in the Discovery surface, which presents them for the user to Discover / Join / + * Dismiss. The list is capped and deduped in memory (the source of truth) and written through to [MeshBeaconPrefs] so + * invitations survive an app restart until the user explicitly dismisses them (Apple `014-mesh-beacons` FR-015). + * + * Note: persisted as opaque delimited records in a Preferences DataStore, not a Room table — the set is tiny (≤ + * [MAX_OFFERS]) and never queried. Promote to a Room entity alongside the discovery tables if querying/joins appear. */ -class MeshBeaconRepository { +class MeshBeaconRepository(private val prefs: MeshBeaconPrefs, scope: CoroutineScope) { private val _offers = MutableStateFlow>(emptyList()) val offers: StateFlow> = _offers.asStateFlow() + init { + // Hydrate once from disk. Memory is authoritative afterwards, so a live beacon that arrives before the async + // DataStore load wins; the guard also makes our own write-through re-emissions no-ops. + scope.launch { + prefs.storedBeacons.collect { records -> + if (records.isEmpty()) return@collect + // Fold the empty-guard into the atomic update so a live beacon that arrived first (or a concurrent + // add/dismiss) is never clobbered by the async DataStore snapshot. + _offers.update { current -> + if (current.isEmpty()) records.mapNotNull(MeshBeaconOffer::decode).take(MAX_OFFERS) else current + } + } + } + } + /** * Records a received [offer], replacing any prior offer with the same [key][MeshBeaconOffer.key] (a re-broadcast of * a standing invitation). Returns true when this is a newly-seen invitation, so the caller can notify only once @@ -40,13 +60,17 @@ class MeshBeaconRepository { fun add(offer: MeshBeaconOffer): Boolean { val isNew = _offers.value.none { it.key == offer.key } _offers.update { current -> (listOf(offer) + current.filterNot { it.key == offer.key }).take(MAX_OFFERS) } + persist() return isNew } fun dismiss(key: String) { _offers.update { current -> current.filterNot { it.key == key } } + persist() } + private fun persist() = prefs.setStoredBeacons(_offers.value.map { it.encode() }) + private companion object { const val MAX_OFFERS = 20 } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt index 4674d4586..262377c75 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt @@ -19,7 +19,9 @@ package org.meshtastic.core.repository.di import org.koin.core.annotation.Module import org.koin.core.annotation.Provided import org.koin.core.annotation.Single +import org.meshtastic.core.common.di.ApplicationCoroutineScope import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MeshBeaconPrefs import org.meshtastic.core.repository.MeshBeaconRepository import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NodeRepository @@ -30,7 +32,11 @@ import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl @Module class CoreRepositoryModule { - @Single fun provideMeshBeaconRepository(): MeshBeaconRepository = MeshBeaconRepository() + @Single + fun provideMeshBeaconRepository( + @Provided meshBeaconPrefs: MeshBeaconPrefs, + @Provided applicationScope: ApplicationCoroutineScope, + ): MeshBeaconRepository = MeshBeaconRepository(meshBeaconPrefs, applicationScope) @Single fun provideSendMessageUseCase( diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index be31a0daa..b399f79f2 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -602,10 +602,10 @@ Firmware Edition The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide. Finish updating %1$s - Resume firmware update - This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update. Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device. + Resume firmware update Dismiss firmware recovery + This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update. Firmware update required. Almost there... Alpha @@ -923,17 +923,43 @@ Match Any | All Max + Mesh Beacon + Broadcast a beacon + Periodically advertise this mesh to nearby nodes + Beacon channels + Broadcast interval (seconds) + Minimum %1$d seconds Mesh invitations + Listen for beacons + Capture invitations advertised by nearby meshes + Beacon message + Maximum %1$d bytes A nearby mesh invited you to join Mesh invitation + Add channel Channel: %1$s + Offered channel key (base64) + Offered channel name Discover Dismiss From an unknown node Join Preset: %1$s + Offered preset Region: %1$s + Offered region + Signal: %1$s / %2$s Mesh invitation + Transmit preset + Transmit region + Advertised by a nearby beacon + None + Broadcast as node number (0 = this node) + Target %1$d + Add target + Channel index + Remove target + Broadcast targets Mesh Map Location Enables the blue location dot for your phone in the mesh map. diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index bf891a7d1..29346e7c2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -155,8 +155,10 @@ fun ScannedQrCodeDialog( channelSet.copy( settings = channelSet.settings.filterIndexed { i, _ -> - val isExisting = i < channels.settings.size - isExisting || channelSelections.getOrNull(i) == true + // Primary (index 0) is always kept; existing secondaries can be dropped to free a slot for the + // incoming channel when the radio is full (Apple FR-017 "replace a secondary, never the + // primary"). + i == 0 || channelSelections.getOrNull(i) == true }, ) } @@ -232,13 +234,16 @@ fun ScannedQrCodeDialog( index, channel, -> - val isExisting = !shouldReplace && index < channels.settings.size + val isPrimary = index == 0 val channelObj = Channel(channel, channelSet.lora_config ?: Channel.default.loraConfig) ChannelSelection( index = index, title = channel.name.ifEmpty { modemPresetName }, - enabled = !isExisting, - isSelected = if (isExisting) true else channelSelections[index], + // Primary is always kept. In ADD mode existing secondaries become deselectable so the user can + // drop one to make room for the incoming channel on a full radio; REPLACE keeps its prior + // all-selectable behavior. + enabled = if (shouldReplace) true else !isPrimary, + isSelected = if (!shouldReplace && isPrimary) true else channelSelections[index], onSelected = { if (it || selectedChannelSet.settings.size > 1) { channelSelections[index] = it diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt index 269d6ef3b..4a8617adf 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt @@ -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, 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, dwellDurationSeconds: Long) { - require(presets.isNotEmpty()) { "At least one preset is required" } + suspend fun startScanTargets(targets: List, 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, dwellDurationSeconds: Long) { - for (preset in presets) { + private suspend fun runScanLoop(targets: List, 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) diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt index 3b8cb5a0d..1dfb68da7 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt @@ -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> = meshBeaconRepository.offers + /** Modem presets advertised by any received beacon — flagged in the scan-setup picker (FR-004). */ + val beaconPresets: StateFlow> = + 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> = + 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>(emptySet()) + val selectedBeaconChannels: StateFlow> = _selectedBeaconChannels.asStateFlow() + + /** The radio's current LoRa config — drives the add-vs-switch decision for a beacon join. */ + val currentLora: StateFlow = + 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> = + radioConfigRepository.channelSetFlow.map { it.settings }.stateInWhileSubscribed(initialValue = emptyList()) + val homePreset: StateFlow = radioConfigRepository.localConfigFlow .map { localConfig -> ChannelOption.from(localConfig.lora?.modem_preset) ?: ChannelOption.DEFAULT } @@ -94,6 +135,12 @@ class DiscoveryViewModel( val sessions: StateFlow> = 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() + + /** Beacon channels we've already auto-selected once (union-only, FR-007). */ + private val autoSelectedBeaconChannels = mutableSetOf() + 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) -> Set) { _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, ) } diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt new file mode 100644 index 000000000..115041fb5 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt @@ -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 . + */ +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()}" +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt index ecb823b22..7142a7c41 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt @@ -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.discoveryGraph(backStack: NavBackStack) { private fun DiscoveryScanScreenEntry(backStack: NavBackStack) { val viewModel = koinViewModel() // 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() val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() DiscoveryScanScreen( @@ -91,7 +92,7 @@ private fun DiscoveryScanScreenEntry(backStack: NavBackStack) { 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() }) } } diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt index 75613e85a..9ec723a85 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt @@ -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( diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt new file mode 100644 index 000000000..63d996402 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt @@ -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 . + */ +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, + selectedIds: Set, + 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, + ) + } + } + } + } + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt index 779f7af1c..23ae7ff3e 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt @@ -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)) + } } } } diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt index 0f9039fd7..b6f9c206d 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt @@ -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 = 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 + }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt index a5766e253..fcd518f00 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt @@ -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 { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 9725c1ced..31609ba0b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -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.settingsGraph(backStack: NavBackStack) { ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + + ModuleRoute.MESH_BEACON -> + MeshBeaconConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 0fa1135e5..199808db2 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt new file mode 100644 index 000000000..03e801564 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt @@ -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 . + */ +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-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. + * + * 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, + enabled: Boolean, + onChange: (List) -> Unit, +) { + TitledCard(title = stringResource(Res.string.mesh_beacon_targets)) { + targets.forEachIndexed { index, target -> + if (index > 0) HorizontalDivider() + DropDownPreference( + title = stringResource(Res.string.mesh_beacon_on_region), + selectedItem = target.region, + 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)) + } + } +}