fix(geofence): restrict crossing alerts to creator, add per-geofence opt-in (#6117)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-06 18:01:17 -05:00
committed by GitHub
parent f38073773e
commit e400acf305
27 changed files with 526 additions and 36 deletions

View File

@@ -669,6 +669,8 @@ generate_input_event_on_press
generate_qr_code
### GEOFENCE ###
geofence
geofence_alerts_opt_in
geofence_alerts_opt_in_desc
geofence_box_author_confirm
geofence_box_author_hint
geofence_box_author_hint_viewport

View File

@@ -143,6 +143,7 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@@ -256,6 +257,7 @@ fun MapView(
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
var showDeleteWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
var showGeofenceInfoDialog by remember { mutableStateOf<Waypoint?>(null) }
var showCacheManagerDialog by remember { mutableStateOf(false) }
var showCurrentCacheInfo by remember { mutableStateOf(false) }
var showPurgeTileSourceDialog by remember { mutableStateOf(false) }
@@ -424,11 +426,15 @@ fun MapView(
performHapticFeedback()
Logger.d { "marker long pressed id=$id" }
val waypoint = waypoints[id]?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteWaypointDialog = waypoint
when {
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
waypoint.toGeofence() != null && !mapViewModel.isMyWaypoint(id) -> showGeofenceInfoDialog = waypoint
// edit only when unlocked or lockedTo myNodeNum
waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected ->
showEditWaypointDialog = waypoint
else -> showDeleteWaypointDialog = waypoint
}
}
@@ -819,6 +825,28 @@ fun MapView(
)
}
showGeofenceInfoDialog?.let { waypoint ->
val optIns by mapViewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
WaypointInfoDialog(
waypoint = waypoint,
displayUnits = displayUnits,
alertsEnabled = waypoint.id in optIns,
onToggleAlerts = { mapViewModel.setGeofenceAlertOptIn(waypoint.id, it) },
onDismissRequest = { showGeofenceInfoDialog = null },
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing means
// re-sending); locked ones stay read-only.
onEdit =
if (waypoint.locked_to == 0 && isConnected) {
{
showGeofenceInfoDialog = null
showEditWaypointDialog = waypoint
}
} else {
null
},
)
}
if (showDeleteWaypointDialog != null) {
val waypoint = showDeleteWaypointDialog ?: return
val canDeleteForEveryone = waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected

View File

@@ -24,6 +24,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
@@ -37,9 +38,17 @@ class MapViewModel(
nodeRepository: NodeRepository,
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
notificationPrefs: NotificationPrefs,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
) : BaseMapViewModel(
mapPrefs,
nodeRepository,
packetRepository,
radioController,
radioConfigRepository,
notificationPrefs,
) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

View File

@@ -150,6 +150,7 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
@@ -250,6 +251,7 @@ fun MapView(
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var geofenceInfoWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
// --- Geofence box authoring (Main mode) ---
@@ -648,6 +650,7 @@ fun MapView(
myNodeNum = myNodeNum,
isConnected = isConnected,
onEditWaypointRequest = { editingWaypoint = it },
onShowGeofenceInfo = { geofenceInfoWaypoint = it },
selectedWaypointId = selectedWaypointId,
mapLayers = mapLayers,
mapViewModel = mapViewModel,
@@ -719,6 +722,28 @@ fun MapView(
},
)
}
geofenceInfoWaypoint?.let { waypoint ->
val optIns by mapViewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
WaypointInfoDialog(
waypoint = waypoint,
displayUnits = displayUnits,
alertsEnabled = waypoint.id in optIns,
onToggleAlerts = { mapViewModel.setGeofenceAlertOptIn(waypoint.id, it) },
onDismissRequest = { geofenceInfoWaypoint = null },
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing
// means re-sending); locked ones stay read-only.
onEdit =
if (waypoint.locked_to == 0 && isConnected) {
{
geofenceInfoWaypoint = null
editingWaypoint = waypoint
}
} else {
null
},
)
}
}
// Box-authoring affordance: instruct the user to tap two corners, with a cancel that re-opens the editor.
@@ -901,6 +926,7 @@ private fun MainMapContent(
myNodeNum: Int?,
isConnected: Boolean,
onEditWaypointRequest: (Waypoint) -> Unit,
onShowGeofenceInfo: (Waypoint) -> Unit,
selectedWaypointId: Int?,
mapLayers: List<MapLayerItem>,
mapViewModel: MapViewModel,
@@ -942,6 +968,8 @@ private fun MainMapContent(
myNodeNum = myNodeNum ?: 0,
isConnected = isConnected,
onEditWaypointRequest = onEditWaypointRequest,
isMyWaypoint = mapViewModel::isMyWaypoint,
onShowGeofenceInfo = onShowGeofenceInfo,
selectedWaypointId = selectedWaypointId,
)

View File

@@ -52,6 +52,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
@@ -91,8 +92,16 @@ class MapViewModel(
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPrefs: UiPrefs,
notificationPrefs: NotificationPrefs,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
) : BaseMapViewModel(
mapPrefs,
nodeRepository,
packetRepository,
radioController,
radioConfigRepository,
notificationPrefs,
) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

View File

@@ -50,6 +50,8 @@ fun WaypointMarkers(
myNodeNum: Int,
isConnected: Boolean,
onEditWaypointRequest: (Waypoint) -> Unit,
isMyWaypoint: (Int) -> Boolean,
onShowGeofenceInfo: (Waypoint) -> Unit,
selectedWaypointId: Int? = null,
) {
val scope = rememberCoroutineScope()
@@ -92,10 +94,14 @@ fun WaypointMarkers(
snippet = snippet,
visible = true,
onInfoWindowClick = {
if (waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected) {
onEditWaypointRequest(waypoint)
} else {
scope.launch { context.showToast(Res.string.locked) }
when {
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
waypoint.toGeofence() != null && !isMyWaypoint(waypoint.id) -> onShowGeofenceInfo(waypoint)
waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected ->
onEditWaypointRequest(waypoint)
else -> scope.launch { context.showToast(Res.string.locked) }
}
},
)

View File

@@ -20,16 +20,18 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.geofence.notifiesOnCrossing
import org.meshtastic.core.model.geofence.geofencesToMonitor
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.geofence
@@ -50,7 +52,10 @@ import kotlin.concurrent.Volatile
*
* Hooked from [MeshDataHandlerImpl.handlePosition]; holds the active geofence-bearing waypoints in memory (refreshed
* from [PacketRepository.getWaypoints], normalised via [activeWaypointPackets] so stale/expired transmissions can't
* fire). Crossing state lives in [GeofenceCrossingStore] (in-memory, baseline-on-first-sighting).
* fire). By default only waypoints created by THIS device are tracked — geofences are mesh-broadcast, so without that
* filter every receiver in range would alert on the creator's crossings — but the user can opt in to specific foreign
* geofences via [NotificationPrefs.geofenceAlertOptIns]. Crossing state lives in [GeofenceCrossingStore] (in-memory,
* baseline-on-first-sighting).
*
* Received positions are funnelled through a single ordered worker so that two positions for the same node can never be
* evaluated out of order (which would corrupt the inside/outside baseline and fire a spurious or missed alert).
@@ -61,6 +66,7 @@ class GeofenceMonitor(
private val nodeManager: NodeManager,
private val serviceNotifications: MeshNotificationManager,
private val crossingStore: GeofenceCrossingStore,
private val notificationPrefs: NotificationPrefs,
@Named("ServiceScope") private val scope: CoroutineScope,
) {
@@ -85,18 +91,19 @@ class GeofenceMonitor(
}
}
}
// A bad emission must not tear down the snapshot for the rest of the session.
// A bad emission must not tear down the snapshot for the rest of the session. Combined with myNodeNum (so the
// creator-only filter recomputes once our node number is known — waypoints can arrive before we're connected)
// and the opt-in set (so subscribing to a foreign geofence takes effect immediately).
scope.launch {
packetRepository.value
.getWaypoints()
combine(
packetRepository.value.getWaypoints(),
nodeManager.myNodeNum,
notificationPrefs.geofenceAlertOptIns,
) { packets, myNodeNum, optIns ->
packets.activeWaypointPackets(nowSeconds).values.geofencesToMonitor(myNodeNum, optIns)
}
.catch { Logger.e(it) { "Geofence waypoint stream failed; geofence tracking paused" } }
.collect { packets ->
val active =
packets
.activeWaypointPackets(nowSeconds)
.values
.mapNotNull { it.waypoint }
.filter { it.notifiesOnCrossing }
.collect { active ->
activeGeofences = active
crossingStore.retainOnly(active.map { it.id }.toSet())
}

View File

@@ -25,6 +25,7 @@ import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -34,6 +35,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -72,33 +74,59 @@ class GeofenceMonitorTest {
notify_favorites_only = favoritesOnly,
)
private data class Mocks(
val packetRepository: PacketRepository,
val nodeManager: NodeManager,
val notifications: MeshNotificationManager,
val notificationPrefs: NotificationPrefs,
)
private fun mocks(
wp: Waypoint,
senderIsFavorite: Boolean = false,
): Triple<PacketRepository, NodeManager, MeshNotificationManager> {
createdByLocal: Boolean = true,
optedInIds: Set<Int> = emptySet(),
): Mocks {
val packetRepository: PacketRepository = mock(MockMode.autofill)
val nodeManager: NodeManager = mock(MockMode.autofill)
val notifications: MeshNotificationManager = mock(MockMode.autofill)
every { packetRepository.getWaypoints() } returns
flowOf(listOf(DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp)))
val notificationPrefs: NotificationPrefs = mock(MockMode.autofill)
// from defaults to ^local (locally created); a remote creator uses another node's hex id.
val packet =
DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp).also {
if (!createdByLocal) it.from = "!0000000$sender"
}
every { packetRepository.getWaypoints() } returns flowOf(listOf(packet))
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(sender to Node(num = sender, isFavorite = senderIsFavorite))
return Triple(packetRepository, nodeManager, notifications)
every { notificationPrefs.geofenceAlertOptIns } returns MutableStateFlow(optedInIds)
return Mocks(packetRepository, nodeManager, notifications, notificationPrefs)
}
private fun expectNoNotification(
wp: Waypoint,
senderIsFavorite: Boolean = false,
createdByLocal: Boolean = true,
optedInIds: Set<Int> = emptySet(),
drive: (GeofenceMonitor) -> Unit,
) = runTest {
val scope = TestScope(StandardTestDispatcher(testScheduler))
val (repo, nodes, notifications) = mocks(wp, senderIsFavorite)
val monitor = GeofenceMonitor(lazy { repo }, nodes, notifications, GeofenceCrossingStore(), scope)
val m = mocks(wp, senderIsFavorite, createdByLocal, optedInIds)
val monitor =
GeofenceMonitor(
lazy { m.packetRepository },
m.nodeManager,
m.notifications,
GeofenceCrossingStore(),
m.notificationPrefs,
scope,
)
scope.advanceUntilIdle() // collect the active-geofence snapshot
drive(monitor)
scope.advanceUntilIdle()
verifySuspend(exactly(0)) { notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
verifySuspend(exactly(0)) { m.notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
scope.cancel() // stop the serial worker so runTest sees no leaked coroutine
}
@@ -118,6 +146,12 @@ class GeofenceMonitorTest {
m.onPositionReceived(myNodeNum, myNodeNum, inside) // sender == self
}
@Test
fun remoteCreatedGeofenceDoesNotNotify() = expectNoNotification(waypoint(), createdByLocal = false) { m ->
m.onPositionReceived(sender, myNodeNum, outside) // baseline
m.onPositionReceived(sender, myNodeNum, inside) // genuine ENTER, but we didn't create this geofence
}
@Test
fun enterOnlyWaypointDoesNotNotifyOnExit() = expectNoNotification(waypoint(enter = true, exit = false)) { m ->
m.onPositionReceived(sender, myNodeNum, inside) // baseline inside

View File

@@ -56,6 +56,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Data
@@ -147,6 +148,7 @@ class MeshDataHandlerTest {
nodeManager = nodeManager,
serviceNotifications = serviceNotifications,
crossingStore = GeofenceCrossingStore(),
notificationPrefs = FakeNotificationPrefs(),
scope = geofenceScope,
),
meshBeaconRepository = meshBeaconRepository,

View File

@@ -17,6 +17,8 @@
package org.meshtastic.core.model.geofence
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.proto.Waypoint
/**
* Collapse a raw waypoint-packet list into the set of currently active waypoints, keyed by waypoint id.
@@ -33,3 +35,14 @@ fun List<DataPacket>.activeWaypointPackets(nowSeconds: Long): Map<Int, DataPacke
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
}
/**
* The geofences whose crossings THIS device should raise notifications for: waypoints we created ([isFromLocal]) plus
* any the user has explicitly opted into ([optedInIds]). Foreign geofences are excluded by default because waypoints
* are mesh-broadcast — every receiver stores them, so without this gate everyone in range would alert on the creator's
* crossings. Only waypoints that actually request crossing notifications ([notifiesOnCrossing]) survive.
*/
fun Collection<DataPacket>.geofencesToMonitor(myNodeNum: Int?, optedInIds: Set<Int>): List<Waypoint> =
filter { it.isFromLocal(myNodeNum) || (it.waypoint?.id in optedInIds) }
.mapNotNull { it.waypoint }
.filter { it.notifiesOnCrossing }

View File

@@ -53,4 +53,38 @@ class ActiveWaypointsTest {
assertTrue(result.containsKey(2))
assertTrue(result.containsKey(3))
}
private val myNodeNum = 7
private fun geofence(id: Int) = Waypoint(id = id, geofence_radius = 100, notify_on_enter = true)
private fun localPacket(wp: Waypoint) = DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp) // from = ^local
private fun remotePacket(wp: Waypoint) =
DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp).also { it.from = "!00000009" } // node 9
@Test
fun geofencesToMonitorKeepsOwnDropsForeign() {
val packets = listOf(localPacket(geofence(1)), remotePacket(geofence(2)))
val monitored = packets.geofencesToMonitor(myNodeNum, optedInIds = emptySet()).map { it.id }
assertEquals(listOf(1), monitored) // only the locally-created geofence
}
@Test
fun geofencesToMonitorIncludesOptedInForeign() {
val packets = listOf(localPacket(geofence(1)), remotePacket(geofence(2)))
val monitored = packets.geofencesToMonitor(myNodeNum, optedInIds = setOf(2)).map { it.id }.sorted()
assertEquals(listOf(1, 2), monitored) // opting into #2 adds the foreign geofence back
}
@Test
fun geofencesToMonitorDropsWaypointsWithNoCrossingNotifications() {
val silent = localPacket(Waypoint(id = 1, geofence_radius = 100)) // notify_on_enter/exit both false
assertTrue(listOf(silent).geofencesToMonitor(myNodeNum, optedInIds = emptySet()).isEmpty())
}
}

View File

@@ -20,6 +20,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
@@ -67,10 +68,39 @@ class NotificationPrefsImpl(
scope.launch { dataStore.edit { it[KEY_LOW_BATTERY_ENABLED] = enabled } }
}
private companion object {
val KEY_MESSAGES_ENABLED = booleanPreferencesKey("notif_messages_enabled")
val KEY_NODE_EVENTS_ENABLED = booleanPreferencesKey("notif_node_events_enabled")
val KEY_NODE_EVENTS_AUTO_DISABLED = booleanPreferencesKey("notif_node_events_auto_disabled_event")
val KEY_LOW_BATTERY_ENABLED = booleanPreferencesKey("notif_low_battery_enabled")
// Stored as an insertion-ordered CSV of ints (DataStore has no int-set key, and a plain set can't express age).
// Ordering lets us evict the oldest opt-in once capped; non-numeric leftovers are ignored on read.
override val geofenceAlertOptIns: StateFlow<Set<Int>> =
dataStore.data
.map { prefs -> parseOptInIds(prefs[KEY_GEOFENCE_ALERT_OPT_INS]).toSet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) {
scope.launch {
dataStore.edit { prefs ->
val ids = parseOptInIds(prefs[KEY_GEOFENCE_ALERT_OPT_INS]).toMutableList()
ids.remove(waypointId) // re-toggling moves it to the most-recent slot
if (enabled) {
ids.add(waypointId)
// Bound growth: opt-ins accumulate as waypoints churn, so keep only the most-recent MAX.
while (ids.size > MAX_GEOFENCE_OPT_INS) ids.removeAt(0)
}
prefs[KEY_GEOFENCE_ALERT_OPT_INS] = ids.joinToString(",")
}
}
}
companion object {
/** Cap on remembered foreign-geofence opt-ins; far above realistic manual use, oldest evicted past it. */
const val MAX_GEOFENCE_OPT_INS = 100
private fun parseOptInIds(csv: String?): List<Int> =
csv?.split(',')?.mapNotNull { it.toIntOrNull() }?.distinct() ?: emptyList()
private val KEY_MESSAGES_ENABLED = booleanPreferencesKey("notif_messages_enabled")
private val KEY_NODE_EVENTS_ENABLED = booleanPreferencesKey("notif_node_events_enabled")
private val KEY_NODE_EVENTS_AUTO_DISABLED = booleanPreferencesKey("notif_node_events_auto_disabled_event")
private val KEY_LOW_BATTERY_ENABLED = booleanPreferencesKey("notif_low_battery_enabled")
private val KEY_GEOFENCE_ALERT_OPT_INS = stringPreferencesKey("notif_geofence_alert_opt_ins")
}
}

View File

@@ -29,6 +29,7 @@ import org.meshtastic.core.repository.NotificationPrefs
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.uuid.Uuid
@@ -89,4 +90,48 @@ class NotificationPrefsTest {
notificationPrefs.setLowBatteryEnabled(false)
assertFalse(notificationPrefs.lowBatteryEnabled.value)
}
@Test
fun `geofenceAlertOptIns defaults to empty`() =
testScope.runTest { assertTrue(notificationPrefs.geofenceAlertOptIns.value.isEmpty()) }
@Test
fun `geofenceAlertOptIns adds and removes waypoint ids`() = testScope.runTest {
notificationPrefs.setGeofenceAlertOptIn(42, enabled = true)
notificationPrefs.setGeofenceAlertOptIn(7, enabled = true)
assertEquals(setOf(7, 42), notificationPrefs.geofenceAlertOptIns.value)
notificationPrefs.setGeofenceAlertOptIn(42, enabled = false)
assertEquals(setOf(7), notificationPrefs.geofenceAlertOptIns.value)
}
@Test
fun `geofenceAlertOptIns caps size and evicts oldest`() = testScope.runTest {
val max = NotificationPrefsImpl.MAX_GEOFENCE_OPT_INS
(1..max + 2).forEach { notificationPrefs.setGeofenceAlertOptIn(it, enabled = true) }
val ids = notificationPrefs.geofenceAlertOptIns.value
assertEquals(max, ids.size)
assertFalse(1 in ids) // oldest two evicted
assertFalse(2 in ids)
assertTrue(max + 2 in ids) // newest kept
}
@Test
fun `geofenceAlertOptIns retoggle refreshes eviction order`() = testScope.runTest {
val max = NotificationPrefsImpl.MAX_GEOFENCE_OPT_INS
(1..max).forEach { notificationPrefs.setGeofenceAlertOptIn(it, enabled = true) } // 1 = oldest
notificationPrefs.setGeofenceAlertOptIn(
1,
enabled = true,
) // re-toggle → 1 becomes most-recent, 2 now oldest
notificationPrefs.setGeofenceAlertOptIn(max + 1, enabled = true) // forces one eviction
val ids = notificationPrefs.geofenceAlertOptIns.value
assertEquals(max, ids.size)
assertTrue(1 in ids) // retoggled id survived
assertFalse(2 in ids) // 2 became the oldest and was evicted
assertTrue(max + 1 in ids)
}
}

View File

@@ -204,6 +204,14 @@ interface NotificationPrefs {
val lowBatteryEnabled: StateFlow<Boolean>
fun setLowBatteryEnabled(enabled: Boolean)
/**
* Waypoint ids of foreign (not locally-created) geofences the user has opted in to receiving crossing alerts for.
* Geofences are mesh-broadcast, so by default only the creator is alerted; this is the per-geofence opt-in.
*/
val geofenceAlertOptIns: StateFlow<Set<Int>>
fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean)
}
/** Reactive interface for general map preferences. */

