mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 12:26:23 -04:00
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:
2
.skills/compose-ui/strings-index.txt
generated
2
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in New Issue
Block a user