feat(discovery): surface received Mesh Beacon invitations (#6043)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-01 16:54:49 -05:00
committed by GitHub
parent 148a578c0f
commit 6a2dfc898b
19 changed files with 412 additions and 4 deletions

View File

@@ -880,6 +880,18 @@ mark_as_read
match_all
match_any
max
### MESH ###
mesh_beacon_invitations_title
mesh_beacon_notification_body
mesh_beacon_notification_title
mesh_beacon_offer_channel
mesh_beacon_offer_discover
mesh_beacon_offer_dismiss
mesh_beacon_offer_from_unknown
mesh_beacon_offer_join
mesh_beacon_offer_preset
mesh_beacon_offer_region
mesh_beacon_offer_title
mesh_map_location
mesh_map_location_description
### MESHTASTIC ###
@@ -889,6 +901,7 @@ meshtastic_app_name
meshtastic_broadcast_notifications
meshtastic_low_battery_notifications
meshtastic_low_battery_temporary_remote_notifications
meshtastic_mesh_beacon_notifications
meshtastic_messages_notifications
meshtastic_new_nodes_notifications
meshtastic_service_notifications

View File

@@ -29,6 +29,7 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
@@ -43,6 +44,7 @@ import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -62,8 +64,11 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.mesh_beacon_notification_body
import org.meshtastic.core.resources.mesh_beacon_notification_title
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.MeshBeacon
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
@@ -102,6 +107,7 @@ class MeshDataHandlerImpl(
private val adminPacketHandler: AdminPacketHandler,
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
private val geofenceMonitor: GeofenceMonitor,
private val meshBeaconRepository: MeshBeaconRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@@ -199,6 +205,10 @@ class MeshDataHandlerImpl(
handleRangeTest(dataPacket, myNodeNum)
}
PortNum.MESH_BEACON_APP -> {
handleMeshBeacon(packet)
}
else -> {}
}
}
@@ -208,6 +218,33 @@ class MeshDataHandlerImpl(
rememberDataPacket(u, myNodeNum)
}
/**
* A Mesh Beacon is an advisory, zero-hop invitation from another mesh — not a contact in the local NodeDB and not a
* message. We stash the offer in [MeshBeaconRepository] for the Discovery surface to present, and fire a single
* 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) {
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)
if (meshBeaconRepository.add(offer)) {
scope.launch {
notificationManager.dispatch(
Notification(
title = getStringSuspend(Res.string.mesh_beacon_notification_title),
message = offer.message.ifBlank { getStringSuspend(Res.string.mesh_beacon_notification_body) },
category = Notification.Category.MeshBeacon,
// Literal URI avoids a core:navigation module dep (see NodeManagerImpl).
deepLinkUri = "meshtastic://meshtastic/discovery",
),
)
}
}
}
private fun handlePaxCounter(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return

View File

@@ -40,6 +40,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.MeshBeaconRepository
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
@@ -54,7 +55,9 @@ import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshBeacon
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
@@ -66,6 +69,7 @@ import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
@@ -87,6 +91,7 @@ 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)
@@ -131,6 +136,7 @@ class MeshDataHandlerTest {
crossingStore = GeofenceCrossingStore(),
scope = geofenceScope,
),
meshBeaconRepository = meshBeaconRepository,
scope = testScope,
)
@@ -365,6 +371,51 @@ class MeshDataHandlerTest {
verify { neighborInfoHandler.handleNeighborInfo(packet) }
}
// --- Mesh Beacon handling ---
@Test
fun `mesh beacon with a join offer is recorded as an invitation`() {
val beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet"))
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()),
)
every { dataMapper.toDataPacket(packet) } returns
DataPacket(
from = "!remote",
bytes = beacon.encode().toByteString(),
dataType = PortNum.MESH_BEACON_APP.value,
)
handler.handleReceivedData(packet, 123)
val offers = meshBeaconRepository.offers.value
assertEquals(1, offers.size)
assertEquals("Join us", offers.first().message)
assertEquals("PartyNet", offers.first().channelName)
}
@Test
fun `mesh beacon without a join offer is ignored`() {
val beacon = MeshBeacon(message = "Just saying hi") // no offer_channel → not actionable
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()),
)
every { dataMapper.toDataPacket(packet) } returns
DataPacket(
from = "!remote",
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

@@ -0,0 +1,39 @@
/*
* 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
import org.meshtastic.proto.MeshBeacon
/**
* A received Mesh Beacon invitation — an advisory, zero-hop advertisement from another mesh offering a channel to join.
* Beacons are unsigned and originate from nodes outside the local NodeDB, so this is deliberately not a
* message/contact; it lives in the Discovery surface instead.
*
* @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.
*/
data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) {
/** 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()}"
val message: String
get() = beacon.message
val channelName: String?
get() = beacon.offer_channel?.name?.ifBlank { null }
}

View File

@@ -24,6 +24,7 @@ import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.MeshBeacon
/**
* Return a [ChannelSet] that represents the ChannelSet encoded by the URL.
@@ -78,6 +79,19 @@ 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)
}
/**
* Return a URL that represents the [ChannelSet]
*

View File

@@ -65,6 +65,8 @@ object DeepLinkRouter {
"connections" -> listOf(ConnectionsRoute.Connections(uri.getQueryParameter("address")))
"discovery" -> listOf(DiscoveryRoute.DiscoveryGraph)
"map" -> routeMap(uri, pathSegments)
"nodes" -> routeNodes(uri, pathSegments)

View File

@@ -0,0 +1,53 @@
/*
* 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.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
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.
*/
class MeshBeaconRepository {
private val _offers = MutableStateFlow<List<MeshBeaconOffer>>(emptyList())
val offers: StateFlow<List<MeshBeaconOffer>> = _offers.asStateFlow()
/**
* 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
* rather than on every periodic re-broadcast.
*/
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) }
return isNew
}
fun dismiss(key: String) {
_offers.update { current -> current.filterNot { it.key == key } }
}
private companion object {
const val MAX_OFFERS = 20
}
}