View File

@@ -693,6 +693,8 @@
<string name="generate_qr_code">Generate QR Code</string>
<!-- GEOFENCE -->
<string name="geofence">Geofence</string>
<string name="geofence_alerts_opt_in">Notify me of crossings</string>
<string name="geofence_alerts_opt_in_desc">Get an alert when nodes enter or leave this geofence</string>
<string name="geofence_box_author_confirm">Confirm area</string>
<string name="geofence_box_author_hint">Tap two corners to define the area</string>
<string name="geofence_box_author_hint_viewport">Pan the map to frame the area, then tap Confirm area</string>

View File

@@ -43,4 +43,11 @@ class FakeNotificationPrefs : NotificationPrefs {
override fun setLowBatteryEnabled(enabled: Boolean) {
lowBatteryEnabled.value = enabled
}
override val geofenceAlertOptIns = MutableStateFlow<Set<Int>>(emptySet())
override fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) {
geofenceAlertOptIns.value =
geofenceAlertOptIns.value.toMutableSet().apply { if (enabled) add(waypointId) else remove(waypointId) }
}
}

View File

@@ -67,6 +67,13 @@ class DesktopNotificationManagerTest {
override fun setLowBatteryEnabled(enabled: Boolean) {
lowBatteryEnabled.value = enabled
}
override val geofenceAlertOptIns = MutableStateFlow<Set<Int>>(emptySet())
override fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) {
geofenceAlertOptIns.value =
geofenceAlertOptIns.value.toMutableSet().apply { if (enabled) add(waypointId) else remove(waypointId) }
}
}
@Test

