diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 6709a4fba..95889f93f 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 1fb0e79b2..ca4984b64 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(null) } var showDeleteWaypointDialog by remember { mutableStateOf(null) } + var showGeofenceInfoDialog by remember { mutableStateOf(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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index c48b0d412..49ffec776 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 7eafc1637..61a7c02c7 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(null) } + var geofenceInfoWaypoint by remember { mutableStateOf(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, mapViewModel: MapViewModel, @@ -942,6 +968,8 @@ private fun MainMapContent( myNodeNum = myNodeNum ?: 0, isConnected = isConnected, onEditWaypointRequest = onEditWaypointRequest, + isMyWaypoint = mapViewModel::isMyWaypoint, + onShowGeofenceInfo = onShowGeofenceInfo, selectedWaypointId = selectedWaypointId, ) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index c378dc6ef..6cd8a7b7d 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index 97dd9a433..73b66698b 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -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) } } }, ) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt index 19908c568..3be4260f5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt @@ -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()) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt index df22ce3ae..5997c33cf 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt @@ -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 { + createdByLocal: Boolean = true, + optedInIds: Set = 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 = 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 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index eeb13d0a5..1b149d6b6 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -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, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt index bb35e166a..4ad7d4ee5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt @@ -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.activeWaypointPackets(nowSeconds: Long): Map 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.geofencesToMonitor(myNodeNum: Int?, optedInIds: Set): List = + filter { it.isFromLocal(myNodeNum) || (it.waypoint?.id in optedInIds) } + .mapNotNull { it.waypoint } + .filter { it.notifiesOnCrossing } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt index 6d15d441f..c0aa57218 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt @@ -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()) + } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt index 763e92834..b30167d7d 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt @@ -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> = + 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 = + 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") } } diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt index b628e1010..1ab64cbe3 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -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) + } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index da9b01218..073b0b4d1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -204,6 +204,14 @@ interface NotificationPrefs { val lowBatteryEnabled: StateFlow 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> + + fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) } /** Reactive interface for general map preferences. */ diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 85c963a01..dc1abcf61 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -693,6 +693,8 @@ Generate QR Code Geofence + Notify me of crossings + Get an alert when nodes enter or leave this geofence Confirm area Tap two corners to define the area Pan the map to frame the area, then tap Confirm area diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNotificationPrefs.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNotificationPrefs.kt index 15149f5f2..8c8825e61 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNotificationPrefs.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNotificationPrefs.kt @@ -43,4 +43,11 @@ class FakeNotificationPrefs : NotificationPrefs { override fun setLowBatteryEnabled(enabled: Boolean) { lowBatteryEnabled.value = enabled } + + override val geofenceAlertOptIns = MutableStateFlow>(emptySet()) + + override fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) { + geofenceAlertOptIns.value = + geofenceAlertOptIns.value.toMutableSet().apply { if (enabled) add(waypointId) else remove(waypointId) } + } } diff --git a/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopNotificationManagerTest.kt b/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopNotificationManagerTest.kt index 9d50ceb7d..ed16d102f 100644 --- a/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopNotificationManagerTest.kt +++ b/desktopApp/src/test/kotlin/org/meshtastic/desktop/notification/DesktopNotificationManagerTest.kt @@ -67,6 +67,13 @@ class DesktopNotificationManagerTest { override fun setLowBatteryEnabled(enabled: Boolean) { lowBatteryEnabled.value = enabled } + + override val geofenceAlertOptIns = MutableStateFlow>(emptySet()) + + override fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) { + geofenceAlertOptIns.value = + geofenceAlertOptIns.value.toMutableSet().apply { if (enabled) add(waypointId) else remove(waypointId) } + } } @Test diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt new file mode 100644 index 000000000..060bf820b --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt @@ -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 . + */ +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 diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialogPreviews.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialogPreviews.kt new file mode 100644 index 000000000..3f6277598 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialogPreviews.kt @@ -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 . + */ +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 = {}, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index b98333eb1..f84647a77 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -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> = 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 = showOnlyFavorites.asStateFlow() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index 6c9f6e650..482e7702f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -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, +) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index d9cf52da7..909c3bcb2 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -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(), ) } diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt index aa084f4b5..7ed5b7304 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt @@ -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() +} diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..cb34475fc Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Light_b29dc7a7_0.png new file mode 100644 index 000000000..9b04b1256 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogOptedIn_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..b525fc3b2 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Light_b29dc7a7_0.png new file mode 100644 index 000000000..abf647e3c Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotWaypointInfoDialogReadOnly_Light_b29dc7a7_0.png differ