feat: Waypoint geofences (editor, map overlays, alert engine) (#6014)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-30 21:27:31 -05:00
committed by GitHub
parent 462781f1eb
commit 77b4ba19de
26 changed files with 1494 additions and 410 deletions

View File

@@ -651,6 +651,24 @@ generate_input_event_on_ccw
generate_input_event_on_cw
generate_input_event_on_press
generate_qr_code
### GEOFENCE ###
geofence
geofence_box_author_confirm
geofence_box_author_hint
geofence_box_author_hint_viewport
geofence_box_use_view
geofence_edit_area
geofence_entered_body
geofence_entered_title
geofence_favorites_only
geofence_left_body
geofence_left_title
geofence_notify_on_enter
geofence_notify_on_exit
geofence_off
geofence_radius
geofence_remove_area
geofence_set_area
get_started
good
### GPIO ###

View File

@@ -18,19 +18,24 @@ package org.meshtastic.app.map
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
@@ -75,7 +80,6 @@ import org.meshtastic.app.R
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@@ -85,6 +89,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -93,6 +98,9 @@ import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.geofence_box_author_confirm
import org.meshtastic.core.resources.geofence_box_author_hint_viewport
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
@@ -132,6 +140,7 @@ import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
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.proto.Config.DisplayConfig.DisplayUnits
@@ -157,18 +166,31 @@ import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import kotlin.math.roundToInt
import org.meshtastic.proto.BoundingBox as ProtoBoundingBox
/**
* Marker subclass for waypoint geofence overlays (circles + boxes). Distinguished from the tile-download / geofence
* authoring rectangle Polygons so [updateMarkers] can rebuild only the waypoint geofences on each refresh. The
* transient-rectangle cleanups in the download/authoring paths exclude this subclass (`removeAll { it is Polygon && it
* !is GeofenceOverlayPolygon }`) so persistent waypoint geofences are not wiped.
*/
private class GeofenceOverlayPolygon : Polygon()
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
geofenceOverlays: List<GeofenceOverlayPolygon>,
nodeClusterer: RadiusMarkerClusterer,
) {
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
overlay is MarkerWithLabel ||
overlay is GeofenceOverlayPolygon ||
(overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(geofenceOverlays)
overlays.addAll(waypointMarkers)
nodeClusterer.items.clear()
@@ -226,6 +248,12 @@ fun MapView(
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) }
// Geofence box authoring: when geofenceBoxDraft is non-null the user is positioning a rectangle (reusing the
// tile-download rectangle machinery) that becomes the draft's bounding box on confirm. Kept distinct from the
// tile-download box (downloadRegionBoundingBox) so the two rectangle modes never collide.
var geofenceBoxDraft by remember { mutableStateOf<Waypoint?>(null) }
var geofenceBoxBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
var showDeleteWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
@@ -327,6 +355,7 @@ fun MapView(
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
val myId by mapViewModel.myId.collectAsStateWithLifecycle()
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
LaunchedEffect(selectedWaypointId, waypoints) {
if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) {
@@ -428,9 +457,12 @@ fun MapView(
expireTimeMillis <= now -> "Expired"
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
// Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's
// accessible title for screen-reader and color-challenged users.
val geofenceLabel = if (pt.toGeofence() != null) " · " + getString(Res.string.geofence) else ""
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
title = "${pt.name} (${getUsername(waypoint.from)}$lock)$geofenceLabel"
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
if (selectedWaypointId == pt.id) {
@@ -444,6 +476,48 @@ fun MapView(
}
}
// Builds the orange geofence overlays (a circle for a radius geofence, a 4-point rect for a bounding box) for every
// displayed waypoint. Uses the shared toGeofence() decoder so the coordinate math stays in one place.
fun buildGeofenceOverlays(waypoints: Collection<DataPacket>): List<GeofenceOverlayPolygon> {
if (!mapFilterState.showWaypoints) return emptyList()
return waypoints.flatMap { packet ->
val pt = packet.waypoint ?: return@flatMap emptyList()
val geofence = pt.toGeofence() ?: return@flatMap emptyList()
buildList {
geofence.circle?.let { circle ->
add(
GeofenceOverlayPolygon().apply {
points =
Polygon.pointsAsCircle(
GeoPoint(circle.centerLat, circle.centerLon),
circle.radiusMeters.toDouble(),
)
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
fillPaint.color = GEOFENCE_FILL_COLOR
},
)
}
geofence.box?.let { box ->
add(
GeofenceOverlayPolygon().apply {
points =
listOf(
GeoPoint(box.south, box.west),
GeoPoint(box.north, box.west),
GeoPoint(box.north, box.east),
GeoPoint(box.south, box.east),
)
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
fillPaint.color = GEOFENCE_FILL_COLOR
},
)
}
}
}
}
val mapEventsReceiver =
object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
@@ -453,7 +527,7 @@ fun MapView(
override fun longPressHelper(p: GeoPoint): Boolean {
performHapticFeedback()
val enabled = isConnected && downloadRegionBoundingBox == null
val enabled = isConnected && downloadRegionBoundingBox == null && geofenceBoxDraft == null
if (enabled) {
showEditWaypointDialog =
@@ -482,7 +556,7 @@ fun MapView(
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
val zoomFactor = 1.3
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
@@ -498,11 +572,29 @@ fun MapView(
cacheEstimate = getString(Res.string.map_cache_tiles, tileCount)
}
// Positions the geofence rectangle from the current viewport (reusing the tile-download rectangle approach) and
// draws an orange preview Polygon. Mirrors generateBoxOverlay(), but stores into geofenceBoxBoundingBox so it
// never collides with the tile-download box.
fun MapView.generateGeofenceBoxOverlay() {
overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
geofenceBoxBoundingBox = boundingBox.zoomIn(GEOFENCE_BOX_ZOOM_FACTOR)
val polygon =
Polygon().apply {
points = Polygon.pointsAsRect(geofenceBoxBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
fillPaint.color = GEOFENCE_FILL_COLOR
}
overlays.add(polygon)
invalidate()
}
val boxOverlayListener =
object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
if (downloadRegionBoundingBox != null) {
event.source.generateBoxOverlay()
when {
downloadRegionBoundingBox != null -> event.source.generateBoxOverlay()
geofenceBoxDraft != null -> event.source.generateGeofenceBoxOverlay()
}
return true
}
@@ -546,7 +638,9 @@ fun MapView(
Scaffold(
modifier = modifier,
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null && geofenceBoxDraft == null) {
showCacheManagerDialog = true
}
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
@@ -563,19 +657,45 @@ fun MapView(
updateMarkers(
onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
buildGeofenceOverlays(waypoints.values),
nodeClusterer,
)
}
mapView.drawOverlays()
}, // Renamed map to mapView to avoid conflict
)
if (downloadRegionBoundingBox != null) {
if (geofenceBoxDraft != null) {
GeofenceBoxAuthoringBar(
onConfirm = {
val draft = geofenceBoxDraft
val bb = geofenceBoxBoundingBox
if (draft != null && bb != null) {
showEditWaypointDialog = draft.copy(bounding_box = bb.toProtoBoundingBox())
} else {
showEditWaypointDialog = draft
}
geofenceBoxDraft = null
geofenceBoxBoundingBox = null
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
map.invalidate()
},
onCancel = {
val draft = geofenceBoxDraft
geofenceBoxDraft = null
geofenceBoxBoundingBox = null
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
map.invalidate()
showEditWaypointDialog = draft
},
modifier = Modifier.align(Alignment.BottomCenter),
)
} else if (downloadRegionBoundingBox != null) {
CacheLayout(
cacheEstimate = cacheEstimate,
onExecuteJob = { startDownload() },
onCancelDownload = {
downloadRegionBoundingBox = null
map.overlays.removeAll { it is Polygon }
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
map.invalidate()
},
modifier = Modifier.align(Alignment.BottomCenter),
@@ -662,7 +782,8 @@ fun MapView(
if (showEditWaypointDialog != null) {
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
onSendClicked = { waypoint ->
displayUnits = displayUnits,
onSend = { waypoint ->
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
@@ -682,7 +803,7 @@ fun MapView(
),
)
},
onDeleteClicked = { waypoint ->
onDelete = { waypoint ->
Logger.d { "User clicked delete waypoint ${waypoint.id}" }
showEditWaypointDialog = null
showDeleteWaypointDialog = waypoint
@@ -691,6 +812,12 @@ fun MapView(
Logger.d { "User clicked cancel marker edit dialog" }
showEditWaypointDialog = null
},
onBeginBoxAuthoring = { draft ->
Logger.d { "User began geofence box authoring for waypoint ${draft.id}" }
showEditWaypointDialog = null
geofenceBoxDraft = draft
map.generateGeofenceBoxOverlay()
},
)
}
@@ -992,3 +1119,39 @@ private fun MapsDialog(
}
private const val WAYPOINT_ZOOM = 15.0
// Geofence / authoring rectangle styling (orange) for the imperative OSMDroid Polygon paints (android.graphics ints).
private const val GEOFENCE_OVERLAY_COLOR = 0xFFFF9800.toInt()
private const val GEOFENCE_FILL_COLOR = 0x1FFF9800 // ~12% alpha orange
private const val GEOFENCE_STROKE_WIDTH_PX = 4f
private const val GEOFENCE_BOX_ZOOM_FACTOR = 1.3
/** Converts an OSMDroid [BoundingBox] (decimal degrees) to a proto [ProtoBoundingBox] (degrees ×1e7). */
@Suppress("MagicNumber")
private fun BoundingBox.toProtoBoundingBox(): ProtoBoundingBox = ProtoBoundingBox(
longitude_west_i = (lonWest * 1e7).toInt(),
latitude_south_i = (latSouth * 1e7).toInt(),
longitude_east_i = (lonEast * 1e7).toInt(),
latitude_north_i = (latNorth * 1e7).toInt(),
)
/** Bottom bar shown while the user positions the geofence rectangle: confirm applies it, cancel re-opens the editor. */
@Composable
private fun GeofenceBoxAuthoringBar(onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier) {
Card(modifier = modifier.padding(16.dp)) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(Res.string.geofence_box_author_hint_viewport),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
Button(onClick = onConfirm) { Text(stringResource(Res.string.geofence_box_author_confirm)) }
}
}
}

View File

@@ -41,7 +41,7 @@ class MapViewModel(
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()

View File

@@ -1,357 +0,0 @@
/*
* 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.app.map.component
import android.app.DatePickerDialog
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@Suppress("MagicNumber")
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
val context = LocalContext.current
val tz = systemTimeZone
// Determine locale-specific date format
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
// Check if 24-hour format is preferred
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
val currentInstant =
remember(waypointInput.expire) {
val expire = waypointInput.expire
if (expire != 0 && expire != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(expire.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
// State to hold selected date and time
var selectedDate by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
var selectedTime by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
EditTextPreference(
title = stringResource(Res.string.name),
value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(name = it) },
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(emoji)),
modifier =
Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
.padding(4.dp),
fontSize = 24.sp,
color = Color.Unspecified.copy(alpha = 1f),
)
}
},
)
EditTextPreference(
title = stringResource(Res.string.description),
value = waypointInput.description,
maxSize = 99,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(description = it) },
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Text(stringResource(Res.string.locked))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.locked_to != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
val newLdt =
LocalDateTime(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
hour = ldt.hour,
minute = ldt.minute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
ldt.month.ordinal,
ldt.day,
)
val timePickerDialog =
android.app.TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
val newLdt =
LocalDateTime(
year = ldt.year,
month = ldt.month,
day = ldt.day,
hour = selectedHour,
minute = selectedMinute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.hour,
ldt.minute,
is24Hour,
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Text(stringResource(Res.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
} else {
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
},
)
}
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedDate,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedTime,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
},
confirmButton = {
FlowRow(
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.Center,
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(Res.string.cancel))
}
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = !(waypointInput.name.isNullOrEmpty()),
) {
Text(stringResource(Res.string.delete))
}
}
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
Text(stringResource(Res.string.send))
}
}
},
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
showEmojiPickerView = false
waypointInput = waypointInput.copy(icon = it.codePointAt(0))
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("MagicNumber")
private fun EditWaypointFormPreview() {
AppTheme {
EditWaypointDialog(
waypoint =
Waypoint(
id = 123,
name = "Test 123",
description = "This is only a test",
icon = 128169,
expire = (nowSeconds.toInt() + 8 * 3600),
),
onSendClicked = {},
onDeleteClicked = {},
onDismissRequest = {},
)
}
}

View File

@@ -25,6 +25,8 @@ import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,12 +36,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -68,6 +73,7 @@ import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.maps.android.SphericalUtil
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
@@ -77,6 +83,7 @@ import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polygon
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberCameraPositionState
@@ -93,7 +100,6 @@ import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.component.ClusterItemsListDialog
import org.meshtastic.app.map.component.CustomMapLayersSheet
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapFilterDropdown
import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
@@ -104,6 +110,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
@@ -112,6 +119,9 @@ import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.geofence_box_author_hint
import org.meshtastic.core.resources.geofence_box_use_view
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
@@ -134,9 +144,11 @@ import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
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.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -175,6 +187,15 @@ sealed interface GoogleMapMode {
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
// Shared geofence overlay styling (orange, matching the fdroid flavor).
private val GEOFENCE_OVERLAY_COLOR = Color(0xFFFF9800)
private const val GEOFENCE_FILL_ALPHA = 0.12f
private const val GEOFENCE_STROKE_WIDTH = 2f
// Minimum lat/lon delta (~11 m) between the two box-authoring corner taps; below this the box would be degenerate
// (zero-area) so the second tap is ignored.
private const val BOX_AUTHORING_MIN_CORNER_DELTA = 1e-4
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -219,6 +240,13 @@ fun MapView(
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
// --- Geofence box authoring (Main mode) ---
// When non-null, the user is defining a bounding box for [boxAuthoringDraft] by tapping two corners.
var boxAuthoringDraft by remember { mutableStateOf<Waypoint?>(null) }
var boxAuthoringFirstCorner by remember { mutableStateOf<LatLng?>(null) }
var boxAuthoringSecondCorner by remember { mutableStateOf<LatLng?>(null) }
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
@@ -529,8 +557,28 @@ fun MapView(
mapType = effectiveGoogleMapType,
isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
),
onMapClick = { latLng ->
if (isMainMode && boxAuthoringDraft != null) {
val first = boxAuthoringFirstCorner
if (first == null) {
boxAuthoringFirstCorner = latLng
} else if (
abs(latLng.latitude - first.latitude) >= BOX_AUTHORING_MIN_CORNER_DELTA &&
abs(latLng.longitude - first.longitude) >= BOX_AUTHORING_MIN_CORNER_DELTA
) {
// Only commit when the two corners are meaningfully distinct; a near-identical second tap
// would yield a zero-area box, so ignore it and keep waiting for a valid second corner.
boxAuthoringSecondCorner = latLng
val box = boundingBoxFromCorners(first, latLng)
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
boxAuthoringDraft = null
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
}
}
},
onMapLongClick = { latLng ->
if (isMainMode && isConnected) {
if (isMainMode && isConnected && boxAuthoringDraft == null) {
editingWaypoint =
Waypoint(
latitude_i = (latLng.latitude / DEG_D).toInt(),
@@ -552,6 +600,20 @@ fun MapView(
}
}
// The two-tap flow commits on the second tap, so there is no both-corners-uncommitted state to draw a
// rectangle preview from; instead mark the first tapped corner so the user sees it registered.
boxAuthoringFirstCorner?.let { first ->
val cornerState = rememberUpdatedMarkerState(position = first)
MarkerComposable(state = cornerState, zIndex = 5f) {
Box(
modifier =
Modifier.size(16.dp)
.background(GEOFENCE_OVERLAY_COLOR, CircleShape)
.border(2.dp, Color.White, CircleShape),
)
}
}
when (mode) {
is GoogleMapMode.Main ->
MainMapContent(
@@ -585,7 +647,6 @@ fun MapView(
)
is GoogleMapMode.NodeTrack -> {
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
NodeTrackOverlay(
focusedNode = mode.focusedNode,
@@ -620,7 +681,8 @@ fun MapView(
editingWaypoint?.let { waypointToEdit ->
EditWaypointDialog(
waypoint = waypointToEdit,
onSendClicked = { updatedWp ->
displayUnits = displayUnits,
onSend = { updatedWp ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
finalWp = finalWp.copy(id = mapViewModel.generatePacketId())
@@ -631,7 +693,7 @@ fun MapView(
mapViewModel.sendWaypoint(finalWp)
editingWaypoint = null
},
onDeleteClicked = { wpToDelete ->
onDelete = { wpToDelete ->
if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) {
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
}
@@ -639,10 +701,61 @@ fun MapView(
editingWaypoint = null
},
onDismissRequest = { editingWaypoint = null },
onBeginBoxAuthoring = { draft ->
boxAuthoringDraft = draft
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
editingWaypoint = null
},
)
}
}
// Box-authoring affordance: instruct the user to tap two corners, with a cancel that re-opens the editor.
boxAuthoringDraft?.let { draft ->
Card(
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 72.dp).padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.geofence_box_author_hint),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
TextButton(
onClick = {
boxAuthoringDraft = null
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
editingWaypoint = draft
},
) {
Text(stringResource(Res.string.cancel))
}
// Single operable control: commits the currently-visible map bounds as the box, so the box can be
// authored without the two precise corner taps (keyboard/screen-reader accessible path).
Button(
onClick = {
val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds
if (bounds != null) {
val box = boundingBoxFromCorners(bounds.southwest, bounds.northeast)
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
boxAuthoringDraft = null
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
}
},
) {
Text(stringResource(Res.string.geofence_box_use_view))
}
}
}
}
// Controls overlay
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
val showRefresh = visibleNetworkLayers.isNotEmpty()
@@ -822,9 +935,45 @@ private fun MainMapContent(
selectedWaypointId = selectedWaypointId,
)
if (mapFilterState.showWaypoints) {
displayableWaypoints.forEach { waypoint -> WaypointGeofenceOverlay(waypoint) }
}
mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
/**
* Draws this waypoint's geofence region: a [Circle] for a radius geofence and/or a [Polygon] for a bounding box. Uses
* the shared [toGeofence] decoder so the coordinate math stays in one place. Renders nothing when no region is set.
*/
@Composable
private fun WaypointGeofenceOverlay(waypoint: Waypoint) {
val geofence = waypoint.toGeofence() ?: return
geofence.circle?.let { circle ->
Circle(
center = LatLng(circle.centerLat, circle.centerLon),
radius = circle.radiusMeters.toDouble(),
strokeColor = GEOFENCE_OVERLAY_COLOR,
fillColor = GEOFENCE_OVERLAY_COLOR.copy(alpha = GEOFENCE_FILL_ALPHA),
strokeWidth = GEOFENCE_STROKE_WIDTH,
)
}
geofence.box?.let { box ->
Polygon(
points =
listOf(
LatLng(box.south, box.west),
LatLng(box.north, box.west),
LatLng(box.north, box.east),
LatLng(box.south, box.east),
),
strokeColor = GEOFENCE_OVERLAY_COLOR,
fillColor = GEOFENCE_OVERLAY_COLOR.copy(alpha = GEOFENCE_FILL_ALPHA),
strokeWidth = GEOFENCE_STROKE_WIDTH,
)
}
}
// endregion
// region --- Node Track Overlay ---
@@ -1129,4 +1278,12 @@ fun Uri.getFileName(context: android.content.Context): String {
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),
latitude_south_i = (minOf(a.latitude, b.latitude) / DEG_D).toInt(),
longitude_east_i = (maxOf(a.longitude, b.longitude) / DEG_D).toInt(),
latitude_north_i = (maxOf(a.latitude, b.latitude) / DEG_D).toInt(),
)
// endregion

View File

@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -59,7 +58,6 @@ import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -94,7 +92,7 @@ class MapViewModel(
private val customTileProviderRepository: CustomTileProviderRepository,
uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
@@ -147,11 +145,6 @@ class MapViewModel(
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
val displayUnits =
radioConfigRepository.deviceProfileFlow
.mapNotNull { it.config?.display?.units }
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
viewModelScope.launch {
if (

View File

@@ -31,9 +31,12 @@ import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
@@ -71,11 +74,22 @@ fun WaypointMarkers(
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
// Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's
// accessible snippet for screen-reader and color-challenged users.
val description = waypoint.description.replace('\n', ' ').replace('\b', ' ')
val snippet =
if (waypoint.toGeofence() != null) {
val geofenceLabel = stringResource(Res.string.geofence)
if (description.isBlank()) geofenceLabel else "$description · $geofenceLabel"
} else {
description
}
Marker(
state = markerState,
icon = icon,
title = waypoint.name.replace('\n', ' ').replace('\b', ' '),
snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '),
snippet = snippet,
visible = true,
onInfoWindowClick = {
if (waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected) {

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
/** The transition observed when a node's inside/outside state for a geofence is updated. */
enum class GeofenceTransition {
/** First time this (waypoint, node) pair was seen — establishes a baseline, never notifies. */
BASELINE,
ENTERED,
EXITED,
/** Same side as last time — no notification. */
UNCHANGED,
}
/**
* In-memory inside/outside state per `(waypointId, nodeNum)`. NOT persisted: a relaunch re-baselines, which is exactly
* what prevents spurious alerts at startup. Mutated from the service scope as positions arrive, so guarded by a
* [Mutex].
*/
@Single
class GeofenceCrossingStore {
private val mutex = Mutex()
private val inside = mutableMapOf<Pair<Int, Int>, Boolean>()
/** Record [isInside] for ([waypointId], [nodeNum]) and report the transition versus the prior state. */
suspend fun update(waypointId: Int, nodeNum: Int, isInside: Boolean): GeofenceTransition = mutex.withLock {
val was = inside.put(waypointId to nodeNum, isInside)
when {
was == null -> GeofenceTransition.BASELINE
was == isInside -> GeofenceTransition.UNCHANGED
isInside -> GeofenceTransition.ENTERED
else -> GeofenceTransition.EXITED
}
}
/** Drop crossing state for waypoints no longer active, bounding memory growth. */
suspend fun retainOnly(waypointIds: Set<Int>): Unit =
mutex.withLock { inside.keys.retainAll { it.first in waypointIds } }
}

View File

@@ -0,0 +1,184 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.catch
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.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.PacketRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.geofence_entered_body
import org.meshtastic.core.resources.geofence_entered_title
import org.meshtastic.core.resources.geofence_left_body
import org.meshtastic.core.resources.geofence_left_title
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import kotlin.concurrent.Volatile
/**
* Raises a LOCAL notification when another mesh node's reported position crosses a waypoint's geofence. Evaluation runs
* against OTHER nodes' positions arriving over the mesh — never the device's own location — so this is manual
* point-in-region math, NOT the OS Geofencing API.
*
* 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).
*
* 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).
*/
@Single
class GeofenceMonitor(
private val packetRepository: Lazy<PacketRepository>,
private val nodeManager: NodeManager,
private val serviceNotifications: MeshNotificationManager,
private val crossingStore: GeofenceCrossingStore,
@Named("ServiceScope") private val scope: CoroutineScope,
) {
private data class PositionSample(val nodeNum: Int, val lat: Double, val lon: Double)
@Volatile private var activeGeofences: List<Waypoint> = emptyList()
// Unbounded so we never drop a position (which could swallow a real crossing); positions arrive infrequently.
private val samples = Channel<PositionSample>(Channel.UNLIMITED)
init {
// Single serial consumer → evaluations happen in arrival order, and we launch ONE coroutine, not one per
// received position.
scope.launch {
for (sample in samples) {
try {
evaluate(sample.nodeNum, sample.lat, sample.lon)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// Isolate per-sample failures: an unexpected throw must not kill the sole consumer and silently
// stop geofence tracking for the rest of the session.
Logger.e(e) { "Geofence evaluation failed for node ${sample.nodeNum}; skipping sample" }
}
}
}
// A bad emission must not tear down the snapshot for the rest of the session.
scope.launch {
packetRepository.value
.getWaypoints()
.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 }
activeGeofences = active
crossingStore.retainOnly(active.map { it.id }.toSet())
}
}
}
/** Evaluate a received node position against every active geofence. [nodeNum] is the position's sender. */
fun onPositionReceived(nodeNum: Int, myNodeNum: Int, position: Position) {
val latI = position.latitude_i ?: 0
val lonI = position.longitude_i ?: 0
val lat = latI * DEG_D
val lon = lonI * DEG_D
// Skip our own position, a node with no fix (don't false-cross at 0,0), out-of-range/garbage coordinates, or
// when nothing is geofenced.
val skip =
nodeNum == myNodeNum ||
(latI == 0 && lonI == 0) ||
lat !in MIN_LATITUDE..MAX_LATITUDE ||
lon !in MIN_LONGITUDE..MAX_LONGITUDE ||
activeGeofences.isEmpty()
if (skip) return
samples.trySend(PositionSample(nodeNum, lat, lon))
}
private suspend fun evaluate(nodeNum: Int, lat: Double, lon: Double) {
val now = nowSeconds
for (waypoint in activeGeofences) {
// Re-check expiry per evaluation: the snapshot is only recomputed on a new waypoint emission, so a
// waypoint that expired since the last emission must not keep firing.
if (waypoint.expire == 0 || waypoint.expire.toLong() > now) {
val geofence = waypoint.toGeofence() ?: continue
when (crossingStore.update(waypoint.id, nodeNum, geofence.contains(lat, lon))) {
GeofenceTransition.ENTERED -> if (waypoint.notify_on_enter) notifyCrossing(waypoint, nodeNum, true)
GeofenceTransition.EXITED -> if (waypoint.notify_on_exit) notifyCrossing(waypoint, nodeNum, false)
GeofenceTransition.BASELINE,
GeofenceTransition.UNCHANGED,
-> Unit
}
}
}
}
private suspend fun notifyCrossing(waypoint: Waypoint, nodeNum: Int, entered: Boolean) {
// Favorites gate is receiver-local and resolved only on a real transition.
if (waypoint.notify_favorites_only && nodeManager.nodeDBbyNodeNum[nodeNum]?.isFavorite != true) return
try {
val nodeName =
nodeManager.nodeDBbyNodeNum[nodeNum]?.user?.long_name?.takeIf { it.isNotBlank() }
?: getStringSuspend(Res.string.unknown_username)
val waypointName = waypoint.name.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.geofence)
val title =
getStringSuspend(
if (entered) Res.string.geofence_entered_title else Res.string.geofence_left_title,
waypointName,
)
val body =
getStringSuspend(
if (entered) Res.string.geofence_entered_body else Res.string.geofence_left_body,
nodeName,
waypointName,
)
// Reuse the waypoint notification channel + map?waypointId= deep link. A distinct contactKey per
// (waypoint, node) gives each crossing its own notification id (matches the Apple reference).
serviceNotifications.updateWaypointNotification(
contactKey = "geofence:${waypoint.id}:$nodeNum",
name = title,
message = body,
waypointId = waypoint.id,
)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// A string-resource or notification failure must not take down the evaluation worker.
Logger.e(e) { "Failed to raise geofence crossing notification for waypoint ${waypoint.id}" }
}
}
private companion object {
const val MIN_LATITUDE = -90.0
const val MAX_LATITUDE = 90.0
const val MIN_LONGITUDE = -180.0
const val MAX_LONGITUDE = 180.0
}
}

View File

@@ -101,6 +101,7 @@ class MeshDataHandlerImpl(
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
private val geofenceMonitor: GeofenceMonitor,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@@ -218,6 +219,7 @@ class MeshDataHandlerImpl(
val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" }
nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
geofenceMonitor.onPositionReceived(packet.from, myNodeNum, p)
}
private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class GeofenceCrossingStoreTest {
@Test
fun firstSightingIsBaselineThenTransitionsOnlyOnChange() = runTest {
val store = GeofenceCrossingStore()
// First sighting establishes a baseline — never a notification.
assertEquals(GeofenceTransition.BASELINE, store.update(waypointId = 1, nodeNum = 9, isInside = false))
// Same side → no change.
assertEquals(GeofenceTransition.UNCHANGED, store.update(1, 9, false))
// outside -> inside.
assertEquals(GeofenceTransition.ENTERED, store.update(1, 9, true))
assertEquals(GeofenceTransition.UNCHANGED, store.update(1, 9, true))
// inside -> outside.
assertEquals(GeofenceTransition.EXITED, store.update(1, 9, false))
}
@Test
fun stateIsIndependentPerWaypointAndNode() = runTest {
val store = GeofenceCrossingStore()
assertEquals(GeofenceTransition.BASELINE, store.update(1, 9, true))
// Different node, same waypoint — its own baseline.
assertEquals(GeofenceTransition.BASELINE, store.update(1, 10, true))
// Different waypoint, same node — its own baseline.
assertEquals(GeofenceTransition.BASELINE, store.update(2, 9, true))
}
@Test
fun retainOnlyForgetsInactiveWaypoints() = runTest {
val store = GeofenceCrossingStore()
store.update(1, 9, true)
store.update(2, 9, true)
store.retainOnly(setOf(2))
// Waypoint 1 was forgotten → re-baselines; waypoint 2 was kept.
assertEquals(GeofenceTransition.BASELINE, store.update(1, 9, true))
assertEquals(GeofenceTransition.UNCHANGED, store.update(2, 9, true))
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
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.PacketRepository
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import kotlin.test.Test
/**
* Covers the crossing-decision logic deterministically via virtual time. The positive notification *dispatch* (R9) is
* NOT asserted here: it is gated behind `getStringSuspend` (compose-resources), which does not resolve in the plain-JVM
* test runner, so any test reaching it hangs. Coverage is instead structured around negatives that still drive the full
* decision path right up to the notify entry point:
* - [favoritesOnlySuppressesNonFavoriteCrossing] drives a real outside→inside ENTER and is stopped only by the gate,
* proving the snapshot/worker/store/enter-branch all work.
* - [enterOnlyWaypointDoesNotNotifyOnExit] proves the EXIT branch is gated by `notify_on_exit`. The actual dispatch
* (contactKey/channel/deep-link) is verified by code review + manual run.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class GeofenceMonitorTest {
private val sender = 9
private val myNodeNum = 1
// Geofence centred at (10, 20), 1 km radius.
private val centerLatI = 100_000_000
private val centerLonI = 200_000_000
private val inside = Position(latitude_i = centerLatI, longitude_i = centerLonI) // distance 0
private val outside = Position(latitude_i = 110_000_000, longitude_i = centerLonI) // ~111 km north
private fun waypoint(enter: Boolean = true, exit: Boolean = false, favoritesOnly: Boolean = false) = Waypoint(
id = 42,
latitude_i = centerLatI,
longitude_i = centerLonI,
name = "Base",
geofence_radius = 1000,
notify_on_enter = enter,
notify_on_exit = exit,
notify_favorites_only = favoritesOnly,
)
private fun mocks(
wp: Waypoint,
senderIsFavorite: Boolean = false,
): Triple<PacketRepository, NodeManager, MeshNotificationManager> {
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)))
every { nodeManager.nodeDBbyNodeNum } returns mapOf(sender to Node(num = sender, isFavorite = senderIsFavorite))
return Triple(packetRepository, nodeManager, notifications)
}
private fun expectNoNotification(
wp: Waypoint,
senderIsFavorite: Boolean = false,
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)
scope.advanceUntilIdle() // collect the active-geofence snapshot
drive(monitor)
scope.advanceUntilIdle()
verifySuspend(exactly(0)) { notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
scope.cancel() // stop the serial worker so runTest sees no leaked coroutine
}
@Test
fun firstSightingInsideDoesNotNotify() =
expectNoNotification(waypoint()) { m -> m.onPositionReceived(sender, myNodeNum, inside) }
@Test
fun favoritesOnlySuppressesNonFavoriteCrossing() =
expectNoNotification(waypoint(favoritesOnly = true), senderIsFavorite = false) { m ->
m.onPositionReceived(sender, myNodeNum, outside) // baseline
m.onPositionReceived(sender, myNodeNum, inside) // genuine ENTER, but sender is not a favorite
}
@Test
fun ownPositionIsNeverEvaluated() = expectNoNotification(waypoint()) { m ->
m.onPositionReceived(myNodeNum, myNodeNum, inside) // sender == self
}
@Test
fun enterOnlyWaypointDoesNotNotifyOnExit() = expectNoNotification(waypoint(enter = true, exit = false)) { m ->
m.onPositionReceived(sender, myNodeNum, inside) // baseline inside
m.onPositionReceived(sender, myNodeNum, outside) // EXIT, but notify_on_exit is false
}
}

View File

@@ -24,8 +24,11 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
@@ -60,6 +63,7 @@ import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
@@ -87,6 +91,16 @@ class MeshDataHandlerTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
// Separate scope for the real GeofenceMonitor: its serial worker is a never-completing coroutine, so it must NOT
// live in the runTest scope (that would trip UncompletedCoroutinesError). Shares the dispatcher so advanceUntilIdle
// still drives it; cancelled in tearDown.
private val geofenceScope = CoroutineScope(testDispatcher)
@AfterTest
fun tearDown() {
geofenceScope.cancel()
}
@BeforeTest
fun setUp() {
handler =
@@ -107,6 +121,16 @@ class MeshDataHandlerTest {
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
collectorRegistry = mock(MockMode.autofill),
// GeofenceMonitor is a final @Single (mokkery can't mock it) — use a real one over mocked
// collaborators. With no geofence-bearing waypoints emitted, onPositionReceived is a no-op.
geofenceMonitor =
GeofenceMonitor(
packetRepository = lazy { packetRepository },
nodeManager = nodeManager,
serviceNotifications = serviceNotifications,
crossingStore = GeofenceCrossingStore(),
scope = geofenceScope,
),
scope = testScope,
)
@@ -116,6 +140,8 @@ class MeshDataHandlerTest {
every { nodeManager.getNodeById(any()) } returns null
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
// GeofenceMonitor collects this on init; stub it so the launched collector doesn't NPE on the test scope.
every { packetRepository.getWaypoints() } returns emptyFlow()
}
@Test

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.core.model.DataPacket
/**
* Collapse a raw waypoint-packet list into the set of currently active waypoints, keyed by waypoint id.
*
* `PacketRepository.getWaypoints()` is a row-PER-TRANSMISSION firehose: every re-broadcast or edit inserts a new
* `packet` row (keyed on the random MeshPacket transmission id, not the semantic waypoint id). Consumers must normalise
* — latest transmission wins, expired waypoints dropped — or they will see duplicate/stale geofences and keep alerting
* on waypoints the user can no longer see. Both the map UI and the geofence engine go through here so they cannot
* drift. Rows are ordered oldest-first, so `associateBy` keeps the newest copy per id.
*/
fun List<DataPacket>.activeWaypointPackets(nowSeconds: Long): Map<Int, DataPacket> = filter { it.waypoint != null }
.associateBy { it.waypoint!!.id }
.filterValues {
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.proto.Waypoint
/** A circular geofence centered on [centerLat]/[centerLon] (decimal degrees) with [radiusMeters]. */
data class GeofenceCircle(val centerLat: Double, val centerLon: Double, val radiusMeters: Int) {
/** True when ([lat], [lon]) is within [radiusMeters] great-circle metres of the centre. */
fun contains(lat: Double, lon: Double): Boolean = latLongToMeter(lat, lon, centerLat, centerLon) <= radiusMeters
}
/** An axis-aligned WSEN bounding box in decimal degrees. Bounds are inclusive (matches the Apple reference). */
data class GeofenceBox(val south: Double, val west: Double, val north: Double, val east: Double) {
fun contains(lat: Double, lon: Double): Boolean = lat in south..north && lon in west..east
}
/**
* A waypoint's geofence region: a [circle] and/or a [box]. A point is inside if it is in EITHER shape (OR semantics) —
* both may be set, either may be null.
*/
data class Geofence(val circle: GeofenceCircle?, val box: GeofenceBox?) {
fun contains(lat: Double, lon: Double): Boolean =
(circle?.contains(lat, lon) == true) || (box?.contains(lat, lon) == true)
}
/** True when this waypoint asks receivers to raise crossing notifications. */
val Waypoint.notifiesOnCrossing: Boolean
get() = notify_on_enter || notify_on_exit
/**
* Decode this waypoint's geofence region, or null when it defines neither a circle nor a box. This is the single source
* of the proto `×1e-7 → decimal-degree` conversion, shared by the alert engine, the map overlays, and the editor — keep
* all geofence coordinate decoding here so the three consumers cannot drift.
*/
fun Waypoint.toGeofence(): Geofence? {
val circle =
if (geofence_radius > 0) {
GeofenceCircle((latitude_i ?: 0) * DEG_D, (longitude_i ?: 0) * DEG_D, geofence_radius)
} else {
null
}
val box =
bounding_box?.let {
val southDeg = it.latitude_south_i * DEG_D
val northDeg = it.latitude_north_i * DEG_D
val westDeg = it.longitude_west_i * DEG_D
val eastDeg = it.longitude_east_i * DEG_D
// Normalize transposed corners so an inverted box (south>north / west>east) from another client still
// describes the intended rectangle. (Antimeridian-crossing boxes remain a documented non-goal.)
GeofenceBox(
south = minOf(southDeg, northDeg),
west = minOf(westDeg, eastDeg),
north = maxOf(southDeg, northDeg),
east = maxOf(westDeg, eastDeg),
)
}
return if (circle == null && box == null) null else Geofence(circle, box)
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import kotlin.math.abs
/**
* Radius presets for the waypoint editor. The wire value is ALWAYS metres; the UI renders each value locale-aware via
* `DistanceExtensions`. `0` means "Off" (no circle). Imperial values are chosen to render as clean feet/mile figures.
* Do NOT copy Apple's imperial-only table — the selector list is locale-driven by [forUnits].
*/
@Suppress("MagicNumber")
object GeofenceRadiusPresets {
/** Off, 100 m, 250 m, 500 m, 1 km, 2 km, 5 km. */
val METRIC_METERS: List<Int> = listOf(0, 100, 250, 500, 1000, 2000, 5000)
/** Off, ~250 ft, ~500 ft, ~1000 ft, 1 mi, 2 mi, 5 mi (stored as metres). */
val IMPERIAL_METERS: List<Int> = listOf(0, 76, 152, 305, 1609, 3219, 8047)
fun forUnits(units: DisplayUnits): List<Int> =
if (units == DisplayUnits.IMPERIAL) IMPERIAL_METERS else METRIC_METERS
/** The preset (in the active unit system) closest to [meters] — used to highlight the current selection. */
fun nearest(meters: Int, units: DisplayUnits): Int = forUnits(units).minBy { abs(it - meters) }
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.Waypoint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ActiveWaypointsTest {
private val now = 1000L
private fun packet(wp: Waypoint) = DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp)
/** The P1 regression: re-broadcasts of the same waypoint are separate rows; latest transmission must win. */
@Test
fun latestTransmissionWinsPerWaypointId() {
val old = packet(Waypoint(id = 42, geofence_radius = 100, expire = 0))
val new = packet(Waypoint(id = 42, geofence_radius = 5000, expire = 0))
val active = listOf(old, new).activeWaypointPackets(now)
assertEquals(1, active.size)
assertEquals(5000, active[42]?.waypoint?.geofence_radius)
}
@Test
fun expiredWaypointsAreDropped() {
val expired = packet(Waypoint(id = 1, geofence_radius = 100, expire = 500)) // before now=1000
val active = packet(Waypoint(id = 2, geofence_radius = 100, expire = 2000)) // after now
val never = packet(Waypoint(id = 3, geofence_radius = 100, expire = 0)) // never expires
val result = listOf(expired, active, never).activeWaypointPackets(now)
assertFalse(result.containsKey(1))
assertTrue(result.containsKey(2))
assertTrue(result.containsKey(3))
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import kotlin.test.Test
import kotlin.test.assertEquals
class GeofenceRadiusPresetsTest {
@Test
fun bothListsStartWithOff() {
assertEquals(0, GeofenceRadiusPresets.METRIC_METERS.first())
assertEquals(0, GeofenceRadiusPresets.IMPERIAL_METERS.first())
}
@Test
fun forUnitsSelectsBySystem() {
assertEquals(GeofenceRadiusPresets.IMPERIAL_METERS, GeofenceRadiusPresets.forUnits(DisplayUnits.IMPERIAL))
assertEquals(GeofenceRadiusPresets.METRIC_METERS, GeofenceRadiusPresets.forUnits(DisplayUnits.METRIC))
}
@Test
fun nearestSnapsToClosestPreset() {
assertEquals(0, GeofenceRadiusPresets.nearest(0, DisplayUnits.METRIC))
assertEquals(100, GeofenceRadiusPresets.nearest(120, DisplayUnits.METRIC))
assertEquals(2000, GeofenceRadiusPresets.nearest(2600, DisplayUnits.METRIC))
assertEquals(5000, GeofenceRadiusPresets.nearest(99999, DisplayUnits.METRIC))
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class GeofenceTest {
// Circle centered at (10, 20) with a 1 km radius.
private val circle = GeofenceCircle(centerLat = 10.0, centerLon = 20.0, radiusMeters = 1000)
// WSEN box: south=10, west=20, north=11, east=21.
private val box = GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0)
@Test
fun circleContainsCenterAndNearby() {
assertTrue(circle.contains(10.0, 20.0)) // distance 0
assertTrue(circle.contains(10.005, 20.0)) // ~555 m north, inside 1 km
}
@Test
fun circleExcludesFarPoint() {
assertFalse(circle.contains(10.05, 20.0)) // ~5.5 km north, outside 1 km
}
@Test
fun boxBoundsAreInclusive() {
assertTrue(box.contains(10.0, 20.0)) // SW corner
assertTrue(box.contains(11.0, 21.0)) // NE corner
assertTrue(box.contains(10.5, 20.5)) // interior
assertTrue(box.contains(10.0, 20.5)) // on the south edge
}
@Test
fun boxExcludesOutsidePoints() {
assertFalse(box.contains(9.999, 20.5)) // just south
assertFalse(box.contains(11.001, 20.5)) // just north
assertFalse(box.contains(10.5, 21.001)) // just east
assertFalse(box.contains(10.5, 19.999)) // just west
}
@Test
fun geofenceUsesOrSemantics() {
val circleOnly = Geofence(circle = circle, box = null)
val boxOnly = Geofence(circle = null, box = box)
val both = Geofence(circle = circle, box = box)
val neither = Geofence(circle = null, box = null)
// A point inside the box but far from the circle counts via the box.
assertTrue(boxOnly.contains(10.9, 20.9))
assertTrue(both.contains(10.9, 20.9))
assertFalse(circleOnly.contains(10.9, 20.9)) // ~140 km from circle center
// A point inside the circle but outside the box counts via the circle.
assertTrue(circleOnly.contains(9.998, 20.0))
assertTrue(both.contains(9.998, 20.0))
assertFalse(boxOnly.contains(9.998, 20.0)) // south of the box
// Empty geofence never contains anything.
assertFalse(neither.contains(10.0, 20.0))
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.geofence
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.Waypoint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class WaypointToGeofenceTest {
private val box =
BoundingBox(
longitude_west_i = 200_000_000,
latitude_south_i = 100_000_000,
longitude_east_i = 210_000_000,
latitude_north_i = 110_000_000,
)
@Test
fun radiusOnlyDecodesCircleNoBox() {
val wp = Waypoint(id = 1, latitude_i = 100_000_000, longitude_i = 200_000_000, geofence_radius = 500)
val geofence = wp.toGeofence()
assertNotNullCircleAt(geofence?.circle, 10.0, 20.0, 500)
assertNull(geofence?.box)
}
@Test
fun boxOnlyDecodesBoxNoCircle() {
val wp = Waypoint(id = 1, bounding_box = box)
val geofence = wp.toGeofence()
assertNull(geofence?.circle)
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
}
@Test
fun neitherShapeDecodesToNull() {
assertNull(Waypoint(id = 1).toGeofence())
assertNull(Waypoint(id = 1, geofence_radius = 0).toGeofence())
}
@Test
fun bothShapesDecode() {
val wp =
Waypoint(
id = 1,
latitude_i = 100_000_000,
longitude_i = 200_000_000,
geofence_radius = 500,
bounding_box = box,
)
val geofence = wp.toGeofence()
assertNotNullCircleAt(geofence?.circle, 10.0, 20.0, 500)
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
}
@Test
fun invertedBoundingBoxIsNormalized() {
// A box whose corners arrive transposed (south>north, west>east) should still describe the intended
// rectangle after decode.
val inverted =
BoundingBox(
longitude_west_i = 210_000_000, // 21 (east-most) given as west
latitude_south_i = 110_000_000, // 11 (north-most) given as south
longitude_east_i = 200_000_000, // 20
latitude_north_i = 100_000_000, // 10
)
val geofence = Waypoint(id = 1, bounding_box = inverted).toGeofence()
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
}
@Test
fun notifiesOnCrossingTruthTable() {
assertFalse(Waypoint(id = 1).notifiesOnCrossing)
assertTrue(Waypoint(id = 1, notify_on_enter = true).notifiesOnCrossing)
assertTrue(Waypoint(id = 1, notify_on_exit = true).notifiesOnCrossing)
}
/** R2: geofence fields survive an unrelated edit and a proto encode/decode round-trip. */
@Test
fun geofenceFieldsSurviveEditAndRoundTrip() {
val original =
Waypoint(
id = 7,
latitude_i = 100_000_000,
longitude_i = 200_000_000,
name = "old",
geofence_radius = 500,
bounding_box = box,
notify_on_enter = true,
notify_on_exit = true,
notify_favorites_only = true,
)
val edited = original.copy(name = "new")
assertEquals(500, edited.geofence_radius)
assertEquals(box, edited.bounding_box)
assertTrue(edited.notify_on_enter)
assertTrue(edited.notify_on_exit)
assertTrue(edited.notify_favorites_only)
val roundTripped = Waypoint.ADAPTER.decode(Waypoint.ADAPTER.encode(edited))
assertEquals(edited, roundTripped)
}
private fun assertNotNullCircleAt(circle: GeofenceCircle?, lat: Double, lon: Double, radius: Int) {
assertEquals(GeofenceCircle(centerLat = lat, centerLon = lon, radiusMeters = radius), circle)
}
}

View File

@@ -675,6 +675,24 @@
<string name="generate_input_event_on_cw">Generate input event on CW</string>
<string name="generate_input_event_on_press">Generate input event on Press</string>
<string name="generate_qr_code">Generate QR Code</string>
<!-- GEOFENCE -->
<string name="geofence">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>
<string name="geofence_box_use_view">Use current view</string>
<string name="geofence_edit_area">Edit area</string>
<string name="geofence_entered_body">%1$s entered %2$s</string>
<string name="geofence_entered_title">Entered %1$s</string>
<string name="geofence_favorites_only">Favorites only</string>
<string name="geofence_left_body">%1$s left %2$s</string>
<string name="geofence_left_title">Left %1$s</string>
<string name="geofence_notify_on_enter">Notify on enter</string>
<string name="geofence_notify_on_exit">Notify on exit</string>
<string name="geofence_off">Off</string>
<string name="geofence_radius">Geofence radius</string>
<string name="geofence_remove_area">Remove area</string>
<string name="geofence_set_area">Set area on map</string>
<string name="get_started">Get started</string>
<string name="good">Good</string>
<!-- GPIO -->

View File

@@ -14,7 +14,7 @@
* 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.app.map.component
package org.meshtastic.feature.map.component
import android.app.DatePickerDialog
import android.app.TimePickerDialog
@@ -24,20 +24,28 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -51,6 +59,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -65,12 +74,23 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.model.geofence.GeofenceRadiusPresets
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.geofence_edit_area
import org.meshtastic.core.resources.geofence_favorites_only
import org.meshtastic.core.resources.geofence_notify_on_enter
import org.meshtastic.core.resources.geofence_notify_on_exit
import org.meshtastic.core.resources.geofence_off
import org.meshtastic.core.resources.geofence_radius
import org.meshtastic.core.resources.geofence_remove_area
import org.meshtastic.core.resources.geofence_set_area
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
@@ -81,18 +101,27 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalMaterial3Api::class)
/**
* Shared waypoint editor used by both the google and fdroid map flavors (DRY replaces the two drifted per-flavor
* copies). Map-engine-specific concerns stay outside: drawing the box overlay and the drag-to-define gesture are
* triggered via [onBeginBoxAuthoring], which hands the current draft back to the flavor's map so the user can define
* the bounding box there; the flavor re-opens this dialog with the drawn box applied.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
displayUnits: DisplayUnits,
onSend: (Waypoint) -> Unit,
onDelete: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
onBeginBoxAuthoring: (Waypoint) -> Unit = {},
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@@ -103,7 +132,6 @@ fun EditWaypointDialog(
val context = LocalContext.current
val tz = systemTimeZone
// Initialize date and time states from waypointInput.expire
var selectedDateString by remember { mutableStateOf("") }
var selectedTimeString by remember { mutableStateOf("") }
var isExpiryEnabled by remember {
@@ -123,7 +151,7 @@ fun EditWaypointDialog(
val date = java.util.Date(instant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now
} else {
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
@@ -148,7 +176,7 @@ fun EditWaypointDialog(
)
},
text = {
Column(modifier = modifier.fillMaxWidth()) {
Column(modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = waypointInput.name,
onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) },
@@ -220,7 +248,6 @@ fun EditWaypointDialog(
isExpiryEnabled = checked
if (checked) {
val expireValue = waypointInput.expire
// Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
@@ -336,6 +363,16 @@ fun EditWaypointDialog(
}
}
}
Spacer(modifier = Modifier.size(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.size(8.dp))
GeofenceSection(
waypoint = waypointInput,
displayUnits = displayUnits,
onWaypointChange = { waypointInput = it },
onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput) },
)
}
},
confirmButton = {
@@ -344,23 +381,20 @@ fun EditWaypointDialog(
horizontalArrangement = Arrangement.End,
) {
if (waypoint.id != 0) {
TextButton(
onClick = { onDeleteClicked(waypointInput) },
modifier = Modifier.padding(end = 8.dp),
) {
TextButton(onClick = { onDelete(waypointInput) }, modifier = Modifier.padding(end = 8.dp)) {
Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
Text(stringResource(Res.string.cancel))
}
Button(onClick = { onSendClicked(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
Button(onClick = { onSend(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
Text(stringResource(Res.string.send))
}
}
},
dismissButton = null, // Using custom buttons in confirmButton Row
dismissButton = null,
modifier = modifier,
)
} else {
@@ -370,3 +404,126 @@ fun EditWaypointDialog(
}
}
}
/**
* Geofence authoring controls. Radius presets are locale-aware (wire value is always metres); the enter/exit toggles
* appear only once a circle or box exists, and the favorites-only toggle only once enter or exit is on.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GeofenceSection(
waypoint: Waypoint,
displayUnits: DisplayUnits,
onWaypointChange: (Waypoint) -> Unit,
onBeginBoxAuthoring: () -> Unit,
) {
val presets = remember(displayUnits) { GeofenceRadiusPresets.forUnits(displayUnits) }
val hasBox = waypoint.bounding_box != null
val hasRegion = waypoint.geofence_radius > 0 || hasBox
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(Res.string.geofence),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Spacer(modifier = Modifier.size(4.dp))
Text(stringResource(Res.string.geofence_radius), style = MaterialTheme.typography.bodyMedium)
// Highlight the closest preset so a radius authored on another client (e.g. an imperial/non-preset value)
// is still shown as selected rather than appearing as "no selection" and being silently clobbered on edit.
val selectedRadius = GeofenceRadiusPresets.nearest(waypoint.geofence_radius, displayUnits)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
presets.forEach { meters ->
val label =
if (meters == 0) {
stringResource(Res.string.geofence_off)
} else {
meters.toDistanceString(displayUnits)
}
FilterChip(
selected = meters == selectedRadius,
onClick = {
onWaypointChange(waypoint.copy(geofence_radius = meters).normalizeGeofenceNotifications())
},
label = { Text(label) },
)
}
}
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBeginBoxAuthoring) {
Text(stringResource(if (hasBox) Res.string.geofence_edit_area else Res.string.geofence_set_area))
}
if (hasBox) {
TextButton(
onClick = { onWaypointChange(waypoint.copy(bounding_box = null).normalizeGeofenceNotifications()) },
) {
Text(stringResource(Res.string.geofence_remove_area))
}
}
}
if (hasRegion) {
Spacer(modifier = Modifier.size(8.dp))
GeofenceNotificationControls(waypoint = waypoint, onWaypointChange = onWaypointChange)
}
}
}
/** Enter/exit toggles plus the favorites-only toggle (visible only once enter or exit is on). */
@Composable
private fun GeofenceNotificationControls(waypoint: Waypoint, onWaypointChange: (Waypoint) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
GeofenceToggleRow(
label = stringResource(Res.string.geofence_notify_on_enter),
checked = waypoint.notify_on_enter,
onCheckedChange = {
onWaypointChange(waypoint.copy(notify_on_enter = it).normalizeGeofenceNotifications())
},
)
GeofenceToggleRow(
label = stringResource(Res.string.geofence_notify_on_exit),
checked = waypoint.notify_on_exit,
onCheckedChange = { onWaypointChange(waypoint.copy(notify_on_exit = it).normalizeGeofenceNotifications()) },
)
if (waypoint.notify_on_enter || waypoint.notify_on_exit) {
GeofenceToggleRow(
label = stringResource(Res.string.geofence_favorites_only),
checked = waypoint.notify_favorites_only,
onCheckedChange = { onWaypointChange(waypoint.copy(notify_favorites_only = it)) },
)
}
}
}
@Composable
private fun GeofenceToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
// The whole row is one toggleable target so the label names the switch to a screen reader and the
// tap target spans the row, not just the thumb.
modifier =
Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label)
Switch(checked = checked, onCheckedChange = null)
}
}
/**
* Clears geofence notification flags the UI can no longer expose, so stale values don't silently reappear when a region
* is added again: drop all three when no region remains, and drop favorites-only when neither enter nor exit is on.
*/
private fun Waypoint.normalizeGeofenceNotifications(): Waypoint = when {
geofence_radius <= 0 && bounding_box == null ->
copy(notify_on_enter = false, notify_on_exit = false, notify_favorites_only = false)
!notify_on_enter && !notify_on_exit -> copy(notify_favorites_only = false)
else -> this
}

View File

@@ -31,9 +31,11 @@ import org.meshtastic.core.model.DataPacket
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.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.any
@@ -43,6 +45,7 @@ import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -58,10 +61,17 @@ open class BaseMapViewModel(
protected val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
private val radioConfigRepository: RadioConfigRepository,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo
/** Device display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. */
val displayUnits: StateFlow<DisplayUnits> =
radioConfigRepository.localConfigFlow
.map { it.display?.units ?: DisplayUnits.METRIC }
.stateInWhileSubscribed(initialValue = DisplayUnits.METRIC)
val ourNodeInfo = nodeRepository.ourNodeInfo
val myNodeNum
@@ -88,15 +98,9 @@ open class BaseMapViewModel(
val waypoints: StateFlow<Map<Int, DataPacket>> =
packetRepository
.getWaypoints()
.mapLatest { list ->
list
.filter { it.waypoint != null }
.associateBy { packet -> packet.waypoint!!.id }
.filterValues {
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
}
}
// Shared with GeofenceMonitor via activeWaypointPackets — dedup by waypoint id + drop expired,
// so the map and the geofence engine can't drift (getWaypoints is a row-per-transmission firehose).
.mapLatest { list -> list.activeWaypointPackets(nowSeconds) }
.stateInWhileSubscribed(initialValue = emptyMap())
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)

View File

@@ -20,6 +20,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
@KoinViewModel
@@ -28,4 +29,5 @@ class SharedMapViewModel(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)
radioConfigRepository: RadioConfigRepository,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository)

View File

@@ -34,6 +34,7 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Waypoint
@@ -50,6 +51,7 @@ class BaseMapViewModelTest {
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var radioConfigRepository: FakeRadioConfigRepository
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
private val mapPrefs: MapPrefs = mock()
private val packetRepository: PacketRepository = mock()
@@ -59,6 +61,7 @@ class BaseMapViewModelTest {
Dispatchers.setMain(testDispatcher)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
radioConfigRepository = FakeRadioConfigRepository()
radioController.setConnectionState(ConnectionState.Disconnected)
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
@@ -76,6 +79,7 @@ class BaseMapViewModelTest {
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
radioConfigRepository = radioConfigRepository,
)
}

View File

@@ -95,7 +95,7 @@ mqttastic = "0.4.0"
jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
takpacket-sdk = "0.7.0"
meshtastic-protobufs = "2.7.26-21879a9-SNAPSHOT"
meshtastic-protobufs = "2.7.26-06d729a-SNAPSHOT"
# Gradle Plugins
develocity = "4.5.0"