View File

@@ -0,0 +1,112 @@
/*
* 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.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.edit
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.geofence_alerts_opt_in
import org.meshtastic.core.resources.geofence_alerts_opt_in_desc
import org.meshtastic.core.resources.geofence_radius
import org.meshtastic.core.resources.geofence_set_area
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
/**
* Read-only view of a geofence waypoint the local device did NOT create. Editing/re-broadcasting someone else's
* geofence is not offered here; the one interactive control is the receiver-local opt-in that decides whether THIS
* device raises crossing notifications for it (foreign geofences are silent by default — see
* [org.meshtastic.core.data.manager .GeofenceMonitor]). Reached for both locked and unlocked foreign geofences, so the
* locked case gets a view at all. Unlocked foreign geofences are still editable — [onEdit], when non-null, opens the
* full editor.
*/
@Composable
fun WaypointInfoDialog(
waypoint: Waypoint,
displayUnits: DisplayUnits,
alertsEnabled: Boolean,
onToggleAlerts: (Boolean) -> Unit,
onDismissRequest: () -> Unit,
onEdit: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val emoji = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
val title = waypoint.name.takeIf { it.isNotBlank() } ?: stringResource(Res.string.geofence)
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = "${String(Character.toChars(emoji))} $title", fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
if (waypoint.description.isNotBlank()) {
Text(waypoint.description, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.size(8.dp))
}
if (waypoint.geofence_radius > 0) {
Text(
"${stringResource(Res.string.geofence_radius)}: " +
waypoint.geofence_radius.toDistanceString(displayUnits),
style = MaterialTheme.typography.bodyMedium,
)
}
if (waypoint.bounding_box != null) {
Text(stringResource(Res.string.geofence_set_area), style = MaterialTheme.typography.bodyMedium)
}
Spacer(modifier = Modifier.size(8.dp))
HorizontalDivider()
// The one interactive control: whether this device alerts on crossings of a geofence it didn't create.
BasicListItem(
text = stringResource(Res.string.geofence_alerts_opt_in),
supportingText = stringResource(Res.string.geofence_alerts_opt_in_desc),
trailingContent = { Switch(checked = alertsEnabled, onCheckedChange = null) },
onClick = { onToggleAlerts(!alertsEnabled) },
)
}
},
confirmButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.close)) } },
// Unlocked foreign geofences stay editable; the button is absent for locked ones.
dismissButton =
if (onEdit != null) {
{ TextButton(onClick = onEdit) { Text(stringResource(Res.string.edit)) } }
} else {
null
},
modifier = modifier,
)
}
private const val PUSHPIN = 0x1F4CD // 📍 Round Pushpin

