diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 924d89b38..b7600ac57 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -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
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 d06434c42..d9d207056 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
@@ -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
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 89f59b948..5da583d76 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
@@ -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
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
new file mode 100644
index 000000000..15fc21582
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
@@ -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 .
+ */
+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 }
+}
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 f907c893c..c361009c7 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
@@ -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]
*
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
index 90dae94bd..dac31aaae 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
@@ -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)
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
new file mode 100644
index 000000000..3921b4f11
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt
@@ -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 .
+ */
+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>(emptyList())
+ val offers: StateFlow> = _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
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
index fb55e3a2e..678a78f2a 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt
@@ -45,5 +45,8 @@ data class Notification(
Battery,
Alert,
Service,
+
+ /** Advisory Mesh Beacon invitations from other meshes — low-importance, its own channel. */
+ MeshBeacon,
}
}
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 92e141625..4674d4586 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
@@ -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,
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index d1caff2d3..09999c816 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -910,6 +910,18 @@
Match All | Any
Match Any | All
Max
+
+ Mesh invitations
+ A nearby mesh invited you to join
+ Mesh invitation
+ Channel: %1$s
+ Discover
+ Dismiss
+ From an unknown node
+ Join
+ Preset: %1$s
+ Region: %1$s
+ Mesh invitation
Mesh Map Location
Enables the blue location dot for your phone in the mesh map.
@@ -919,6 +931,7 @@
Broadcast message notifications
Low battery notifications
Low battery notifications (favorite nodes)
+ Mesh invitation notifications
Direct message notifications
New node notifications
Service notifications
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
index f8f02a6ea..0e102d374 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
@@ -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,
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
index 8e692d769..bf1057865 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt
@@ -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"
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 628498b2a..9a003e63b 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -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
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
index c408f3e53..40f0da326 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt
@@ -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)
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
index f9e67a871..5ac93b2d2 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt
@@ -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"
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 87095e587..3b8cb5a0d 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
@@ -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 = scanEngine.currentSession
val connectionState: StateFlow = serviceRepository.connectionState
+ /** Mesh Beacon invitations received from other meshes, newest first. */
+ val beaconOffers: StateFlow> = meshBeaconRepository.offers
+
val homePreset: StateFlow =
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)
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 ab18b1aad..ecb823b22 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
@@ -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.discoveryGraph(backStack: NavBackStack) {
@Composable
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.
+ val uiViewModel = koinViewModel()
+ 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() }) }
}
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 3525ce519..75613e85a 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
@@ -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(
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
new file mode 100644
index 000000000..779f7af1c
--- /dev/null
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
@@ -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 .
+ */
+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)) }
+ }
+ }
+ }
+}