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)) } + } + } + } +}