View File

@@ -0,0 +1,68 @@
/*
* 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.map.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
private fun sampleGeofence(lockedTo: Int) = Waypoint(
id = 42,
name = "Trailhead",
description = "North gate of the reserve",
latitude_i = 377_749_000,
longitude_i = -1_224_194_000,
geofence_radius = 500,
notify_on_enter = true,
locked_to = lockedTo,
)
/** Locked foreign geofence: read-only, opt-in off, no Edit affordance. */
@PreviewLightDark
@Composable
@Suppress("PreviewPublic")
fun WaypointInfoDialogReadOnlyPreview() {
AppTheme {
WaypointInfoDialog(
waypoint = sampleGeofence(lockedTo = 7),
displayUnits = DisplayUnits.METRIC,
alertsEnabled = false,
onToggleAlerts = {},
onDismissRequest = {},
onEdit = null,
)
}
}
/** Unlocked foreign geofence: opted in, with the Edit affordance into the full editor. */
@PreviewLightDark
@Composable
@Suppress("PreviewPublic")
fun WaypointInfoDialogOptedInPreview() {
AppTheme {
WaypointInfoDialog(
waypoint = sampleGeofence(lockedTo = 0),
displayUnits = DisplayUnits.METRIC,
alertsEnabled = true,
onToggleAlerts = {},
onDismissRequest = {},
onEdit = {},
)
}
}

