mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-02 17:35:36 -04:00
feat(discovery): surface received Mesh Beacon invitations (#6043)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
13
.skills/compose-ui/strings-index.txt
generated
13
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -45,5 +45,8 @@ data class Notification(
|
||||
Battery,
|
||||
Alert,
|
||||
Service,
|
||||
|
||||
/** Advisory Mesh Beacon invitations from other meshes — low-importance, its own channel. */
|
||||
MeshBeacon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }) }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user