Skip to content

Commit e400acf

Browse files
jamesarichclaude
andauthored
fix(geofence): restrict crossing alerts to creator, add per-geofence opt-in (#6117)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f380737 commit e400acf

27 files changed

Lines changed: 526 additions & 36 deletions

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ import org.meshtastic.feature.map.LastHeardFilter
143143
import org.meshtastic.feature.map.component.EditWaypointDialog
144144
import org.meshtastic.feature.map.component.MapButton
145145
import org.meshtastic.feature.map.component.MapControlsOverlay
146+
import org.meshtastic.feature.map.component.WaypointInfoDialog
146147
import org.meshtastic.proto.Waypoint
147148
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
148149
import org.osmdroid.config.Configuration
@@ -256,6 +257,7 @@ fun MapView(
256257
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
257258
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
258259
var showDeleteWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
260+
var showGeofenceInfoDialog by remember { mutableStateOf<Waypoint?>(null) }
259261
var showCacheManagerDialog by remember { mutableStateOf(false) }
260262
var showCurrentCacheInfo by remember { mutableStateOf(false) }
261263
var showPurgeTileSourceDialog by remember { mutableStateOf(false) }
@@ -424,11 +426,15 @@ fun MapView(
424426
performHapticFeedback()
425427
Logger.d { "marker long pressed id=$id" }
426428
val waypoint = waypoints[id]?.waypoint ?: return
427-
// edit only when unlocked or lockedTo myNodeNum
428-
if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
429-
showEditWaypointDialog = waypoint
430-
} else {
431-
showDeleteWaypointDialog = waypoint
429+
when {
430+
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
431+
waypoint.toGeofence() != null && !mapViewModel.isMyWaypoint(id) -> showGeofenceInfoDialog = waypoint
432+
433+
// edit only when unlocked or lockedTo myNodeNum
434+
waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected ->
435+
showEditWaypointDialog = waypoint
436+
437+
else -> showDeleteWaypointDialog = waypoint
432438
}
433439
}
434440

@@ -819,6 +825,28 @@ fun MapView(
819825
)
820826
}
821827

828+
showGeofenceInfoDialog?.let { waypoint ->
829+
val optIns by mapViewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
830+
WaypointInfoDialog(
831+
waypoint = waypoint,
832+
displayUnits = displayUnits,
833+
alertsEnabled = waypoint.id in optIns,
834+
onToggleAlerts = { mapViewModel.setGeofenceAlertOptIn(waypoint.id, it) },
835+
onDismissRequest = { showGeofenceInfoDialog = null },
836+
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing means
837+
// re-sending); locked ones stay read-only.
838+
onEdit =
839+
if (waypoint.locked_to == 0 && isConnected) {
840+
{
841+
showGeofenceInfoDialog = null
842+
showEditWaypointDialog = waypoint
843+
}
844+
} else {
845+
null
846+
},
847+
)
848+
}
849+
822850
if (showDeleteWaypointDialog != null) {
823851
val waypoint = showDeleteWaypointDialog ?: return
824852
val canDeleteForEveryone = waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected

androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import org.koin.core.annotation.KoinViewModel
2424
import org.meshtastic.core.common.BuildConfigProvider
2525
import org.meshtastic.core.repository.MapPrefs
2626
import org.meshtastic.core.repository.NodeRepository
27+
import org.meshtastic.core.repository.NotificationPrefs
2728
import org.meshtastic.core.repository.PacketRepository
2829
import org.meshtastic.core.repository.RadioConfigRepository
2930
import org.meshtastic.core.repository.RadioController
@@ -37,9 +38,17 @@ class MapViewModel(
3738
nodeRepository: NodeRepository,
3839
radioController: RadioController,
3940
radioConfigRepository: RadioConfigRepository,
41+
notificationPrefs: NotificationPrefs,
4042
buildConfigProvider: BuildConfigProvider,
4143
savedStateHandle: SavedStateHandle,
42-
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
44+
) : BaseMapViewModel(
45+
mapPrefs,
46+
nodeRepository,
47+
packetRepository,
48+
radioController,
49+
radioConfigRepository,
50+
notificationPrefs,
51+
) {
4352

4453
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
4554
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import org.meshtastic.feature.map.LastHeardFilter
150150
import org.meshtastic.feature.map.component.EditWaypointDialog
151151
import org.meshtastic.feature.map.component.MapButton
152152
import org.meshtastic.feature.map.component.MapControlsOverlay
153+
import org.meshtastic.feature.map.component.WaypointInfoDialog
153154
import org.meshtastic.feature.map.tracerouteNodeSelection
154155
import org.meshtastic.proto.BoundingBox
155156
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
@@ -250,6 +251,7 @@ fun MapView(
250251
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
251252
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
252253
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
254+
var geofenceInfoWaypoint by remember { mutableStateOf<Waypoint?>(null) }
253255
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
254256

255257
// --- Geofence box authoring (Main mode) ---
@@ -648,6 +650,7 @@ fun MapView(
648650
myNodeNum = myNodeNum,
649651
isConnected = isConnected,
650652
onEditWaypointRequest = { editingWaypoint = it },
653+
onShowGeofenceInfo = { geofenceInfoWaypoint = it },
651654
selectedWaypointId = selectedWaypointId,
652655
mapLayers = mapLayers,
653656
mapViewModel = mapViewModel,
@@ -719,6 +722,28 @@ fun MapView(
719722
},
720723
)
721724
}
725+
726+
geofenceInfoWaypoint?.let { waypoint ->
727+
val optIns by mapViewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
728+
WaypointInfoDialog(
729+
waypoint = waypoint,
730+
displayUnits = displayUnits,
731+
alertsEnabled = waypoint.id in optIns,
732+
onToggleAlerts = { mapViewModel.setGeofenceAlertOptIn(waypoint.id, it) },
733+
onDismissRequest = { geofenceInfoWaypoint = null },
734+
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing
735+
// means re-sending); locked ones stay read-only.
736+
onEdit =
737+
if (waypoint.locked_to == 0 && isConnected) {
738+
{
739+
geofenceInfoWaypoint = null
740+
editingWaypoint = waypoint
741+
}
742+
} else {
743+
null
744+
},
745+
)
746+
}
722747
}
723748

724749
// 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(
901926
myNodeNum: Int?,
902927
isConnected: Boolean,
903928
onEditWaypointRequest: (Waypoint) -> Unit,
929+
onShowGeofenceInfo: (Waypoint) -> Unit,
904930
selectedWaypointId: Int?,
905931
mapLayers: List<MapLayerItem>,
906932
mapViewModel: MapViewModel,
@@ -942,6 +968,8 @@ private fun MainMapContent(
942968
myNodeNum = myNodeNum ?: 0,
943969
isConnected = isConnected,
944970
onEditWaypointRequest = onEditWaypointRequest,
971+
isMyWaypoint = mapViewModel::isMyWaypoint,
972+
onShowGeofenceInfo = onShowGeofenceInfo,
945973
selectedWaypointId = selectedWaypointId,
946974
)
947975

androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
5252
import org.meshtastic.core.model.NodeAddress
5353
import org.meshtastic.core.repository.MapPrefs
5454
import org.meshtastic.core.repository.NodeRepository
55+
import org.meshtastic.core.repository.NotificationPrefs
5556
import org.meshtastic.core.repository.PacketRepository
5657
import org.meshtastic.core.repository.RadioConfigRepository
5758
import org.meshtastic.core.repository.RadioController
@@ -91,8 +92,16 @@ class MapViewModel(
9192
radioController: RadioController,
9293
private val customTileProviderRepository: CustomTileProviderRepository,
9394
uiPrefs: UiPrefs,
95+
notificationPrefs: NotificationPrefs,
9496
savedStateHandle: SavedStateHandle,
95-
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
97+
) : BaseMapViewModel(
98+
mapPrefs,
99+
nodeRepository,
100+
packetRepository,
101+
radioController,
102+
radioConfigRepository,
103+
notificationPrefs,
104+
) {
96105

97106
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
98107
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ fun WaypointMarkers(
5050
myNodeNum: Int,
5151
isConnected: Boolean,
5252
onEditWaypointRequest: (Waypoint) -> Unit,
53+
isMyWaypoint: (Int) -> Boolean,
54+
onShowGeofenceInfo: (Waypoint) -> Unit,
5355
selectedWaypointId: Int? = null,
5456
) {
5557
val scope = rememberCoroutineScope()
@@ -92,10 +94,14 @@ fun WaypointMarkers(
9294
snippet = snippet,
9395
visible = true,
9496
onInfoWindowClick = {
95-
if (waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected) {
96-
onEditWaypointRequest(waypoint)
97-
} else {
98-
scope.launch { context.showToast(Res.string.locked) }
97+
when {
98+
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
99+
waypoint.toGeofence() != null && !isMyWaypoint(waypoint.id) -> onShowGeofenceInfo(waypoint)
100+
101+
waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected ->
102+
onEditWaypointRequest(waypoint)
103+
104+
else -> scope.launch { context.showToast(Res.string.locked) }
99105
}
100106
},
101107
)

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ import co.touchlab.kermit.Logger
2020
import kotlinx.coroutines.CoroutineScope
2121
import kotlinx.coroutines.channels.Channel
2222
import kotlinx.coroutines.flow.catch
23+
import kotlinx.coroutines.flow.combine
2324
import kotlinx.coroutines.launch
2425
import org.koin.core.annotation.Named
2526
import org.koin.core.annotation.Single
2627
import org.meshtastic.core.common.util.nowSeconds
2728
import org.meshtastic.core.model.geofence.activeWaypointPackets
28-
import org.meshtastic.core.model.geofence.notifiesOnCrossing
29+
import org.meshtastic.core.model.geofence.geofencesToMonitor
2930
import org.meshtastic.core.model.geofence.toGeofence
3031
import org.meshtastic.core.model.util.GeoConstants.DEG_D
3132
import org.meshtastic.core.repository.MeshNotificationManager
3233
import org.meshtastic.core.repository.NodeManager
34+
import org.meshtastic.core.repository.NotificationPrefs
3335
import org.meshtastic.core.repository.PacketRepository
3436
import org.meshtastic.core.resources.Res
3537
import org.meshtastic.core.resources.geofence
@@ -50,7 +52,10 @@ import kotlin.concurrent.Volatile
5052
*
5153
* Hooked from [MeshDataHandlerImpl.handlePosition]; holds the active geofence-bearing waypoints in memory (refreshed
5254
* from [PacketRepository.getWaypoints], normalised via [activeWaypointPackets] so stale/expired transmissions can't
53-
* fire). Crossing state lives in [GeofenceCrossingStore] (in-memory, baseline-on-first-sighting).
55+
* fire). By default only waypoints created by THIS device are tracked — geofences are mesh-broadcast, so without that
56+
* filter every receiver in range would alert on the creator's crossings — but the user can opt in to specific foreign
57+
* geofences via [NotificationPrefs.geofenceAlertOptIns]. Crossing state lives in [GeofenceCrossingStore] (in-memory,
58+
* baseline-on-first-sighting).
5459
*
5560
* Received positions are funnelled through a single ordered worker so that two positions for the same node can never be
5661
* evaluated out of order (which would corrupt the inside/outside baseline and fire a spurious or missed alert).
@@ -61,6 +66,7 @@ class GeofenceMonitor(
6166
private val nodeManager: NodeManager,
6267
private val serviceNotifications: MeshNotificationManager,
6368
private val crossingStore: GeofenceCrossingStore,
69+
private val notificationPrefs: NotificationPrefs,
6470
@Named("ServiceScope") private val scope: CoroutineScope,
6571
) {
6672

@@ -85,18 +91,19 @@ class GeofenceMonitor(
8591
}
8692
}
8793
}
88-
// A bad emission must not tear down the snapshot for the rest of the session.
94+
// A bad emission must not tear down the snapshot for the rest of the session. Combined with myNodeNum (so the
95+
// creator-only filter recomputes once our node number is known — waypoints can arrive before we're connected)
96+
// and the opt-in set (so subscribing to a foreign geofence takes effect immediately).
8997
scope.launch {
90-
packetRepository.value
91-
.getWaypoints()
98+
combine(
99+
packetRepository.value.getWaypoints(),
100+
nodeManager.myNodeNum,
101+
notificationPrefs.geofenceAlertOptIns,
102+
) { packets, myNodeNum, optIns ->
103+
packets.activeWaypointPackets(nowSeconds).values.geofencesToMonitor(myNodeNum, optIns)
104+
}
92105
.catch { Logger.e(it) { "Geofence waypoint stream failed; geofence tracking paused" } }
93-
.collect { packets ->
94-
val active =
95-
packets
96-
.activeWaypointPackets(nowSeconds)
97-
.values
98-
.mapNotNull { it.waypoint }
99-
.filter { it.notifiesOnCrossing }
106+
.collect { active ->
100107
activeGeofences = active
101108
crossingStore.retainOnly(active.map { it.id }.toSet())
102109
}

core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import dev.mokkery.verify.VerifyMode.Companion.exactly
2525
import dev.mokkery.verifySuspend
2626
import kotlinx.coroutines.ExperimentalCoroutinesApi
2727
import kotlinx.coroutines.cancel
28+
import kotlinx.coroutines.flow.MutableStateFlow
2829
import kotlinx.coroutines.flow.flowOf
2930
import kotlinx.coroutines.test.StandardTestDispatcher
3031
import kotlinx.coroutines.test.TestScope
@@ -34,6 +35,7 @@ import org.meshtastic.core.model.DataPacket
3435
import org.meshtastic.core.model.Node
3536
import org.meshtastic.core.repository.MeshNotificationManager
3637
import org.meshtastic.core.repository.NodeManager
38+
import org.meshtastic.core.repository.NotificationPrefs
3739
import org.meshtastic.core.repository.PacketRepository
3840
import org.meshtastic.proto.Position
3941
import org.meshtastic.proto.Waypoint
@@ -72,33 +74,59 @@ class GeofenceMonitorTest {
7274
notify_favorites_only = favoritesOnly,
7375
)
7476

77+
private data class Mocks(
78+
val packetRepository: PacketRepository,
79+
val nodeManager: NodeManager,
80+
val notifications: MeshNotificationManager,
81+
val notificationPrefs: NotificationPrefs,
82+
)
83+
7584
private fun mocks(
7685
wp: Waypoint,
7786
senderIsFavorite: Boolean = false,
78-
): Triple<PacketRepository, NodeManager, MeshNotificationManager> {
87+
createdByLocal: Boolean = true,
88+
optedInIds: Set<Int> = emptySet(),
89+
): Mocks {
7990
val packetRepository: PacketRepository = mock(MockMode.autofill)
8091
val nodeManager: NodeManager = mock(MockMode.autofill)
8192
val notifications: MeshNotificationManager = mock(MockMode.autofill)
82-
every { packetRepository.getWaypoints() } returns
83-
flowOf(listOf(DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp)))
93+
val notificationPrefs: NotificationPrefs = mock(MockMode.autofill)
94+
// from defaults to ^local (locally created); a remote creator uses another node's hex id.
95+
val packet =
96+
DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp).also {
97+
if (!createdByLocal) it.from = "!0000000$sender"
98+
}
99+
every { packetRepository.getWaypoints() } returns flowOf(listOf(packet))
100+
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
84101
every { nodeManager.nodeDBbyNodeNum } returns mapOf(sender to Node(num = sender, isFavorite = senderIsFavorite))
85-
return Triple(packetRepository, nodeManager, notifications)
102+
every { notificationPrefs.geofenceAlertOptIns } returns MutableStateFlow(optedInIds)
103+
return Mocks(packetRepository, nodeManager, notifications, notificationPrefs)
86104
}
87105

88106
private fun expectNoNotification(
89107
wp: Waypoint,
90108
senderIsFavorite: Boolean = false,
109+
createdByLocal: Boolean = true,
110+
optedInIds: Set<Int> = emptySet(),
91111
drive: (GeofenceMonitor) -> Unit,
92112
) = runTest {
93113
val scope = TestScope(StandardTestDispatcher(testScheduler))
94-
val (repo, nodes, notifications) = mocks(wp, senderIsFavorite)
95-
val monitor = GeofenceMonitor(lazy { repo }, nodes, notifications, GeofenceCrossingStore(), scope)
114+
val m = mocks(wp, senderIsFavorite, createdByLocal, optedInIds)
115+
val monitor =
116+
GeofenceMonitor(
117+
lazy { m.packetRepository },
118+
m.nodeManager,
119+
m.notifications,
120+
GeofenceCrossingStore(),
121+
m.notificationPrefs,
122+
scope,
123+
)
96124
scope.advanceUntilIdle() // collect the active-geofence snapshot
97125

98126
drive(monitor)
99127
scope.advanceUntilIdle()
100128

101-
verifySuspend(exactly(0)) { notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
129+
verifySuspend(exactly(0)) { m.notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
102130
scope.cancel() // stop the serial worker so runTest sees no leaked coroutine
103131
}
104132

@@ -118,6 +146,12 @@ class GeofenceMonitorTest {
118146
m.onPositionReceived(myNodeNum, myNodeNum, inside) // sender == self
119147
}
120148

149+
@Test
150+
fun remoteCreatedGeofenceDoesNotNotify() = expectNoNotification(waypoint(), createdByLocal = false) { m ->
151+
m.onPositionReceived(sender, myNodeNum, outside) // baseline
152+
m.onPositionReceived(sender, myNodeNum, inside) // genuine ENTER, but we didn't create this geofence
153+
}
154+
121155
@Test
122156
fun enterOnlyWaypointDoesNotNotifyOnExit() = expectNoNotification(waypoint(enter = true, exit = false)) { m ->
123157
m.onPositionReceived(sender, myNodeNum, inside) // baseline inside

0 commit comments

Comments
 (0)