View File

@@ -32,9 +32,11 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
@@ -63,6 +65,7 @@ open class BaseMapViewModel(
private val packetRepository: PacketRepository,
private val radioController: RadioController,
private val radioConfigRepository: RadioConfigRepository,
private val notificationPrefs: NotificationPrefs,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo
@@ -105,6 +108,15 @@ open class BaseMapViewModel(
.mapLatest { list -> list.activeWaypointPackets(nowSeconds) }
.stateInWhileSubscribed(initialValue = emptyMap())
/** Waypoint ids of foreign geofences the user opted in to crossing alerts for (see [NotificationPrefs]). */
val geofenceAlertOptIns: StateFlow<Set<Int>> = notificationPrefs.geofenceAlertOptIns
fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) =
notificationPrefs.setGeofenceAlertOptIn(waypointId, enabled)
/** True if the waypoint with [id] was created by this device (vs. received from another node over the mesh). */
fun isMyWaypoint(id: Int): Boolean = waypoints.value[id]?.isFromLocal(myNodeNum) == true
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
val showOnlyFavoritesOnMap: StateFlow<Boolean> = showOnlyFavorites.asStateFlow()

View File

@@ -19,6 +19,7 @@ package org.meshtastic.feature.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
@@ -30,4 +31,12 @@ class SharedMapViewModel(
packetRepository: PacketRepository,
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository)
notificationPrefs: NotificationPrefs,
) : BaseMapViewModel(
mapPrefs,
nodeRepository,
packetRepository,
radioController,
radioConfigRepository,
notificationPrefs,
)

View File

@@ -34,6 +34,7 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
@@ -80,6 +81,7 @@ class BaseMapViewModelTest {
packetRepository = packetRepository,
radioController = radioController,
radioConfigRepository = radioConfigRepository,
notificationPrefs = FakeNotificationPrefs(),
)
}

View File

@@ -20,6 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.android.tools.screenshot.PreviewTest
import org.meshtastic.feature.map.component.MapControlsOverlayPreview
import org.meshtastic.feature.map.component.WaypointInfoDialogOptedInPreview
import org.meshtastic.feature.map.component.WaypointInfoDialogReadOnlyPreview
@PreviewTest
@PreviewLightDark
@@ -27,3 +29,17 @@ import org.meshtastic.feature.map.component.MapControlsOverlayPreview
fun ScreenshotMapControlsOverlay() {
MapControlsOverlayPreview()
}
@PreviewTest
@PreviewLightDark
@Composable
fun ScreenshotWaypointInfoDialogReadOnly() {
WaypointInfoDialogReadOnlyPreview()
}
@PreviewTest
@PreviewLightDark
@Composable
fun ScreenshotWaypointInfoDialogOptedIn() {
WaypointInfoDialogOptedInPreview()
}