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

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

View File

@@ -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(

View File

@@ -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<List<String>>(emptyList())
override val storedBeacons: StateFlow<List<String>> = flow
override fun setStoredBeacons(records: List<String>) {
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

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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|<base64 MeshBeacon>` 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)
}
}
}

View File

@@ -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<ChannelSettings>): 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]
*

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"))
}
}

View File

@@ -158,6 +158,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object TAK : SettingsRoute
@Serializable data object MeshBeacon : SettingsRoute
// endregion
// region advanced config routes

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>,
dispatchers: CoroutineDispatchers,
) : MeshBeaconPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val storedBeacons: StateFlow<List<String>> =
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<List<String>?>(null)
init {
scope.launch {
pendingWrite.filterNotNull().collectLatest { records ->
runCatching { dataStore.edit { it[KEY_STORED_BEACONS] = records.joinToString(RECORD_DELIMITER) } }
}
}
}
override fun setStoredBeacons(records: List<String>) {
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"
}
}

View File

@@ -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<List<String>>
fun setStoredBeacons(records: List<String>)
}

View File

@@ -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<List<MeshBeaconOffer>>(emptyList())
val offers: StateFlow<List<MeshBeaconOffer>> = _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
}

View File

@@ -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(

View File

@@ -602,10 +602,10 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
<string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_explanation">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.</string>
<string name="firmware_recovery_ble_failed">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.</string>
<string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_dismiss">Dismiss firmware recovery</string>
<string name="firmware_recovery_explanation">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.</string>
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
@@ -923,17 +923,43 @@
<string name="match_any">Match Any | All</string>
<string name="max">Max</string>
<!-- MESH -->
<string name="mesh_beacon">Mesh Beacon</string>
<string name="mesh_beacon_broadcast">Broadcast a beacon</string>
<string name="mesh_beacon_broadcast_summary">Periodically advertise this mesh to nearby nodes</string>
<string name="mesh_beacon_channels_title">Beacon channels</string>
<string name="mesh_beacon_interval">Broadcast interval (seconds)</string>
<string name="mesh_beacon_interval_error">Minimum %1$d seconds</string>
<string name="mesh_beacon_invitations_title">Mesh invitations</string>
<string name="mesh_beacon_listen">Listen for beacons</string>
<string name="mesh_beacon_listen_summary">Capture invitations advertised by nearby meshes</string>
<string name="mesh_beacon_message">Beacon message</string>
<string name="mesh_beacon_message_error">Maximum %1$d bytes</string>
<string name="mesh_beacon_notification_body">A nearby mesh invited you to join</string>
<string name="mesh_beacon_notification_title">Mesh invitation</string>
<string name="mesh_beacon_offer_add">Add channel</string>
<string name="mesh_beacon_offer_channel">Channel: %1$s</string>
<string name="mesh_beacon_offer_channel_key">Offered channel key (base64)</string>
<string name="mesh_beacon_offer_channel_name">Offered channel name</string>
<string name="mesh_beacon_offer_discover">Discover</string>
<string name="mesh_beacon_offer_dismiss">Dismiss</string>
<string name="mesh_beacon_offer_from_unknown">From an unknown node</string>
<string name="mesh_beacon_offer_join">Join</string>
<string name="mesh_beacon_offer_preset">Preset: %1$s</string>
<string name="mesh_beacon_offer_preset_setting">Offered preset</string>
<string name="mesh_beacon_offer_region">Region: %1$s</string>
<string name="mesh_beacon_offer_region_setting">Offered region</string>
<string name="mesh_beacon_offer_signal">Signal: %1$s / %2$s</string>
<string name="mesh_beacon_offer_title">Mesh invitation</string>
<string name="mesh_beacon_on_preset">Transmit preset</string>
<string name="mesh_beacon_on_region">Transmit region</string>
<string name="mesh_beacon_preset_indicator">Advertised by a nearby beacon</string>
<string name="mesh_beacon_preset_none">None</string>
<string name="mesh_beacon_send_as_node">Broadcast as node number (0 = this node)</string>
<string name="mesh_beacon_target">Target %1$d</string>
<string name="mesh_beacon_target_add">Add target</string>
<string name="mesh_beacon_target_channel_index">Channel index</string>
<string name="mesh_beacon_target_remove">Remove target</string>
<string name="mesh_beacon_targets">Broadcast targets</string>
<string name="mesh_map_location">Mesh Map Location</string>
<string name="mesh_map_location_description">Enables the blue location dot for your phone in the mesh map.</string>
<!-- MESHTASTIC -->

View File

@@ -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