View File

@@ -45,5 +45,8 @@ data class Notification(
Battery,
Alert,
Service,
/** Advisory Mesh Beacon invitations from other meshes — low-importance, its own channel. */
MeshBeacon,
}
}

View File

@@ -20,6 +20,7 @@ import org.koin.core.annotation.Module
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -29,6 +30,8 @@ import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl
@Module
class CoreRepositoryModule {
@Single fun provideMeshBeaconRepository(): MeshBeaconRepository = MeshBeaconRepository()
@Single
fun provideSendMessageUseCase(
@Provided nodeRepository: NodeRepository,

View File

@@ -910,6 +910,18 @@
<string name="match_all">Match All | Any</string>
<string name="match_any">Match Any | All</string>
<string name="max">Max</string>
<!-- MESH -->
<string name="mesh_beacon_invitations_title">Mesh invitations</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_channel">Channel: %1$s</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_region">Region: %1$s</string>
<string name="mesh_beacon_offer_title">Mesh invitation</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 -->
@@ -919,6 +931,7 @@
<string name="meshtastic_broadcast_notifications">Broadcast message notifications</string>
<string name="meshtastic_low_battery_notifications">Low battery notifications</string>
<string name="meshtastic_low_battery_temporary_remote_notifications">Low battery notifications (favorite nodes)</string>
<string name="meshtastic_mesh_beacon_notifications">Mesh invitation notifications</string>
<string name="meshtastic_messages_notifications">Direct message notifications</string>
<string name="meshtastic_new_nodes_notifications">New node notifications</string>
<string name="meshtastic_service_notifications">Service notifications</string>

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
import org.meshtastic.core.resources.meshtastic_messages_notifications
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
import org.meshtastic.core.resources.meshtastic_service_notifications
@@ -66,6 +67,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
listOf(
createChannel(Notification.Category.Message, Res.string.meshtastic_messages_notifications),
createChannel(Notification.Category.NodeEvent, Res.string.meshtastic_new_nodes_notifications),
createChannel(Notification.Category.MeshBeacon, Res.string.meshtastic_mesh_beacon_notifications),
createChannel(Notification.Category.Battery, Res.string.meshtastic_low_battery_notifications),
createChannel(Notification.Category.Alert, Res.string.meshtastic_alerts_notifications),
createChannel(Notification.Category.Service, Res.string.meshtastic_service_notifications),
@@ -97,6 +99,12 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
importance = SystemNotificationManager.IMPORTANCE_DEFAULT,
)
Notification.Category.MeshBeacon ->
ChannelConfig(
id = NotificationChannels.MESH_BEACON,
importance = SystemNotificationManager.IMPORTANCE_LOW,
)
Notification.Category.Battery ->
ChannelConfig(
id = NotificationChannels.LOW_BATTERY,

View File

@@ -23,6 +23,7 @@ object NotificationChannels {
const val WAYPOINTS = "my_waypoints"
const val ALERTS = "my_alerts"
const val NEW_NODES = "new_nodes"
const val MESH_BEACON = "mesh_beacon"
const val LOW_BATTERY = "low_battery"
const val LOW_BATTERY_REMOTE = "low_battery_remote"
const val CLIENT = "client_notifications"

View File

@@ -67,6 +67,7 @@ class DesktopNotificationManager(
when (notification.category) {
Notification.Category.Message -> prefs.messagesEnabled.value
Notification.Category.NodeEvent -> prefs.nodeEventsEnabled.value
Notification.Category.MeshBeacon -> prefs.nodeEventsEnabled.value
Notification.Category.Battery -> prefs.lowBatteryEnabled.value
Notification.Category.Alert -> true
Notification.Category.Service -> true

View File

@@ -170,6 +170,7 @@ class LinuxNotificationSender(
Notification.Category.Battery -> "device.warning"
Notification.Category.Alert -> "device.error"
Notification.Category.NodeEvent -> "network"
Notification.Category.MeshBeacon -> "network"
Notification.Category.Service -> "device"
}
libnotify.notify_notification_set_category(ptr, category)

View File

@@ -61,6 +61,7 @@ class MacOSNotificationSender private constructor(private val bridge: MacNotific
internal fun categorySubtitle(category: Notification.Category): String = when (category) {
Notification.Category.Message -> "Message"
Notification.Category.NodeEvent -> "Node Event"
Notification.Category.MeshBeacon -> "Mesh Invitation"
Notification.Category.Battery -> "Low Battery"
Notification.Category.Alert -> "Alert"
Notification.Category.Service -> "Service"

View File

@@ -27,7 +27,9 @@ import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.repository.DiscoveryPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.safeLaunch
@@ -42,6 +44,7 @@ class DiscoveryViewModel(
private val serviceRepository: ServiceRepository,
private val discoveryPrefs: DiscoveryPrefs,
private val check24GhzCapability: Check24GhzCapability,
private val meshBeaconRepository: MeshBeaconRepository,
radioConfigRepository: RadioConfigRepository,
discoveryDao: DiscoveryDao,
) : ViewModel() {
@@ -50,12 +53,12 @@ class DiscoveryViewModel(
val currentSession: StateFlow<DiscoverySessionEntity?> = scanEngine.currentSession
val connectionState: StateFlow<ConnectionState> = serviceRepository.connectionState
/** Mesh Beacon invitations received from other meshes, newest first. */
val beaconOffers: StateFlow<List<MeshBeaconOffer>> = meshBeaconRepository.offers
val homePreset: StateFlow<ChannelOption> =
radioConfigRepository.localConfigFlow
.map { localConfig ->
val presetEnum = localConfig.lora?.modem_preset
ChannelOption.entries.firstOrNull { it.modemPreset == presetEnum } ?: ChannelOption.DEFAULT
}
.map { localConfig -> ChannelOption.from(localConfig.lora?.modem_preset) ?: ChannelOption.DEFAULT }
.stateInWhileSubscribed(initialValue = ChannelOption.DEFAULT)
/** True when the radio is configured for LORA_24 region but hardware doesn't support 2.4 GHz. */
@@ -108,6 +111,22 @@ class DiscoveryViewModel(
}
}
/**
* 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].
*/
fun discoverOffer(offer: MeshBeaconOffer) {
val preset = ChannelOption.from(offer.beacon.offer_preset) ?: return
_selectedPresets.value = setOf(preset)
discoveryPrefs.setSelectedPresets(setOf(preset.name))
}
fun dismissOffer(offer: MeshBeaconOffer) {
meshBeaconRepository.dismiss(offer.key)
}
fun setDwellDuration(minutes: Int) {
_dwellDurationMinutes.value = minutes
discoveryPrefs.setDwellMinutes(minutes)

View File

@@ -17,13 +17,18 @@
package org.meshtastic.feature.discovery.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.EntryProviderScope
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
import org.meshtastic.feature.discovery.DiscoveryHistoryDetailViewModel
import org.meshtastic.feature.discovery.DiscoveryHistoryViewModel
import org.meshtastic.feature.discovery.DiscoveryMapViewModel
@@ -75,10 +80,18 @@ fun EntryProviderScope<NavKey>.discoveryGraph(backStack: NavBackStack<NavKey>) {
@Composable
private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
val viewModel = koinViewModel<DiscoveryViewModel>()
// Join reuses the QR channel-import flow (same ADD/REPLACE + "this retunes your radio" confirmation a scanned
// channel URL shows). UIViewModel is ViewModel-store-scoped per nav entry (MeshtasticNavDisplay uses
// rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a *different* instance —
// we must render the dialog here, against this entry's instance, or the offer never surfaces.
val uiViewModel = koinViewModel<UIViewModel>()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
DiscoveryScanScreen(
viewModel = viewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) },
onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) },
onJoinOffer = { beacon -> beacon.toChannelSet()?.let(uiViewModel::setRequestChannelSet) },
)
requestChannelSet?.let { ScannedQrCodeDialog(incoming = it, onDismiss = { uiViewModel.clearRequestChannelUrl() }) }
}

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -87,6 +88,7 @@ import org.meshtastic.core.resources.discovery_start_scan_reason_default_key
import org.meshtastic.core.resources.discovery_start_scan_reason_no_presets
import org.meshtastic.core.resources.discovery_start_scan_reason_not_connected
import org.meshtastic.core.resources.discovery_stop_scan
import org.meshtastic.core.resources.mesh_beacon_invitations_title
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Close
@@ -98,7 +100,9 @@ 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.DwellProgressIndicator
import org.meshtastic.feature.discovery.ui.component.MeshBeaconInvitationCard
import org.meshtastic.feature.discovery.ui.component.PresetPickerCard
import org.meshtastic.proto.MeshBeacon
private val CONTENT_PADDING = 16.dp
private val SECTION_SPACING = 16.dp
@@ -115,9 +119,11 @@ fun DiscoveryScanScreen(
onNavigateToSummary: (sessionId: Long) -> Unit,
onNavigateToHistory: () -> Unit,
modifier: Modifier = Modifier,
onJoinOffer: (MeshBeacon) -> Unit = {},
) {
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
val beaconOffers by viewModel.beaconOffers.collectAsStateWithLifecycle()
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
@@ -199,6 +205,25 @@ fun DiscoveryScanScreen(
}
if (!isScanning) {
// Received Mesh Beacon invitations from other meshes
if (beaconOffers.isNotEmpty()) {
item(key = "invitations_header") {
Text(
text = stringResource(Res.string.mesh_beacon_invitations_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
}
items(beaconOffers, key = { "invitation_${it.key}" }) { offer ->
MeshBeaconInvitationCard(
offer = offer,
onJoin = { onJoinOffer(offer.beacon) },
onDiscover = { viewModel.discoverOffer(offer) },
onDismiss = { viewModel.dismissOffer(offer) },
)
}
}
// Preset picker
item(key = "preset_picker") {
PresetPickerCard(

View File

@@ -0,0 +1,111 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.model.ChannelOption
import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.core.resources.Res
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
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_title
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.
*/
@Composable
internal fun MeshBeaconInvitationCard(
offer: MeshBeaconOffer,
onJoin: () -> Unit,
onDiscover: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
// Resolve to a ChannelOption so the preset shows a localized label and "Discover" only appears when the offered
// preset is one we can actually seed a scan with (matches DiscoveryViewModel.discoverOffer's success condition).
val presetOption = ChannelOption.from(offer.beacon.offer_preset)
val region = offer.beacon.offer_region
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(Res.string.mesh_beacon_offer_title),
style = MaterialTheme.typography.titleMedium,
)
val body = offer.message.ifBlank { stringResource(Res.string.mesh_beacon_offer_from_unknown) }
Text(text = body, style = MaterialTheme.typography.bodyMedium)
offer.channelName?.let {
Text(
text = stringResource(Res.string.mesh_beacon_offer_channel, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (region != null && region != RegionCode.UNSET) {
Text(
text = stringResource(Res.string.mesh_beacon_offer_region, region.name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
presetOption?.let {
Text(
text = stringResource(Res.string.mesh_beacon_offer_preset, it.displayName()),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.mesh_beacon_offer_dismiss)) }
Spacer(modifier = Modifier.weight(1f))
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)) }
}
}
}
}