mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-02 17:35:36 -04:00
feat: Waypoint geofences (editor, map overlays, alert engine) (#6014)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
.skills/compose-ui/strings-index.txt
generated
18
.skills/compose-ui/strings-index.txt
generated
@@ -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 ###
|
||||
|
||||
@@ -18,19 +18,24 @@ package org.meshtastic.app.map
|
||||
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
@@ -75,7 +80,6 @@ import org.meshtastic.app.R
|
||||
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
|
||||
import org.meshtastic.app.map.component.CacheLayout
|
||||
import org.meshtastic.app.map.component.DownloadButton
|
||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||
import org.meshtastic.app.map.model.CustomTileSource
|
||||
import org.meshtastic.app.map.model.MarkerWithLabel
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
@@ -85,6 +89,7 @@ import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.model.geofence.toGeofence
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.calculating
|
||||
import org.meshtastic.core.resources.cancel
|
||||
@@ -93,6 +98,9 @@ import org.meshtastic.core.resources.close
|
||||
import org.meshtastic.core.resources.delete_for_everyone
|
||||
import org.meshtastic.core.resources.delete_for_me
|
||||
import org.meshtastic.core.resources.expires
|
||||
import org.meshtastic.core.resources.geofence
|
||||
import org.meshtastic.core.resources.geofence_box_author_confirm
|
||||
import org.meshtastic.core.resources.geofence_box_author_hint_viewport
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.last_heard_filter_label
|
||||
import org.meshtastic.core.resources.location_disabled
|
||||
@@ -132,6 +140,7 @@ import org.meshtastic.core.ui.util.rememberLocationPermissionState
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
@@ -157,18 +166,31 @@ import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
import org.meshtastic.proto.BoundingBox as ProtoBoundingBox
|
||||
|
||||
/**
|
||||
* Marker subclass for waypoint geofence overlays (circles + boxes). Distinguished from the tile-download / geofence
|
||||
* authoring rectangle Polygons so [updateMarkers] can rebuild only the waypoint geofences on each refresh. The
|
||||
* transient-rectangle cleanups in the download/authoring paths exclude this subclass (`removeAll { it is Polygon && it
|
||||
* !is GeofenceOverlayPolygon }`) so persistent waypoint geofences are not wiped.
|
||||
*/
|
||||
private class GeofenceOverlayPolygon : Polygon()
|
||||
|
||||
private fun MapView.updateMarkers(
|
||||
nodeMarkers: List<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
geofenceOverlays: List<GeofenceOverlayPolygon>,
|
||||
nodeClusterer: RadiusMarkerClusterer,
|
||||
) {
|
||||
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
|
||||
|
||||
overlays.removeAll { overlay ->
|
||||
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
|
||||
overlay is MarkerWithLabel ||
|
||||
overlay is GeofenceOverlayPolygon ||
|
||||
(overlay is Marker && overlay !in nodeClusterer.items)
|
||||
}
|
||||
|
||||
overlays.addAll(geofenceOverlays)
|
||||
overlays.addAll(waypointMarkers)
|
||||
|
||||
nodeClusterer.items.clear()
|
||||
@@ -226,6 +248,12 @@ fun MapView(
|
||||
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
|
||||
var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) }
|
||||
|
||||
// Geofence box authoring: when geofenceBoxDraft is non-null the user is positioning a rectangle (reusing the
|
||||
// tile-download rectangle machinery) that becomes the draft's bounding box on confirm. Kept distinct from the
|
||||
// tile-download box (downloadRegionBoundingBox) so the two rectangle modes never collide.
|
||||
var geofenceBoxDraft by remember { mutableStateOf<Waypoint?>(null) }
|
||||
var geofenceBoxBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
|
||||
|
||||
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
|
||||
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
|
||||
var showDeleteWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
|
||||
@@ -327,6 +355,7 @@ fun MapView(
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
|
||||
val myId by mapViewModel.myId.collectAsStateWithLifecycle()
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(selectedWaypointId, waypoints) {
|
||||
if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) {
|
||||
@@ -428,9 +457,12 @@ fun MapView(
|
||||
expireTimeMillis <= now -> "Expired"
|
||||
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
|
||||
}
|
||||
// Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's
|
||||
// accessible title for screen-reader and color-challenged users.
|
||||
val geofenceLabel = if (pt.toGeofence() != null) " · " + getString(Res.string.geofence) else ""
|
||||
MarkerWithLabel(this, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
|
||||
title = "${pt.name} (${getUsername(waypoint.from)}$lock)$geofenceLabel"
|
||||
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
|
||||
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
|
||||
if (selectedWaypointId == pt.id) {
|
||||
@@ -444,6 +476,48 @@ fun MapView(
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the orange geofence overlays (a circle for a radius geofence, a 4-point rect for a bounding box) for every
|
||||
// displayed waypoint. Uses the shared toGeofence() decoder so the coordinate math stays in one place.
|
||||
fun buildGeofenceOverlays(waypoints: Collection<DataPacket>): List<GeofenceOverlayPolygon> {
|
||||
if (!mapFilterState.showWaypoints) return emptyList()
|
||||
return waypoints.flatMap { packet ->
|
||||
val pt = packet.waypoint ?: return@flatMap emptyList()
|
||||
val geofence = pt.toGeofence() ?: return@flatMap emptyList()
|
||||
buildList {
|
||||
geofence.circle?.let { circle ->
|
||||
add(
|
||||
GeofenceOverlayPolygon().apply {
|
||||
points =
|
||||
Polygon.pointsAsCircle(
|
||||
GeoPoint(circle.centerLat, circle.centerLon),
|
||||
circle.radiusMeters.toDouble(),
|
||||
)
|
||||
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
|
||||
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
|
||||
fillPaint.color = GEOFENCE_FILL_COLOR
|
||||
},
|
||||
)
|
||||
}
|
||||
geofence.box?.let { box ->
|
||||
add(
|
||||
GeofenceOverlayPolygon().apply {
|
||||
points =
|
||||
listOf(
|
||||
GeoPoint(box.south, box.west),
|
||||
GeoPoint(box.north, box.west),
|
||||
GeoPoint(box.north, box.east),
|
||||
GeoPoint(box.south, box.east),
|
||||
)
|
||||
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
|
||||
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
|
||||
fillPaint.color = GEOFENCE_FILL_COLOR
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mapEventsReceiver =
|
||||
object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
@@ -453,7 +527,7 @@ fun MapView(
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
val enabled = isConnected && downloadRegionBoundingBox == null
|
||||
val enabled = isConnected && downloadRegionBoundingBox == null && geofenceBoxDraft == null
|
||||
|
||||
if (enabled) {
|
||||
showEditWaypointDialog =
|
||||
@@ -482,7 +556,7 @@ fun MapView(
|
||||
}
|
||||
|
||||
fun MapView.generateBoxOverlay() {
|
||||
overlays.removeAll { it is Polygon }
|
||||
overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
|
||||
val zoomFactor = 1.3
|
||||
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
|
||||
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
|
||||
@@ -498,11 +572,29 @@ fun MapView(
|
||||
cacheEstimate = getString(Res.string.map_cache_tiles, tileCount)
|
||||
}
|
||||
|
||||
// Positions the geofence rectangle from the current viewport (reusing the tile-download rectangle approach) and
|
||||
// draws an orange preview Polygon. Mirrors generateBoxOverlay(), but stores into geofenceBoxBoundingBox so it
|
||||
// never collides with the tile-download box.
|
||||
fun MapView.generateGeofenceBoxOverlay() {
|
||||
overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
|
||||
geofenceBoxBoundingBox = boundingBox.zoomIn(GEOFENCE_BOX_ZOOM_FACTOR)
|
||||
val polygon =
|
||||
Polygon().apply {
|
||||
points = Polygon.pointsAsRect(geofenceBoxBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
|
||||
outlinePaint.color = GEOFENCE_OVERLAY_COLOR
|
||||
outlinePaint.strokeWidth = GEOFENCE_STROKE_WIDTH_PX
|
||||
fillPaint.color = GEOFENCE_FILL_COLOR
|
||||
}
|
||||
overlays.add(polygon)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
val boxOverlayListener =
|
||||
object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
event.source.generateBoxOverlay()
|
||||
when {
|
||||
downloadRegionBoundingBox != null -> event.source.generateBoxOverlay()
|
||||
geofenceBoxDraft != null -> event.source.generateGeofenceBoxOverlay()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -546,7 +638,9 @@ fun MapView(
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
floatingActionButton = {
|
||||
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
|
||||
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null && geofenceBoxDraft == null) {
|
||||
showCacheManagerDialog = true
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
@@ -563,19 +657,45 @@ fun MapView(
|
||||
updateMarkers(
|
||||
onNodesChanged(nodes),
|
||||
onWaypointChanged(waypoints.values, selectedWaypointId),
|
||||
buildGeofenceOverlays(waypoints.values),
|
||||
nodeClusterer,
|
||||
)
|
||||
}
|
||||
mapView.drawOverlays()
|
||||
}, // Renamed map to mapView to avoid conflict
|
||||
)
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
if (geofenceBoxDraft != null) {
|
||||
GeofenceBoxAuthoringBar(
|
||||
onConfirm = {
|
||||
val draft = geofenceBoxDraft
|
||||
val bb = geofenceBoxBoundingBox
|
||||
if (draft != null && bb != null) {
|
||||
showEditWaypointDialog = draft.copy(bounding_box = bb.toProtoBoundingBox())
|
||||
} else {
|
||||
showEditWaypointDialog = draft
|
||||
}
|
||||
geofenceBoxDraft = null
|
||||
geofenceBoxBoundingBox = null
|
||||
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
|
||||
map.invalidate()
|
||||
},
|
||||
onCancel = {
|
||||
val draft = geofenceBoxDraft
|
||||
geofenceBoxDraft = null
|
||||
geofenceBoxBoundingBox = null
|
||||
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
|
||||
map.invalidate()
|
||||
showEditWaypointDialog = draft
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else if (downloadRegionBoundingBox != null) {
|
||||
CacheLayout(
|
||||
cacheEstimate = cacheEstimate,
|
||||
onExecuteJob = { startDownload() },
|
||||
onCancelDownload = {
|
||||
downloadRegionBoundingBox = null
|
||||
map.overlays.removeAll { it is Polygon }
|
||||
map.overlays.removeAll { it is Polygon && it !is GeofenceOverlayPolygon }
|
||||
map.invalidate()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
@@ -662,7 +782,8 @@ fun MapView(
|
||||
if (showEditWaypointDialog != null) {
|
||||
EditWaypointDialog(
|
||||
waypoint = showEditWaypointDialog ?: return, // Safe call
|
||||
onSendClicked = { waypoint ->
|
||||
displayUnits = displayUnits,
|
||||
onSend = { waypoint ->
|
||||
Logger.d { "User clicked send waypoint ${waypoint.id}" }
|
||||
showEditWaypointDialog = null
|
||||
|
||||
@@ -682,7 +803,7 @@ fun MapView(
|
||||
),
|
||||
)
|
||||
},
|
||||
onDeleteClicked = { waypoint ->
|
||||
onDelete = { waypoint ->
|
||||
Logger.d { "User clicked delete waypoint ${waypoint.id}" }
|
||||
showEditWaypointDialog = null
|
||||
showDeleteWaypointDialog = waypoint
|
||||
@@ -691,6 +812,12 @@ fun MapView(
|
||||
Logger.d { "User clicked cancel marker edit dialog" }
|
||||
showEditWaypointDialog = null
|
||||
},
|
||||
onBeginBoxAuthoring = { draft ->
|
||||
Logger.d { "User began geofence box authoring for waypoint ${draft.id}" }
|
||||
showEditWaypointDialog = null
|
||||
geofenceBoxDraft = draft
|
||||
map.generateGeofenceBoxOverlay()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -992,3 +1119,39 @@ private fun MapsDialog(
|
||||
}
|
||||
|
||||
private const val WAYPOINT_ZOOM = 15.0
|
||||
|
||||
// Geofence / authoring rectangle styling (orange) for the imperative OSMDroid Polygon paints (android.graphics ints).
|
||||
private const val GEOFENCE_OVERLAY_COLOR = 0xFFFF9800.toInt()
|
||||
private const val GEOFENCE_FILL_COLOR = 0x1FFF9800 // ~12% alpha orange
|
||||
private const val GEOFENCE_STROKE_WIDTH_PX = 4f
|
||||
private const val GEOFENCE_BOX_ZOOM_FACTOR = 1.3
|
||||
|
||||
/** Converts an OSMDroid [BoundingBox] (decimal degrees) to a proto [ProtoBoundingBox] (degrees ×1e7). */
|
||||
@Suppress("MagicNumber")
|
||||
private fun BoundingBox.toProtoBoundingBox(): ProtoBoundingBox = ProtoBoundingBox(
|
||||
longitude_west_i = (lonWest * 1e7).toInt(),
|
||||
latitude_south_i = (latSouth * 1e7).toInt(),
|
||||
longitude_east_i = (lonEast * 1e7).toInt(),
|
||||
latitude_north_i = (latNorth * 1e7).toInt(),
|
||||
)
|
||||
|
||||
/** Bottom bar shown while the user positions the geofence rectangle: confirm applies it, cancel re-opens the editor. */
|
||||
@Composable
|
||||
private fun GeofenceBoxAuthoringBar(onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.geofence_box_author_hint_viewport),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
|
||||
Button(onClick = onConfirm) { Text(stringResource(Res.string.geofence_box_author_confirm)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class MapViewModel(
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
buildConfigProvider: BuildConfigProvider,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
|
||||
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.common.util.systemTimeZone
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.date
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.description
|
||||
import org.meshtastic.core.resources.expires
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.send
|
||||
import org.meshtastic.core.resources.time
|
||||
import org.meshtastic.core.resources.waypoint_edit
|
||||
import org.meshtastic.core.resources.waypoint_new
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.icon.CalendarMonth
|
||||
import org.meshtastic.core.ui.icon.Lock
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
// Get current context for dialogs
|
||||
val context = LocalContext.current
|
||||
val tz = systemTimeZone
|
||||
|
||||
// Determine locale-specific date format
|
||||
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
|
||||
// Check if 24-hour format is preferred
|
||||
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
|
||||
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
|
||||
|
||||
val currentInstant =
|
||||
remember(waypointInput.expire) {
|
||||
val expire = waypointInput.expire
|
||||
if (expire != 0 && expire != Int.MAX_VALUE) {
|
||||
kotlin.time.Instant.fromEpochSeconds(expire.toLong())
|
||||
} else {
|
||||
kotlin.time.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
|
||||
// State to hold selected date and time
|
||||
var selectedDate by
|
||||
remember(currentInstant) {
|
||||
mutableStateOf(
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
var selectedTime by
|
||||
remember(currentInstant) {
|
||||
mutableStateOf(
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.name),
|
||||
value = waypointInput.name,
|
||||
maxSize = 29,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {}),
|
||||
onValueChanged = { waypointInput = waypointInput.copy(name = it) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(emoji)),
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
|
||||
.padding(4.dp),
|
||||
fontSize = 24.sp,
|
||||
color = Color.Unspecified.copy(alpha = 1f),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.description),
|
||||
value = waypointInput.description,
|
||||
maxSize = 99,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {}),
|
||||
onValueChanged = { waypointInput = waypointInput.copy(description = it) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.Lock,
|
||||
contentDescription = stringResource(Res.string.locked),
|
||||
)
|
||||
Text(stringResource(Res.string.locked))
|
||||
Switch(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
|
||||
checked = waypointInput.locked_to != 0,
|
||||
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
|
||||
)
|
||||
}
|
||||
|
||||
val ldt = currentInstant.toLocalDateTime(tz)
|
||||
val datePickerDialog =
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
val newLdt =
|
||||
LocalDateTime(
|
||||
year = selectedYear,
|
||||
month = Month(selectedMonth + 1),
|
||||
day = selectedDay,
|
||||
hour = ldt.hour,
|
||||
minute = ldt.minute,
|
||||
second = ldt.second,
|
||||
nanosecond = ldt.nanosecond,
|
||||
)
|
||||
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.year,
|
||||
ldt.month.ordinal,
|
||||
ldt.day,
|
||||
)
|
||||
|
||||
val timePickerDialog =
|
||||
android.app.TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
val newLdt =
|
||||
LocalDateTime(
|
||||
year = ldt.year,
|
||||
month = ldt.month,
|
||||
day = ldt.day,
|
||||
hour = selectedHour,
|
||||
minute = selectedMinute,
|
||||
second = ldt.second,
|
||||
nanosecond = ldt.nanosecond,
|
||||
)
|
||||
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.hour,
|
||||
ldt.minute,
|
||||
is24Hour,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
imageVector = MeshtasticIcons.CalendarMonth,
|
||||
contentDescription = stringResource(Res.string.expires),
|
||||
)
|
||||
Text(stringResource(Res.string.expires))
|
||||
Switch(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
|
||||
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
|
||||
onCheckedChange = { isChecked ->
|
||||
if (isChecked) {
|
||||
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
|
||||
} else {
|
||||
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedDate,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedTime,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
if (waypoint.id != 0) {
|
||||
Button(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
enabled = !(waypointInput.name.isNullOrEmpty()),
|
||||
) {
|
||||
Text(stringResource(Res.string.delete))
|
||||
}
|
||||
}
|
||||
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy(icon = it.codePointAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
private fun EditWaypointFormPreview() {
|
||||
AppTheme {
|
||||
EditWaypointDialog(
|
||||
waypoint =
|
||||
Waypoint(
|
||||
id = 123,
|
||||
name = "Test 123",
|
||||
description = "This is only a test",
|
||||
icon = 128169,
|
||||
expire = (nowSeconds.toInt() + 8 * 3600),
|
||||
),
|
||||
onSendClicked = {},
|
||||
onDeleteClicked = {},
|
||||
onDismissRequest = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import android.view.WindowManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,12 +36,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -68,6 +73,7 @@ import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.maps.android.SphericalUtil
|
||||
import com.google.maps.android.compose.CameraPositionState
|
||||
import com.google.maps.android.compose.Circle
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapEffect
|
||||
@@ -77,6 +83,7 @@ import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.MarkerInfoWindowComposable
|
||||
import com.google.maps.android.compose.Polygon
|
||||
import com.google.maps.android.compose.Polyline
|
||||
import com.google.maps.android.compose.TileOverlay
|
||||
import com.google.maps.android.compose.rememberCameraPositionState
|
||||
@@ -93,7 +100,6 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.app.map.component.ClusterItemsListDialog
|
||||
import org.meshtastic.app.map.component.CustomMapLayersSheet
|
||||
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
|
||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||
import org.meshtastic.app.map.component.MapFilterDropdown
|
||||
import org.meshtastic.app.map.component.MapTypeDropdown
|
||||
import org.meshtastic.app.map.component.NodeClusterMarkers
|
||||
@@ -104,6 +110,7 @@ import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import org.meshtastic.core.model.geofence.toGeofence
|
||||
import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
@@ -112,6 +119,9 @@ import org.meshtastic.core.model.util.mpsToMph
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.alt
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.geofence_box_author_hint
|
||||
import org.meshtastic.core.resources.geofence_box_use_view
|
||||
import org.meshtastic.core.resources.heading
|
||||
import org.meshtastic.core.resources.latitude
|
||||
import org.meshtastic.core.resources.longitude
|
||||
@@ -134,9 +144,11 @@ import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.core.ui.util.rememberLocationPermissionState
|
||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.feature.map.tracerouteNodeSelection
|
||||
import org.meshtastic.proto.BoundingBox
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
@@ -175,6 +187,15 @@ sealed interface GoogleMapMode {
|
||||
private const val TRACEROUTE_OFFSET_METERS = 100.0
|
||||
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
|
||||
|
||||
// Shared geofence overlay styling (orange, matching the fdroid flavor).
|
||||
private val GEOFENCE_OVERLAY_COLOR = Color(0xFFFF9800)
|
||||
private const val GEOFENCE_FILL_ALPHA = 0.12f
|
||||
private const val GEOFENCE_STROKE_WIDTH = 2f
|
||||
|
||||
// Minimum lat/lon delta (~11 m) between the two box-authoring corner taps; below this the box would be degenerate
|
||||
// (zero-area) so the second tap is ignored.
|
||||
private const val BOX_AUTHORING_MIN_CORNER_DELTA = 1e-4
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -219,6 +240,13 @@ fun MapView(
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
|
||||
// --- Geofence box authoring (Main mode) ---
|
||||
// When non-null, the user is defining a bounding box for [boxAuthoringDraft] by tapping two corners.
|
||||
var boxAuthoringDraft by remember { mutableStateOf<Waypoint?>(null) }
|
||||
var boxAuthoringFirstCorner by remember { mutableStateOf<LatLng?>(null) }
|
||||
var boxAuthoringSecondCorner by remember { mutableStateOf<LatLng?>(null) }
|
||||
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
@@ -529,8 +557,28 @@ fun MapView(
|
||||
mapType = effectiveGoogleMapType,
|
||||
isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
|
||||
),
|
||||
onMapClick = { latLng ->
|
||||
if (isMainMode && boxAuthoringDraft != null) {
|
||||
val first = boxAuthoringFirstCorner
|
||||
if (first == null) {
|
||||
boxAuthoringFirstCorner = latLng
|
||||
} else if (
|
||||
abs(latLng.latitude - first.latitude) >= BOX_AUTHORING_MIN_CORNER_DELTA &&
|
||||
abs(latLng.longitude - first.longitude) >= BOX_AUTHORING_MIN_CORNER_DELTA
|
||||
) {
|
||||
// Only commit when the two corners are meaningfully distinct; a near-identical second tap
|
||||
// would yield a zero-area box, so ignore it and keep waiting for a valid second corner.
|
||||
boxAuthoringSecondCorner = latLng
|
||||
val box = boundingBoxFromCorners(first, latLng)
|
||||
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
|
||||
boxAuthoringDraft = null
|
||||
boxAuthoringFirstCorner = null
|
||||
boxAuthoringSecondCorner = null
|
||||
}
|
||||
}
|
||||
},
|
||||
onMapLongClick = { latLng ->
|
||||
if (isMainMode && isConnected) {
|
||||
if (isMainMode && isConnected && boxAuthoringDraft == null) {
|
||||
editingWaypoint =
|
||||
Waypoint(
|
||||
latitude_i = (latLng.latitude / DEG_D).toInt(),
|
||||
@@ -552,6 +600,20 @@ fun MapView(
|
||||
}
|
||||
}
|
||||
|
||||
// The two-tap flow commits on the second tap, so there is no both-corners-uncommitted state to draw a
|
||||
// rectangle preview from; instead mark the first tapped corner so the user sees it registered.
|
||||
boxAuthoringFirstCorner?.let { first ->
|
||||
val cornerState = rememberUpdatedMarkerState(position = first)
|
||||
MarkerComposable(state = cornerState, zIndex = 5f) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(16.dp)
|
||||
.background(GEOFENCE_OVERLAY_COLOR, CircleShape)
|
||||
.border(2.dp, Color.White, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
is GoogleMapMode.Main ->
|
||||
MainMapContent(
|
||||
@@ -585,7 +647,6 @@ fun MapView(
|
||||
)
|
||||
|
||||
is GoogleMapMode.NodeTrack -> {
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
|
||||
NodeTrackOverlay(
|
||||
focusedNode = mode.focusedNode,
|
||||
@@ -620,7 +681,8 @@ fun MapView(
|
||||
editingWaypoint?.let { waypointToEdit ->
|
||||
EditWaypointDialog(
|
||||
waypoint = waypointToEdit,
|
||||
onSendClicked = { updatedWp ->
|
||||
displayUnits = displayUnits,
|
||||
onSend = { updatedWp ->
|
||||
var finalWp = updatedWp
|
||||
if (updatedWp.id == 0) {
|
||||
finalWp = finalWp.copy(id = mapViewModel.generatePacketId())
|
||||
@@ -631,7 +693,7 @@ fun MapView(
|
||||
mapViewModel.sendWaypoint(finalWp)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDeleteClicked = { wpToDelete ->
|
||||
onDelete = { wpToDelete ->
|
||||
if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) {
|
||||
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
|
||||
}
|
||||
@@ -639,10 +701,61 @@ fun MapView(
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDismissRequest = { editingWaypoint = null },
|
||||
onBeginBoxAuthoring = { draft ->
|
||||
boxAuthoringDraft = draft
|
||||
boxAuthoringFirstCorner = null
|
||||
boxAuthoringSecondCorner = null
|
||||
editingWaypoint = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Box-authoring affordance: instruct the user to tap two corners, with a cancel that re-opens the editor.
|
||||
boxAuthoringDraft?.let { draft ->
|
||||
Card(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 72.dp).padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.geofence_box_author_hint),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
boxAuthoringDraft = null
|
||||
boxAuthoringFirstCorner = null
|
||||
boxAuthoringSecondCorner = null
|
||||
editingWaypoint = draft
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
// Single operable control: commits the currently-visible map bounds as the box, so the box can be
|
||||
// authored without the two precise corner taps (keyboard/screen-reader accessible path).
|
||||
Button(
|
||||
onClick = {
|
||||
val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds
|
||||
if (bounds != null) {
|
||||
val box = boundingBoxFromCorners(bounds.southwest, bounds.northeast)
|
||||
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
|
||||
boxAuthoringDraft = null
|
||||
boxAuthoringFirstCorner = null
|
||||
boxAuthoringSecondCorner = null
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.geofence_box_use_view))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controls overlay
|
||||
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
|
||||
val showRefresh = visibleNetworkLayers.isNotEmpty()
|
||||
@@ -822,9 +935,45 @@ private fun MainMapContent(
|
||||
selectedWaypointId = selectedWaypointId,
|
||||
)
|
||||
|
||||
if (mapFilterState.showWaypoints) {
|
||||
displayableWaypoints.forEach { waypoint -> WaypointGeofenceOverlay(waypoint) }
|
||||
}
|
||||
|
||||
mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws this waypoint's geofence region: a [Circle] for a radius geofence and/or a [Polygon] for a bounding box. Uses
|
||||
* the shared [toGeofence] decoder so the coordinate math stays in one place. Renders nothing when no region is set.
|
||||
*/
|
||||
@Composable
|
||||
private fun WaypointGeofenceOverlay(waypoint: Waypoint) {
|
||||
val geofence = waypoint.toGeofence() ?: return
|
||||
geofence.circle?.let { circle ->
|
||||
Circle(
|
||||
center = LatLng(circle.centerLat, circle.centerLon),
|
||||
radius = circle.radiusMeters.toDouble(),
|
||||
strokeColor = GEOFENCE_OVERLAY_COLOR,
|
||||
fillColor = GEOFENCE_OVERLAY_COLOR.copy(alpha = GEOFENCE_FILL_ALPHA),
|
||||
strokeWidth = GEOFENCE_STROKE_WIDTH,
|
||||
)
|
||||
}
|
||||
geofence.box?.let { box ->
|
||||
Polygon(
|
||||
points =
|
||||
listOf(
|
||||
LatLng(box.south, box.west),
|
||||
LatLng(box.north, box.west),
|
||||
LatLng(box.north, box.east),
|
||||
LatLng(box.south, box.east),
|
||||
),
|
||||
strokeColor = GEOFENCE_OVERLAY_COLOR,
|
||||
fillColor = GEOFENCE_OVERLAY_COLOR.copy(alpha = GEOFENCE_FILL_ALPHA),
|
||||
strokeWidth = GEOFENCE_STROKE_WIDTH,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region --- Node Track Overlay ---
|
||||
@@ -1129,4 +1278,12 @@ fun Uri.getFileName(context: android.content.Context): String {
|
||||
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
|
||||
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
|
||||
|
||||
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
|
||||
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
|
||||
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),
|
||||
latitude_south_i = (minOf(a.latitude, b.latitude) / DEG_D).toInt(),
|
||||
longitude_east_i = (maxOf(a.longitude, b.longitude) / DEG_D).toInt(),
|
||||
latitude_north_i = (maxOf(a.latitude, b.latitude) / DEG_D).toInt(),
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -59,7 +58,6 @@ import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.Config
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -94,7 +92,7 @@ class MapViewModel(
|
||||
private val customTileProviderRepository: CustomTileProviderRepository,
|
||||
uiPrefs: UiPrefs,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, radioConfigRepository) {
|
||||
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
|
||||
@@ -147,11 +145,6 @@ class MapViewModel(
|
||||
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
|
||||
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
|
||||
|
||||
val displayUnits =
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.mapNotNull { it.config?.display?.units }
|
||||
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
|
||||
|
||||
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
|
||||
viewModelScope.launch {
|
||||
if (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/** The transition observed when a node's inside/outside state for a geofence is updated. */
|
||||
enum class GeofenceTransition {
|
||||
/** First time this (waypoint, node) pair was seen — establishes a baseline, never notifies. */
|
||||
BASELINE,
|
||||
ENTERED,
|
||||
EXITED,
|
||||
|
||||
/** Same side as last time — no notification. */
|
||||
UNCHANGED,
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory inside/outside state per `(waypointId, nodeNum)`. NOT persisted: a relaunch re-baselines, which is exactly
|
||||
* what prevents spurious alerts at startup. Mutated from the service scope as positions arrive, so guarded by a
|
||||
* [Mutex].
|
||||
*/
|
||||
@Single
|
||||
class GeofenceCrossingStore {
|
||||
private val mutex = Mutex()
|
||||
private val inside = mutableMapOf<Pair<Int, Int>, Boolean>()
|
||||
|
||||
/** Record [isInside] for ([waypointId], [nodeNum]) and report the transition versus the prior state. */
|
||||
suspend fun update(waypointId: Int, nodeNum: Int, isInside: Boolean): GeofenceTransition = mutex.withLock {
|
||||
val was = inside.put(waypointId to nodeNum, isInside)
|
||||
when {
|
||||
was == null -> GeofenceTransition.BASELINE
|
||||
was == isInside -> GeofenceTransition.UNCHANGED
|
||||
isInside -> GeofenceTransition.ENTERED
|
||||
else -> GeofenceTransition.EXITED
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop crossing state for waypoints no longer active, bounding memory growth. */
|
||||
suspend fun retainOnly(waypointIds: Set<Int>): Unit =
|
||||
mutex.withLock { inside.keys.retainAll { it.first in waypointIds } }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.geofence.activeWaypointPackets
|
||||
import org.meshtastic.core.model.geofence.notifiesOnCrossing
|
||||
import org.meshtastic.core.model.geofence.toGeofence
|
||||
import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.geofence
|
||||
import org.meshtastic.core.resources.geofence_entered_body
|
||||
import org.meshtastic.core.resources.geofence_entered_title
|
||||
import org.meshtastic.core.resources.geofence_left_body
|
||||
import org.meshtastic.core.resources.geofence_left_title
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Raises a LOCAL notification when another mesh node's reported position crosses a waypoint's geofence. Evaluation runs
|
||||
* against OTHER nodes' positions arriving over the mesh — never the device's own location — so this is manual
|
||||
* point-in-region math, NOT the OS Geofencing API.
|
||||
*
|
||||
* Hooked from [MeshDataHandlerImpl.handlePosition]; holds the active geofence-bearing waypoints in memory (refreshed
|
||||
* from [PacketRepository.getWaypoints], normalised via [activeWaypointPackets] so stale/expired transmissions can't
|
||||
* fire). Crossing state lives in [GeofenceCrossingStore] (in-memory, baseline-on-first-sighting).
|
||||
*
|
||||
* Received positions are funnelled through a single ordered worker so that two positions for the same node can never be
|
||||
* evaluated out of order (which would corrupt the inside/outside baseline and fire a spurious or missed alert).
|
||||
*/
|
||||
@Single
|
||||
class GeofenceMonitor(
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val nodeManager: NodeManager,
|
||||
private val serviceNotifications: MeshNotificationManager,
|
||||
private val crossingStore: GeofenceCrossingStore,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private data class PositionSample(val nodeNum: Int, val lat: Double, val lon: Double)
|
||||
|
||||
@Volatile private var activeGeofences: List<Waypoint> = emptyList()
|
||||
|
||||
// Unbounded so we never drop a position (which could swallow a real crossing); positions arrive infrequently.
|
||||
private val samples = Channel<PositionSample>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
// Single serial consumer → evaluations happen in arrival order, and we launch ONE coroutine, not one per
|
||||
// received position.
|
||||
scope.launch {
|
||||
for (sample in samples) {
|
||||
try {
|
||||
evaluate(sample.nodeNum, sample.lat, sample.lon)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
// Isolate per-sample failures: an unexpected throw must not kill the sole consumer and silently
|
||||
// stop geofence tracking for the rest of the session.
|
||||
Logger.e(e) { "Geofence evaluation failed for node ${sample.nodeNum}; skipping sample" }
|
||||
}
|
||||
}
|
||||
}
|
||||
// A bad emission must not tear down the snapshot for the rest of the session.
|
||||
scope.launch {
|
||||
packetRepository.value
|
||||
.getWaypoints()
|
||||
.catch { Logger.e(it) { "Geofence waypoint stream failed; geofence tracking paused" } }
|
||||
.collect { packets ->
|
||||
val active =
|
||||
packets
|
||||
.activeWaypointPackets(nowSeconds)
|
||||
.values
|
||||
.mapNotNull { it.waypoint }
|
||||
.filter { it.notifiesOnCrossing }
|
||||
activeGeofences = active
|
||||
crossingStore.retainOnly(active.map { it.id }.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate a received node position against every active geofence. [nodeNum] is the position's sender. */
|
||||
fun onPositionReceived(nodeNum: Int, myNodeNum: Int, position: Position) {
|
||||
val latI = position.latitude_i ?: 0
|
||||
val lonI = position.longitude_i ?: 0
|
||||
val lat = latI * DEG_D
|
||||
val lon = lonI * DEG_D
|
||||
// Skip our own position, a node with no fix (don't false-cross at 0,0), out-of-range/garbage coordinates, or
|
||||
// when nothing is geofenced.
|
||||
val skip =
|
||||
nodeNum == myNodeNum ||
|
||||
(latI == 0 && lonI == 0) ||
|
||||
lat !in MIN_LATITUDE..MAX_LATITUDE ||
|
||||
lon !in MIN_LONGITUDE..MAX_LONGITUDE ||
|
||||
activeGeofences.isEmpty()
|
||||
if (skip) return
|
||||
samples.trySend(PositionSample(nodeNum, lat, lon))
|
||||
}
|
||||
|
||||
private suspend fun evaluate(nodeNum: Int, lat: Double, lon: Double) {
|
||||
val now = nowSeconds
|
||||
for (waypoint in activeGeofences) {
|
||||
// Re-check expiry per evaluation: the snapshot is only recomputed on a new waypoint emission, so a
|
||||
// waypoint that expired since the last emission must not keep firing.
|
||||
if (waypoint.expire == 0 || waypoint.expire.toLong() > now) {
|
||||
val geofence = waypoint.toGeofence() ?: continue
|
||||
when (crossingStore.update(waypoint.id, nodeNum, geofence.contains(lat, lon))) {
|
||||
GeofenceTransition.ENTERED -> if (waypoint.notify_on_enter) notifyCrossing(waypoint, nodeNum, true)
|
||||
|
||||
GeofenceTransition.EXITED -> if (waypoint.notify_on_exit) notifyCrossing(waypoint, nodeNum, false)
|
||||
|
||||
GeofenceTransition.BASELINE,
|
||||
GeofenceTransition.UNCHANGED,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyCrossing(waypoint: Waypoint, nodeNum: Int, entered: Boolean) {
|
||||
// Favorites gate is receiver-local and resolved only on a real transition.
|
||||
if (waypoint.notify_favorites_only && nodeManager.nodeDBbyNodeNum[nodeNum]?.isFavorite != true) return
|
||||
|
||||
try {
|
||||
val nodeName =
|
||||
nodeManager.nodeDBbyNodeNum[nodeNum]?.user?.long_name?.takeIf { it.isNotBlank() }
|
||||
?: getStringSuspend(Res.string.unknown_username)
|
||||
val waypointName = waypoint.name.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.geofence)
|
||||
val title =
|
||||
getStringSuspend(
|
||||
if (entered) Res.string.geofence_entered_title else Res.string.geofence_left_title,
|
||||
waypointName,
|
||||
)
|
||||
val body =
|
||||
getStringSuspend(
|
||||
if (entered) Res.string.geofence_entered_body else Res.string.geofence_left_body,
|
||||
nodeName,
|
||||
waypointName,
|
||||
)
|
||||
// Reuse the waypoint notification channel + map?waypointId= deep link. A distinct contactKey per
|
||||
// (waypoint, node) gives each crossing its own notification id (matches the Apple reference).
|
||||
serviceNotifications.updateWaypointNotification(
|
||||
contactKey = "geofence:${waypoint.id}:$nodeNum",
|
||||
name = title,
|
||||
message = body,
|
||||
waypointId = waypoint.id,
|
||||
)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
// A string-resource or notification failure must not take down the evaluation worker.
|
||||
Logger.e(e) { "Failed to raise geofence crossing notification for waypoint ${waypoint.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_LATITUDE = -90.0
|
||||
const val MAX_LATITUDE = 90.0
|
||||
const val MIN_LONGITUDE = -180.0
|
||||
const val MAX_LONGITUDE = 180.0
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class GeofenceCrossingStoreTest {
|
||||
|
||||
@Test
|
||||
fun firstSightingIsBaselineThenTransitionsOnlyOnChange() = runTest {
|
||||
val store = GeofenceCrossingStore()
|
||||
|
||||
// First sighting establishes a baseline — never a notification.
|
||||
assertEquals(GeofenceTransition.BASELINE, store.update(waypointId = 1, nodeNum = 9, isInside = false))
|
||||
// Same side → no change.
|
||||
assertEquals(GeofenceTransition.UNCHANGED, store.update(1, 9, false))
|
||||
// outside -> inside.
|
||||
assertEquals(GeofenceTransition.ENTERED, store.update(1, 9, true))
|
||||
assertEquals(GeofenceTransition.UNCHANGED, store.update(1, 9, true))
|
||||
// inside -> outside.
|
||||
assertEquals(GeofenceTransition.EXITED, store.update(1, 9, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateIsIndependentPerWaypointAndNode() = runTest {
|
||||
val store = GeofenceCrossingStore()
|
||||
assertEquals(GeofenceTransition.BASELINE, store.update(1, 9, true))
|
||||
// Different node, same waypoint — its own baseline.
|
||||
assertEquals(GeofenceTransition.BASELINE, store.update(1, 10, true))
|
||||
// Different waypoint, same node — its own baseline.
|
||||
assertEquals(GeofenceTransition.BASELINE, store.update(2, 9, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainOnlyForgetsInactiveWaypoints() = runTest {
|
||||
val store = GeofenceCrossingStore()
|
||||
store.update(1, 9, true)
|
||||
store.update(2, 9, true)
|
||||
|
||||
store.retainOnly(setOf(2))
|
||||
|
||||
// Waypoint 1 was forgotten → re-baselines; waypoint 2 was kept.
|
||||
assertEquals(GeofenceTransition.BASELINE, store.update(1, 9, true))
|
||||
assertEquals(GeofenceTransition.UNCHANGED, store.update(2, 9, true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify.VerifyMode.Companion.exactly
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.MeshNotificationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Covers the crossing-decision logic deterministically via virtual time. The positive notification *dispatch* (R9) is
|
||||
* NOT asserted here: it is gated behind `getStringSuspend` (compose-resources), which does not resolve in the plain-JVM
|
||||
* test runner, so any test reaching it hangs. Coverage is instead structured around negatives that still drive the full
|
||||
* decision path right up to the notify entry point:
|
||||
* - [favoritesOnlySuppressesNonFavoriteCrossing] drives a real outside→inside ENTER and is stopped only by the gate,
|
||||
* proving the snapshot/worker/store/enter-branch all work.
|
||||
* - [enterOnlyWaypointDoesNotNotifyOnExit] proves the EXIT branch is gated by `notify_on_exit`. The actual dispatch
|
||||
* (contactKey/channel/deep-link) is verified by code review + manual run.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GeofenceMonitorTest {
|
||||
|
||||
private val sender = 9
|
||||
private val myNodeNum = 1
|
||||
|
||||
// Geofence centred at (10, 20), 1 km radius.
|
||||
private val centerLatI = 100_000_000
|
||||
private val centerLonI = 200_000_000
|
||||
private val inside = Position(latitude_i = centerLatI, longitude_i = centerLonI) // distance 0
|
||||
private val outside = Position(latitude_i = 110_000_000, longitude_i = centerLonI) // ~111 km north
|
||||
|
||||
private fun waypoint(enter: Boolean = true, exit: Boolean = false, favoritesOnly: Boolean = false) = Waypoint(
|
||||
id = 42,
|
||||
latitude_i = centerLatI,
|
||||
longitude_i = centerLonI,
|
||||
name = "Base",
|
||||
geofence_radius = 1000,
|
||||
notify_on_enter = enter,
|
||||
notify_on_exit = exit,
|
||||
notify_favorites_only = favoritesOnly,
|
||||
)
|
||||
|
||||
private fun mocks(
|
||||
wp: Waypoint,
|
||||
senderIsFavorite: Boolean = false,
|
||||
): Triple<PacketRepository, NodeManager, MeshNotificationManager> {
|
||||
val packetRepository: PacketRepository = mock(MockMode.autofill)
|
||||
val nodeManager: NodeManager = mock(MockMode.autofill)
|
||||
val notifications: MeshNotificationManager = mock(MockMode.autofill)
|
||||
every { packetRepository.getWaypoints() } returns
|
||||
flowOf(listOf(DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp)))
|
||||
every { nodeManager.nodeDBbyNodeNum } returns mapOf(sender to Node(num = sender, isFavorite = senderIsFavorite))
|
||||
return Triple(packetRepository, nodeManager, notifications)
|
||||
}
|
||||
|
||||
private fun expectNoNotification(
|
||||
wp: Waypoint,
|
||||
senderIsFavorite: Boolean = false,
|
||||
drive: (GeofenceMonitor) -> Unit,
|
||||
) = runTest {
|
||||
val scope = TestScope(StandardTestDispatcher(testScheduler))
|
||||
val (repo, nodes, notifications) = mocks(wp, senderIsFavorite)
|
||||
val monitor = GeofenceMonitor(lazy { repo }, nodes, notifications, GeofenceCrossingStore(), scope)
|
||||
scope.advanceUntilIdle() // collect the active-geofence snapshot
|
||||
|
||||
drive(monitor)
|
||||
scope.advanceUntilIdle()
|
||||
|
||||
verifySuspend(exactly(0)) { notifications.updateWaypointNotification(any(), any(), any(), any(), any()) }
|
||||
scope.cancel() // stop the serial worker so runTest sees no leaked coroutine
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstSightingInsideDoesNotNotify() =
|
||||
expectNoNotification(waypoint()) { m -> m.onPositionReceived(sender, myNodeNum, inside) }
|
||||
|
||||
@Test
|
||||
fun favoritesOnlySuppressesNonFavoriteCrossing() =
|
||||
expectNoNotification(waypoint(favoritesOnly = true), senderIsFavorite = false) { m ->
|
||||
m.onPositionReceived(sender, myNodeNum, outside) // baseline
|
||||
m.onPositionReceived(sender, myNodeNum, inside) // genuine ENTER, but sender is not a favorite
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownPositionIsNeverEvaluated() = expectNoNotification(waypoint()) { m ->
|
||||
m.onPositionReceived(myNodeNum, myNodeNum, inside) // sender == self
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enterOnlyWaypointDoesNotNotifyOnExit() = expectNoNotification(waypoint(enter = true, exit = false)) { m ->
|
||||
m.onPositionReceived(sender, myNodeNum, inside) // baseline inside
|
||||
m.onPositionReceived(sender, myNodeNum, outside) // EXIT, but notify_on_exit is false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
|
||||
/**
|
||||
* Collapse a raw waypoint-packet list into the set of currently active waypoints, keyed by waypoint id.
|
||||
*
|
||||
* `PacketRepository.getWaypoints()` is a row-PER-TRANSMISSION firehose: every re-broadcast or edit inserts a new
|
||||
* `packet` row (keyed on the random MeshPacket transmission id, not the semantic waypoint id). Consumers must normalise
|
||||
* — latest transmission wins, expired waypoints dropped — or they will see duplicate/stale geofences and keep alerting
|
||||
* on waypoints the user can no longer see. Both the map UI and the geofence engine go through here so they cannot
|
||||
* drift. Rows are ordered oldest-first, so `associateBy` keeps the newest copy per id.
|
||||
*/
|
||||
fun List<DataPacket>.activeWaypointPackets(nowSeconds: Long): Map<Int, DataPacket> = filter { it.waypoint != null }
|
||||
.associateBy { it.waypoint!!.id }
|
||||
.filterValues {
|
||||
val expire = it.waypoint?.expire ?: 0
|
||||
expire == 0 || expire.toLong() > nowSeconds
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
/** A circular geofence centered on [centerLat]/[centerLon] (decimal degrees) with [radiusMeters]. */
|
||||
data class GeofenceCircle(val centerLat: Double, val centerLon: Double, val radiusMeters: Int) {
|
||||
/** True when ([lat], [lon]) is within [radiusMeters] great-circle metres of the centre. */
|
||||
fun contains(lat: Double, lon: Double): Boolean = latLongToMeter(lat, lon, centerLat, centerLon) <= radiusMeters
|
||||
}
|
||||
|
||||
/** An axis-aligned WSEN bounding box in decimal degrees. Bounds are inclusive (matches the Apple reference). */
|
||||
data class GeofenceBox(val south: Double, val west: Double, val north: Double, val east: Double) {
|
||||
fun contains(lat: Double, lon: Double): Boolean = lat in south..north && lon in west..east
|
||||
}
|
||||
|
||||
/**
|
||||
* A waypoint's geofence region: a [circle] and/or a [box]. A point is inside if it is in EITHER shape (OR semantics) —
|
||||
* both may be set, either may be null.
|
||||
*/
|
||||
data class Geofence(val circle: GeofenceCircle?, val box: GeofenceBox?) {
|
||||
fun contains(lat: Double, lon: Double): Boolean =
|
||||
(circle?.contains(lat, lon) == true) || (box?.contains(lat, lon) == true)
|
||||
}
|
||||
|
||||
/** True when this waypoint asks receivers to raise crossing notifications. */
|
||||
val Waypoint.notifiesOnCrossing: Boolean
|
||||
get() = notify_on_enter || notify_on_exit
|
||||
|
||||
/**
|
||||
* Decode this waypoint's geofence region, or null when it defines neither a circle nor a box. This is the single source
|
||||
* of the proto `×1e-7 → decimal-degree` conversion, shared by the alert engine, the map overlays, and the editor — keep
|
||||
* all geofence coordinate decoding here so the three consumers cannot drift.
|
||||
*/
|
||||
fun Waypoint.toGeofence(): Geofence? {
|
||||
val circle =
|
||||
if (geofence_radius > 0) {
|
||||
GeofenceCircle((latitude_i ?: 0) * DEG_D, (longitude_i ?: 0) * DEG_D, geofence_radius)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val box =
|
||||
bounding_box?.let {
|
||||
val southDeg = it.latitude_south_i * DEG_D
|
||||
val northDeg = it.latitude_north_i * DEG_D
|
||||
val westDeg = it.longitude_west_i * DEG_D
|
||||
val eastDeg = it.longitude_east_i * DEG_D
|
||||
// Normalize transposed corners so an inverted box (south>north / west>east) from another client still
|
||||
// describes the intended rectangle. (Antimeridian-crossing boxes remain a documented non-goal.)
|
||||
GeofenceBox(
|
||||
south = minOf(southDeg, northDeg),
|
||||
west = minOf(westDeg, eastDeg),
|
||||
north = maxOf(southDeg, northDeg),
|
||||
east = maxOf(westDeg, eastDeg),
|
||||
)
|
||||
}
|
||||
return if (circle == null && box == null) null else Geofence(circle, box)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Radius presets for the waypoint editor. The wire value is ALWAYS metres; the UI renders each value locale-aware via
|
||||
* `DistanceExtensions`. `0` means "Off" (no circle). Imperial values are chosen to render as clean feet/mile figures.
|
||||
* Do NOT copy Apple's imperial-only table — the selector list is locale-driven by [forUnits].
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
object GeofenceRadiusPresets {
|
||||
/** Off, 100 m, 250 m, 500 m, 1 km, 2 km, 5 km. */
|
||||
val METRIC_METERS: List<Int> = listOf(0, 100, 250, 500, 1000, 2000, 5000)
|
||||
|
||||
/** Off, ~250 ft, ~500 ft, ~1000 ft, 1 mi, 2 mi, 5 mi (stored as metres). */
|
||||
val IMPERIAL_METERS: List<Int> = listOf(0, 76, 152, 305, 1609, 3219, 8047)
|
||||
|
||||
fun forUnits(units: DisplayUnits): List<Int> =
|
||||
if (units == DisplayUnits.IMPERIAL) IMPERIAL_METERS else METRIC_METERS
|
||||
|
||||
/** The preset (in the active unit system) closest to [meters] — used to highlight the current selection. */
|
||||
fun nearest(meters: Int, units: DisplayUnits): Int = forUnits(units).minBy { abs(it - meters) }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ActiveWaypointsTest {
|
||||
|
||||
private val now = 1000L
|
||||
|
||||
private fun packet(wp: Waypoint) = DataPacket(to = "!abcdabcd", channel = 0, waypoint = wp)
|
||||
|
||||
/** The P1 regression: re-broadcasts of the same waypoint are separate rows; latest transmission must win. */
|
||||
@Test
|
||||
fun latestTransmissionWinsPerWaypointId() {
|
||||
val old = packet(Waypoint(id = 42, geofence_radius = 100, expire = 0))
|
||||
val new = packet(Waypoint(id = 42, geofence_radius = 5000, expire = 0))
|
||||
|
||||
val active = listOf(old, new).activeWaypointPackets(now)
|
||||
|
||||
assertEquals(1, active.size)
|
||||
assertEquals(5000, active[42]?.waypoint?.geofence_radius)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredWaypointsAreDropped() {
|
||||
val expired = packet(Waypoint(id = 1, geofence_radius = 100, expire = 500)) // before now=1000
|
||||
val active = packet(Waypoint(id = 2, geofence_radius = 100, expire = 2000)) // after now
|
||||
val never = packet(Waypoint(id = 3, geofence_radius = 100, expire = 0)) // never expires
|
||||
|
||||
val result = listOf(expired, active, never).activeWaypointPackets(now)
|
||||
|
||||
assertFalse(result.containsKey(1))
|
||||
assertTrue(result.containsKey(2))
|
||||
assertTrue(result.containsKey(3))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class GeofenceRadiusPresetsTest {
|
||||
|
||||
@Test
|
||||
fun bothListsStartWithOff() {
|
||||
assertEquals(0, GeofenceRadiusPresets.METRIC_METERS.first())
|
||||
assertEquals(0, GeofenceRadiusPresets.IMPERIAL_METERS.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forUnitsSelectsBySystem() {
|
||||
assertEquals(GeofenceRadiusPresets.IMPERIAL_METERS, GeofenceRadiusPresets.forUnits(DisplayUnits.IMPERIAL))
|
||||
assertEquals(GeofenceRadiusPresets.METRIC_METERS, GeofenceRadiusPresets.forUnits(DisplayUnits.METRIC))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearestSnapsToClosestPreset() {
|
||||
assertEquals(0, GeofenceRadiusPresets.nearest(0, DisplayUnits.METRIC))
|
||||
assertEquals(100, GeofenceRadiusPresets.nearest(120, DisplayUnits.METRIC))
|
||||
assertEquals(2000, GeofenceRadiusPresets.nearest(2600, DisplayUnits.METRIC))
|
||||
assertEquals(5000, GeofenceRadiusPresets.nearest(99999, DisplayUnits.METRIC))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GeofenceTest {
|
||||
|
||||
// Circle centered at (10, 20) with a 1 km radius.
|
||||
private val circle = GeofenceCircle(centerLat = 10.0, centerLon = 20.0, radiusMeters = 1000)
|
||||
|
||||
// WSEN box: south=10, west=20, north=11, east=21.
|
||||
private val box = GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0)
|
||||
|
||||
@Test
|
||||
fun circleContainsCenterAndNearby() {
|
||||
assertTrue(circle.contains(10.0, 20.0)) // distance 0
|
||||
assertTrue(circle.contains(10.005, 20.0)) // ~555 m north, inside 1 km
|
||||
}
|
||||
|
||||
@Test
|
||||
fun circleExcludesFarPoint() {
|
||||
assertFalse(circle.contains(10.05, 20.0)) // ~5.5 km north, outside 1 km
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boxBoundsAreInclusive() {
|
||||
assertTrue(box.contains(10.0, 20.0)) // SW corner
|
||||
assertTrue(box.contains(11.0, 21.0)) // NE corner
|
||||
assertTrue(box.contains(10.5, 20.5)) // interior
|
||||
assertTrue(box.contains(10.0, 20.5)) // on the south edge
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boxExcludesOutsidePoints() {
|
||||
assertFalse(box.contains(9.999, 20.5)) // just south
|
||||
assertFalse(box.contains(11.001, 20.5)) // just north
|
||||
assertFalse(box.contains(10.5, 21.001)) // just east
|
||||
assertFalse(box.contains(10.5, 19.999)) // just west
|
||||
}
|
||||
|
||||
@Test
|
||||
fun geofenceUsesOrSemantics() {
|
||||
val circleOnly = Geofence(circle = circle, box = null)
|
||||
val boxOnly = Geofence(circle = null, box = box)
|
||||
val both = Geofence(circle = circle, box = box)
|
||||
val neither = Geofence(circle = null, box = null)
|
||||
|
||||
// A point inside the box but far from the circle counts via the box.
|
||||
assertTrue(boxOnly.contains(10.9, 20.9))
|
||||
assertTrue(both.contains(10.9, 20.9))
|
||||
assertFalse(circleOnly.contains(10.9, 20.9)) // ~140 km from circle center
|
||||
|
||||
// A point inside the circle but outside the box counts via the circle.
|
||||
assertTrue(circleOnly.contains(9.998, 20.0))
|
||||
assertTrue(both.contains(9.998, 20.0))
|
||||
assertFalse(boxOnly.contains(9.998, 20.0)) // south of the box
|
||||
|
||||
// Empty geofence never contains anything.
|
||||
assertFalse(neither.contains(10.0, 20.0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.geofence
|
||||
|
||||
import org.meshtastic.proto.BoundingBox
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WaypointToGeofenceTest {
|
||||
|
||||
private val box =
|
||||
BoundingBox(
|
||||
longitude_west_i = 200_000_000,
|
||||
latitude_south_i = 100_000_000,
|
||||
longitude_east_i = 210_000_000,
|
||||
latitude_north_i = 110_000_000,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun radiusOnlyDecodesCircleNoBox() {
|
||||
val wp = Waypoint(id = 1, latitude_i = 100_000_000, longitude_i = 200_000_000, geofence_radius = 500)
|
||||
val geofence = wp.toGeofence()
|
||||
assertNotNullCircleAt(geofence?.circle, 10.0, 20.0, 500)
|
||||
assertNull(geofence?.box)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boxOnlyDecodesBoxNoCircle() {
|
||||
val wp = Waypoint(id = 1, bounding_box = box)
|
||||
val geofence = wp.toGeofence()
|
||||
assertNull(geofence?.circle)
|
||||
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neitherShapeDecodesToNull() {
|
||||
assertNull(Waypoint(id = 1).toGeofence())
|
||||
assertNull(Waypoint(id = 1, geofence_radius = 0).toGeofence())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothShapesDecode() {
|
||||
val wp =
|
||||
Waypoint(
|
||||
id = 1,
|
||||
latitude_i = 100_000_000,
|
||||
longitude_i = 200_000_000,
|
||||
geofence_radius = 500,
|
||||
bounding_box = box,
|
||||
)
|
||||
val geofence = wp.toGeofence()
|
||||
assertNotNullCircleAt(geofence?.circle, 10.0, 20.0, 500)
|
||||
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invertedBoundingBoxIsNormalized() {
|
||||
// A box whose corners arrive transposed (south>north, west>east) should still describe the intended
|
||||
// rectangle after decode.
|
||||
val inverted =
|
||||
BoundingBox(
|
||||
longitude_west_i = 210_000_000, // 21 (east-most) given as west
|
||||
latitude_south_i = 110_000_000, // 11 (north-most) given as south
|
||||
longitude_east_i = 200_000_000, // 20
|
||||
latitude_north_i = 100_000_000, // 10
|
||||
)
|
||||
val geofence = Waypoint(id = 1, bounding_box = inverted).toGeofence()
|
||||
assertEquals(GeofenceBox(south = 10.0, west = 20.0, north = 11.0, east = 21.0), geofence?.box)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifiesOnCrossingTruthTable() {
|
||||
assertFalse(Waypoint(id = 1).notifiesOnCrossing)
|
||||
assertTrue(Waypoint(id = 1, notify_on_enter = true).notifiesOnCrossing)
|
||||
assertTrue(Waypoint(id = 1, notify_on_exit = true).notifiesOnCrossing)
|
||||
}
|
||||
|
||||
/** R2: geofence fields survive an unrelated edit and a proto encode/decode round-trip. */
|
||||
@Test
|
||||
fun geofenceFieldsSurviveEditAndRoundTrip() {
|
||||
val original =
|
||||
Waypoint(
|
||||
id = 7,
|
||||
latitude_i = 100_000_000,
|
||||
longitude_i = 200_000_000,
|
||||
name = "old",
|
||||
geofence_radius = 500,
|
||||
bounding_box = box,
|
||||
notify_on_enter = true,
|
||||
notify_on_exit = true,
|
||||
notify_favorites_only = true,
|
||||
)
|
||||
val edited = original.copy(name = "new")
|
||||
assertEquals(500, edited.geofence_radius)
|
||||
assertEquals(box, edited.bounding_box)
|
||||
assertTrue(edited.notify_on_enter)
|
||||
assertTrue(edited.notify_on_exit)
|
||||
assertTrue(edited.notify_favorites_only)
|
||||
|
||||
val roundTripped = Waypoint.ADAPTER.decode(Waypoint.ADAPTER.encode(edited))
|
||||
assertEquals(edited, roundTripped)
|
||||
}
|
||||
|
||||
private fun assertNotNullCircleAt(circle: GeofenceCircle?, lat: Double, lon: Double, radius: Int) {
|
||||
assertEquals(GeofenceCircle(centerLat = lat, centerLon = lon, radiusMeters = radius), circle)
|
||||
}
|
||||
}
|
||||
@@ -675,6 +675,24 @@
|
||||
<string name="generate_input_event_on_cw">Generate input event on CW</string>
|
||||
<string name="generate_input_event_on_press">Generate input event on Press</string>
|
||||
<string name="generate_qr_code">Generate QR Code</string>
|
||||
<!-- GEOFENCE -->
|
||||
<string name="geofence">Geofence</string>
|
||||
<string name="geofence_box_author_confirm">Confirm area</string>
|
||||
<string name="geofence_box_author_hint">Tap two corners to define the area</string>
|
||||
<string name="geofence_box_author_hint_viewport">Pan the map to frame the area, then tap Confirm area</string>
|
||||
<string name="geofence_box_use_view">Use current view</string>
|
||||
<string name="geofence_edit_area">Edit area</string>
|
||||
<string name="geofence_entered_body">%1$s entered %2$s</string>
|
||||
<string name="geofence_entered_title">Entered %1$s</string>
|
||||
<string name="geofence_favorites_only">Favorites only</string>
|
||||
<string name="geofence_left_body">%1$s left %2$s</string>
|
||||
<string name="geofence_left_title">Left %1$s</string>
|
||||
<string name="geofence_notify_on_enter">Notify on enter</string>
|
||||
<string name="geofence_notify_on_exit">Notify on exit</string>
|
||||
<string name="geofence_off">Off</string>
|
||||
<string name="geofence_radius">Geofence radius</string>
|
||||
<string name="geofence_remove_area">Remove area</string>
|
||||
<string name="geofence_set_area">Set area on map</string>
|
||||
<string name="get_started">Get started</string>
|
||||
<string name="good">Good</string>
|
||||
<!-- GPIO -->
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
package org.meshtastic.feature.map.component
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
@@ -24,20 +24,28 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -51,6 +59,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -65,12 +74,23 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.systemTimeZone
|
||||
import org.meshtastic.core.model.geofence.GeofenceRadiusPresets
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.date
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.description
|
||||
import org.meshtastic.core.resources.expires
|
||||
import org.meshtastic.core.resources.geofence
|
||||
import org.meshtastic.core.resources.geofence_edit_area
|
||||
import org.meshtastic.core.resources.geofence_favorites_only
|
||||
import org.meshtastic.core.resources.geofence_notify_on_enter
|
||||
import org.meshtastic.core.resources.geofence_notify_on_exit
|
||||
import org.meshtastic.core.resources.geofence_off
|
||||
import org.meshtastic.core.resources.geofence_radius
|
||||
import org.meshtastic.core.resources.geofence_remove_area
|
||||
import org.meshtastic.core.resources.geofence_set_area
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.send
|
||||
@@ -81,18 +101,27 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.icon.CalendarMonth
|
||||
import org.meshtastic.core.ui.icon.Lock
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/**
|
||||
* Shared waypoint editor used by both the google and fdroid map flavors (DRY — replaces the two drifted per-flavor
|
||||
* copies). Map-engine-specific concerns stay outside: drawing the box overlay and the drag-to-define gesture are
|
||||
* triggered via [onBeginBoxAuthoring], which hands the current draft back to the flavor's map so the user can define
|
||||
* the bounding box there; the flavor re-opens this dialog with the drawn box applied.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
displayUnits: DisplayUnits,
|
||||
onSend: (Waypoint) -> Unit,
|
||||
onDelete: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBeginBoxAuthoring: (Waypoint) -> Unit = {},
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
|
||||
@@ -103,7 +132,6 @@ fun EditWaypointDialog(
|
||||
val context = LocalContext.current
|
||||
val tz = systemTimeZone
|
||||
|
||||
// Initialize date and time states from waypointInput.expire
|
||||
var selectedDateString by remember { mutableStateOf("") }
|
||||
var selectedTimeString by remember { mutableStateOf("") }
|
||||
var isExpiryEnabled by remember {
|
||||
@@ -123,7 +151,7 @@ fun EditWaypointDialog(
|
||||
val date = java.util.Date(instant.toEpochMilliseconds())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
selectedTimeString = timeFormat.format(date)
|
||||
} else { // If enabled but not set, default to 8 hours from now
|
||||
} else {
|
||||
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
|
||||
val date = java.util.Date(futureInstant.toEpochMilliseconds())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
@@ -148,7 +176,7 @@ fun EditWaypointDialog(
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(
|
||||
value = waypointInput.name,
|
||||
onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) },
|
||||
@@ -220,7 +248,6 @@ fun EditWaypointDialog(
|
||||
isExpiryEnabled = checked
|
||||
if (checked) {
|
||||
val expireValue = waypointInput.expire
|
||||
// Default to 8 hours from now if not already set
|
||||
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
|
||||
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
|
||||
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
|
||||
@@ -336,6 +363,16 @@ fun EditWaypointDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
GeofenceSection(
|
||||
waypoint = waypointInput,
|
||||
displayUnits = displayUnits,
|
||||
onWaypointChange = { waypointInput = it },
|
||||
onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -344,23 +381,20 @@ fun EditWaypointDialog(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (waypoint.id != 0) {
|
||||
TextButton(
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
TextButton(onClick = { onDelete(waypointInput) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
Button(onClick = { onSendClicked(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
|
||||
Button(onClick = { onSend(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null, // Using custom buttons in confirmButton Row
|
||||
dismissButton = null,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
@@ -370,3 +404,126 @@ fun EditWaypointDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Geofence authoring controls. Radius presets are locale-aware (wire value is always metres); the enter/exit toggles
|
||||
* appear only once a circle or box exists, and the favorites-only toggle only once enter or exit is on.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GeofenceSection(
|
||||
waypoint: Waypoint,
|
||||
displayUnits: DisplayUnits,
|
||||
onWaypointChange: (Waypoint) -> Unit,
|
||||
onBeginBoxAuthoring: () -> Unit,
|
||||
) {
|
||||
val presets = remember(displayUnits) { GeofenceRadiusPresets.forUnits(displayUnits) }
|
||||
val hasBox = waypoint.bounding_box != null
|
||||
val hasRegion = waypoint.geofence_radius > 0 || hasBox
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(Res.string.geofence),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(stringResource(Res.string.geofence_radius), style = MaterialTheme.typography.bodyMedium)
|
||||
// Highlight the closest preset so a radius authored on another client (e.g. an imperial/non-preset value)
|
||||
// is still shown as selected rather than appearing as "no selection" and being silently clobbered on edit.
|
||||
val selectedRadius = GeofenceRadiusPresets.nearest(waypoint.geofence_radius, displayUnits)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
presets.forEach { meters ->
|
||||
val label =
|
||||
if (meters == 0) {
|
||||
stringResource(Res.string.geofence_off)
|
||||
} else {
|
||||
meters.toDistanceString(displayUnits)
|
||||
}
|
||||
FilterChip(
|
||||
selected = meters == selectedRadius,
|
||||
onClick = {
|
||||
onWaypointChange(waypoint.copy(geofence_radius = meters).normalizeGeofenceNotifications())
|
||||
},
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBeginBoxAuthoring) {
|
||||
Text(stringResource(if (hasBox) Res.string.geofence_edit_area else Res.string.geofence_set_area))
|
||||
}
|
||||
if (hasBox) {
|
||||
TextButton(
|
||||
onClick = { onWaypointChange(waypoint.copy(bounding_box = null).normalizeGeofenceNotifications()) },
|
||||
) {
|
||||
Text(stringResource(Res.string.geofence_remove_area))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRegion) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
GeofenceNotificationControls(waypoint = waypoint, onWaypointChange = onWaypointChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter/exit toggles plus the favorites-only toggle (visible only once enter or exit is on). */
|
||||
@Composable
|
||||
private fun GeofenceNotificationControls(waypoint: Waypoint, onWaypointChange: (Waypoint) -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
GeofenceToggleRow(
|
||||
label = stringResource(Res.string.geofence_notify_on_enter),
|
||||
checked = waypoint.notify_on_enter,
|
||||
onCheckedChange = {
|
||||
onWaypointChange(waypoint.copy(notify_on_enter = it).normalizeGeofenceNotifications())
|
||||
},
|
||||
)
|
||||
GeofenceToggleRow(
|
||||
label = stringResource(Res.string.geofence_notify_on_exit),
|
||||
checked = waypoint.notify_on_exit,
|
||||
onCheckedChange = { onWaypointChange(waypoint.copy(notify_on_exit = it).normalizeGeofenceNotifications()) },
|
||||
)
|
||||
if (waypoint.notify_on_enter || waypoint.notify_on_exit) {
|
||||
GeofenceToggleRow(
|
||||
label = stringResource(Res.string.geofence_favorites_only),
|
||||
checked = waypoint.notify_favorites_only,
|
||||
onCheckedChange = { onWaypointChange(waypoint.copy(notify_favorites_only = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GeofenceToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
// The whole row is one toggleable target so the label names the switch to a screen reader and the
|
||||
// tap target spans the row, not just the thumb.
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label)
|
||||
Switch(checked = checked, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears geofence notification flags the UI can no longer expose, so stale values don't silently reappear when a region
|
||||
* is added again: drop all three when no region remains, and drop favorites-only when neither enter nor exit is on.
|
||||
*/
|
||||
private fun Waypoint.normalizeGeofenceNotifications(): Waypoint = when {
|
||||
geofence_radius <= 0 && bounding_box == null ->
|
||||
copy(notify_on_enter = false, notify_on_exit = false, notify_favorites_only = false)
|
||||
|
||||
!notify_on_enter && !notify_on_exit -> copy(notify_favorites_only = false)
|
||||
|
||||
else -> this
|
||||
}
|
||||
@@ -31,9 +31,11 @@ import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import org.meshtastic.core.model.geofence.activeWaypointPackets
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.any
|
||||
@@ -43,6 +45,7 @@ import org.meshtastic.core.resources.one_hour
|
||||
import org.meshtastic.core.resources.two_days
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
@@ -58,10 +61,17 @@ open class BaseMapViewModel(
|
||||
protected val nodeRepository: NodeRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val radioController: RadioController,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val myNodeInfo = nodeRepository.myNodeInfo
|
||||
|
||||
/** Device display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. */
|
||||
val displayUnits: StateFlow<DisplayUnits> =
|
||||
radioConfigRepository.localConfigFlow
|
||||
.map { it.display?.units ?: DisplayUnits.METRIC }
|
||||
.stateInWhileSubscribed(initialValue = DisplayUnits.METRIC)
|
||||
|
||||
val ourNodeInfo = nodeRepository.ourNodeInfo
|
||||
|
||||
val myNodeNum
|
||||
@@ -88,15 +98,9 @@ open class BaseMapViewModel(
|
||||
val waypoints: StateFlow<Map<Int, DataPacket>> =
|
||||
packetRepository
|
||||
.getWaypoints()
|
||||
.mapLatest { list ->
|
||||
list
|
||||
.filter { it.waypoint != null }
|
||||
.associateBy { packet -> packet.waypoint!!.id }
|
||||
.filterValues {
|
||||
val expire = it.waypoint?.expire ?: 0
|
||||
expire == 0 || expire.toLong() > nowSeconds
|
||||
}
|
||||
}
|
||||
// Shared with GeofenceMonitor via activeWaypointPackets — dedup by waypoint id + drop expired,
|
||||
// so the map and the geofence engine can't drift (getWaypoints is a row-per-transmission firehose).
|
||||
.mapLatest { list -> list.activeWaypointPackets(nowSeconds) }
|
||||
.stateInWhileSubscribed(initialValue = emptyMap())
|
||||
|
||||
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioConfigRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import org.meshtastic.proto.Waypoint
|
||||
@@ -50,6 +51,7 @@ class BaseMapViewModelTest {
|
||||
private lateinit var viewModel: BaseMapViewModel
|
||||
private lateinit var nodeRepository: FakeNodeRepository
|
||||
private lateinit var radioController: FakeRadioController
|
||||
private lateinit var radioConfigRepository: FakeRadioConfigRepository
|
||||
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
|
||||
private val mapPrefs: MapPrefs = mock()
|
||||
private val packetRepository: PacketRepository = mock()
|
||||
@@ -59,6 +61,7 @@ class BaseMapViewModelTest {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
nodeRepository = FakeNodeRepository()
|
||||
radioController = FakeRadioController()
|
||||
radioConfigRepository = FakeRadioConfigRepository()
|
||||
radioController.setConnectionState(ConnectionState.Disconnected)
|
||||
|
||||
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
|
||||
@@ -76,6 +79,7 @@ class BaseMapViewModelTest {
|
||||
nodeRepository = nodeRepository,
|
||||
packetRepository = packetRepository,
|
||||
radioController = radioController,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user