diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 725761858..1d33c7656 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 ### diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 97a037072..9ba0182f3 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -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, waypointMarkers: List, + geofenceOverlays: List, 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(null) } + var geofenceBoxBoundingBox: BoundingBox? by remember { mutableStateOf(null) } + var showDownloadButton: Boolean by remember { mutableStateOf(false) } var showEditWaypointDialog by remember { mutableStateOf(null) } var showDeleteWaypointDialog by remember { mutableStateOf(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): List { + 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)) } + } + } +} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index 5d68e9d21..549e13f91 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index f013a1253..000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -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 . - */ -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 = {}, - ) - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 28f46cb35..7dad8d2fb 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(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(null) } + var boxAuthoringFirstCorner by remember { mutableStateOf(null) } + var boxAuthoringSecondCorner by remember { mutableStateOf(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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 0e36f2699..c378dc6ef 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() @@ -147,11 +145,6 @@ class MapViewModel( private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL) val selectedGoogleMapType: StateFlow = _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 ( diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index 227805ebd..97dd9a433 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -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) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStore.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStore.kt new file mode 100644 index 000000000..7c9773492 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStore.kt @@ -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 . + */ +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, 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): Unit = + mutex.withLock { inside.keys.retainAll { it.first in waypointIds } } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt new file mode 100644 index 000000000..19908c568 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt @@ -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 . + */ +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, + 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 = emptyList() + + // Unbounded so we never drop a position (which could swallow a real crossing); positions arrive infrequently. + private val samples = Channel(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 + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 35308659e..d06434c42 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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) { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStoreTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStoreTest.kt new file mode 100644 index 000000000..e0da79320 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceCrossingStoreTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt new file mode 100644 index 000000000..df22ce3ae --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt @@ -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 . + */ +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 { + 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 + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 2bb95c3e0..89f59b948 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -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 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt new file mode 100644 index 000000000..bb35e166a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/ActiveWaypoints.kt @@ -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 . + */ +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.activeWaypointPackets(nowSeconds: Long): Map = filter { it.waypoint != null } + .associateBy { it.waypoint!!.id } + .filterValues { + val expire = it.waypoint?.expire ?: 0 + expire == 0 || expire.toLong() > nowSeconds + } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/Geofence.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/Geofence.kt new file mode 100644 index 000000000..32ee82b3e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/Geofence.kt @@ -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 . + */ +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) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresets.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresets.kt new file mode 100644 index 000000000..a6aca7eb1 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresets.kt @@ -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 . + */ +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 = 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 = listOf(0, 76, 152, 305, 1609, 3219, 8047) + + fun forUnits(units: DisplayUnits): List = + 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) } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt new file mode 100644 index 000000000..6d15d441f --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/ActiveWaypointsTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresetsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresetsTest.kt new file mode 100644 index 000000000..afe11c264 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceRadiusPresetsTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceTest.kt new file mode 100644 index 000000000..4b1329345 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/GeofenceTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/WaypointToGeofenceTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/WaypointToGeofenceTest.kt new file mode 100644 index 000000000..ae01a6725 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/geofence/WaypointToGeofenceTest.kt @@ -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 . + */ +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) + } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a36157b42..1c962852a 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -675,6 +675,24 @@ Generate input event on CW Generate input event on Press Generate QR Code + + Geofence + Confirm area + Tap two corners to define the area + Pan the map to frame the area, then tap Confirm area + Use current view + Edit area + %1$s entered %2$s + Entered %1$s + Favorites only + %1$s left %2$s + Left %1$s + Notify on enter + Notify on exit + Off + Geofence radius + Remove area + Set area on map Get started Good diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt similarity index 68% rename from androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt rename to feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 06a063ddf..49dfeaffc 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -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 +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 3c94aa76b..58e1d44f6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -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 = + 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> = 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) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index 767cc6620..6c9f6e650 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -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) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index bc935be25..d9cf52da7 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.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> 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, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c59710583..51eaa640d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"