From 536b1eba1c1c3033d35b9fc44503c214bd4b51b9 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 14 Apr 2026 16:24:29 -0500 Subject: [PATCH] feat(map): replace Google Maps + OSMDroid with unified Mapbox SDK in feature:map/androidMain Replace the dual flavor-specific map implementations (Google Maps in app/src/google, OSMDroid in app/src/fdroid) with a single Mapbox Maps Compose SDK (v11.21.1) implementation living in feature:map/androidMain. - Add Mapbox Maven repo with MAPBOX_DOWNLOADS_TOKEN auth to settings.gradle.kts - Add mapbox-maps-android, mapbox-maps-compose deps to feature/map/build.gradle.kts - Remove Google Maps, osmdroid, osmbonuspack deps from app/build.gradle.kts and catalog - Create unified MapScreen, MapViewModel, MapboxMapContent, GeoJsonConverters, EditWaypointDialog, InlineMap, NodeTrackMap, NodeMapScreen, TracerouteMap - Wire all Local*Provider CompositionLocals in MainActivity to new implementations - Delete ~8200 lines of flavor-specific map code across google/fdroid source sets - Delete dead MapViewProvider interface from core:ui - Keep LocalMapMainScreenProvider for KMP/Desktop compatibility boundary - Fix FlavorModule.kt, KoinVerificationTest.kt for deleted modules - Pass spotlessCheck + detekt with zero violations --- app/build.gradle.kts | 15 +- .../app/map/cluster/MarkerClusterer.java | 216 ---- .../map/cluster/RadiusMarkerClusterer.java | 213 ---- .../app/map/cluster/StaticCluster.java | 85 -- .../app/map/FdroidMapViewProvider.kt | 39 - .../kotlin/org/meshtastic/app/map/MapUtils.kt | 80 -- .../kotlin/org/meshtastic/app/map/MapView.kt | 968 -------------- .../meshtastic/app/map/MapViewExtensions.kt | 145 --- .../org/meshtastic/app/map/MapViewModel.kt | 67 - .../app/map/MapViewWithLifecycle.kt | 136 -- .../meshtastic/app/map/SqlTileWriterExt.kt | 111 -- .../app/map/component/CacheLayout.kt | 94 -- .../app/map/component/DownloadButton.kt | 65 - .../app/map/component/EditWaypointDialog.kt | 357 ------ .../app/map/model/CustomTileSource.kt | 208 --- .../app/map/model/MarkerWithLabel.kt | 138 -- .../app/map/model/NOAAWmsTileSource.kt | 160 --- .../app/map/model/OnlineTileSourceAuth.kt | 65 - .../meshtastic/app/map/node/NodeTrackMap.kt | 50 - .../app/map/node/NodeTrackOsmMap.kt | 162 --- .../app/map/traceroute/TracerouteMap.kt | 41 - .../app/map/traceroute/TracerouteOsmMap.kt | 288 ----- .../app/node/component/InlineMap.kt | 64 - .../metrics/TracerouteMapOverlayInsets.kt | 28 - app/src/google/AndroidManifest.xml | 8 +- .../org/meshtastic/app/di/FlavorModule.kt | 3 +- .../meshtastic/app/map/GetMapViewProvider.kt | 21 - .../app/map/GoogleMapViewProvider.kt | 39 - .../org/meshtastic/app/map/LocationHandler.kt | 139 -- .../org/meshtastic/app/map/MBTilesProvider.kt | 65 - .../kotlin/org/meshtastic/app/map/MapView.kt | 1125 ----------------- .../org/meshtastic/app/map/MapViewModel.kt | 688 ---------- .../map/component/ClusterItemsListDialog.kt | 76 -- .../app/map/component/CustomMapLayersSheet.kt | 216 ---- .../CustomTileProviderManagerSheet.kt | 324 ----- .../app/map/component/MapFilterDropdown.kt | 160 --- .../app/map/component/MapTypeDropdown.kt | 114 -- .../app/map/component/NodeClusterMarkers.kt | 96 -- .../app/map/component/PulsingNodeChip.kt | 68 - .../app/map/component/WaypointMarkers.kt | 92 -- .../app/map/model/CustomTileProviderConfig.kt | 31 - .../app/map/model/CustomTileSource.kt | 26 - .../app/map/model/NodeClusterItem.kt | 58 - .../meshtastic/app/map/node/NodeMapScreen.kt | 54 - .../meshtastic/app/map/node/NodeTrackMap.kt | 58 - .../app/map/prefs/di/GoogleMapsKoinModule.kt | 45 - .../app/map/prefs/map/GoogleMapsPrefs.kt | 196 --- .../CustomTileProviderRepository.kt | 104 -- .../app/map/traceroute/TracerouteMap.kt | 46 - .../app/node/component/InlineMap.kt | 85 -- .../metrics/TracerouteMapOverlayInsets.kt | 28 - .../kotlin/org/meshtastic/app/MainActivity.kt | 27 +- .../meshtastic/app/di/KoinVerificationTest.kt | 2 +- core/proto/src/main/proto | 2 +- .../ui/util/LocalMapMainScreenProvider.kt | 6 +- .../ui/util/LocalTracerouteMapProvider.kt | 2 +- .../core/ui/util/MapViewProvider.kt | 31 - feature/map/build.gradle.kts | 5 + .../feature/map/GeoJsonConverters.kt | 136 ++ .../org/meshtastic/feature/map/MapScreen.kt | 229 +++- .../meshtastic/feature/map/MapViewModel.kt | 181 +++ .../feature/map/MapboxMapContent.kt | 229 ++++ .../map/component/EditWaypointDialog.kt | 2 +- .../meshtastic/feature/map/model/MapStyle.kt | 18 +- .../meshtastic/feature/map/node/InlineMap.kt | 103 ++ .../feature}/map/node/NodeMapScreen.kt | 42 +- .../feature/map/node/NodeTrackMap.kt | 87 ++ .../feature/map/traceroute/TracerouteMap.kt | 108 ++ .../feature/map/navigation/MapNavigation.kt | 5 +- gradle/libs.versions.toml | 15 +- secrets.defaults.properties | 6 +- settings.gradle.kts | 13 + 72 files changed, 1170 insertions(+), 7839 deletions(-) delete mode 100644 app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java delete mode 100644 app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java delete mode 100644 app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt delete mode 100644 app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/MapView.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt delete mode 100644 app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/GeoJsonConverters.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapboxMapContent.kt rename {app/src/google/kotlin/org/meshtastic/app => feature/map/src/androidMain/kotlin/org/meshtastic/feature}/map/component/EditWaypointDialog.kt (99%) rename app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt => feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt (59%) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/node/InlineMap.kt rename {app/src/fdroid/kotlin/org/meshtastic/app => feature/map/src/androidMain/kotlin/org/meshtastic/feature}/map/node/NodeMapScreen.kt (56%) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/node/NodeTrackMap.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/traceroute/TracerouteMap.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39e6bbcc7..8fc70a516 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,12 +156,7 @@ configure { // Configure existing product flavors (defined by convention plugin) // with their dynamic version names. productFlavors { - configureEach { - versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name" - if (name == "google") { - manifestPlaceholders["MAPS_API_KEY"] = "dummy" - } - } + configureEach { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name" } } buildTypes { @@ -277,10 +272,6 @@ dependencies { debugImplementation(libs.androidx.glance.preview) googleImplementation(libs.location.services) - googleImplementation(libs.play.services.maps) - googleImplementation(libs.maps.compose) - googleImplementation(libs.maps.compose.utils) - googleImplementation(libs.maps.compose.widgets) googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) @@ -293,10 +284,6 @@ dependencies { googleImplementation(libs.firebase.analytics) googleImplementation(libs.firebase.crashlytics) - fdroidImplementation(libs.osmdroid.android) - fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } - fdroidImplementation(libs.osmbonuspack) - testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java deleted file mode 100644 index 38e51da52..000000000 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Point; -import android.view.MotionEvent; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.util.BoundingBox; -import org.osmdroid.views.MapView; -import org.osmdroid.views.overlay.Overlay; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.ListIterator; - -/** - * An overlay allowing to perform markers clustering. - * Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays. - * Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker.
- * - * This abstract class provides the framework. Sub-classes have to implement the clustering algorithm, - * and the rendering of a cluster. - * - * @author M.Kergall - * - */ -public abstract class MarkerClusterer extends Overlay { - - /** impossible value for zoom level, to force clustering */ - protected static final int FORCE_CLUSTERING = -1; - - protected ArrayList mItems = new ArrayList(); - protected Point mPoint = new Point(); - protected ArrayList mClusters = new ArrayList(); - protected int mLastZoomLevel; - protected Bitmap mClusterIcon; - protected String mName, mDescription; - - // abstract methods: - - /** clustering algorithm */ - public abstract ArrayList clusterer(MapView mapView); - /** Build the marker for a cluster. */ - public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView); - /** build clusters markers to be used at next draw */ - public abstract void renderer(ArrayList clusters, Canvas canvas, MapView mapView); - - public MarkerClusterer() { - super(); - mLastZoomLevel = FORCE_CLUSTERING; - } - - public void setName(String name){ - mName = name; - } - - public String getName(){ - return mName; - } - - public void setDescription(String description){ - mDescription = description; - } - - public String getDescription(){ - return mDescription; - } - - /** Set the cluster icon to be drawn when a cluster contains more than 1 marker. - * If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */ - public void setIcon(Bitmap icon){ - mClusterIcon = icon; - } - - /** Add the Marker. - * Important: Markers added in a MarkerClusterer should not be added in the map overlays. */ - public void add(MarkerWithLabel marker){ - mItems.add(marker); - } - - /** Force a rebuild of clusters at next draw, even without a zooming action. - * Should be done when you changed the content of a MarkerClusterer. */ - public void invalidate(){ - mLastZoomLevel = FORCE_CLUSTERING; - } - - /** @return the Marker at id (starting at 0) */ - public MarkerWithLabel getItem(int id){ - return mItems.get(id); - } - - /** @return the list of Markers. */ - public ArrayList getItems(){ - return mItems; - } - - protected void hideInfoWindows(){ - for (MarkerWithLabel m : mItems){ - if (m.isInfoWindowShown()) - m.closeInfoWindow(); - } - } - - @Override public void draw(Canvas canvas, MapView mapView, boolean shadow) { - if (shadow) - return; - //if zoom has changed and mapView is now stable, rebuild clusters: - int zoomLevel = mapView.getZoomLevel(); - if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){ - hideInfoWindows(); - mClusters = clusterer(mapView); - renderer(mClusters, canvas, mapView); - mLastZoomLevel = zoomLevel; - } - - for (StaticCluster cluster:mClusters){ - MarkerWithLabel marker = cluster.getMarker(); - marker.draw(canvas, mapView, false); - } - } - - public Iterable reversedClusters() { - return new Iterable() { - @Override - public Iterator iterator() { - final ListIterator i = mClusters.listIterator(mClusters.size()); - return new Iterator() { - @Override - public boolean hasNext() { - return i.hasPrevious(); - } - - @Override - public StaticCluster next() { - return i.previous(); - } - - @Override - public void remove() { - i.remove(); - } - }; - } - }; - } - - @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onLongPress(final MotionEvent event, final MapView mapView) { - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onLongPress(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) { - for (StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onTouchEvent(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) { - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onDoubleTap(event, mapView)) - return true; - } - return false; - } - - @Override public BoundingBox getBounds(){ - if (mItems.size() == 0) - return null; - double minLat = Double.MAX_VALUE; - double minLon = Double.MAX_VALUE; - double maxLat = -Double.MAX_VALUE; - double maxLon = -Double.MAX_VALUE; - for (final MarkerWithLabel item : mItems) { - final double latitude = item.getPosition().getLatitude(); - final double longitude = item.getPosition().getLongitude(); - minLat = Math.min(minLat, latitude); - minLon = Math.min(minLon, longitude); - maxLat = Math.max(maxLat, latitude); - maxLon = Math.max(maxLon, longitude); - } - return new BoundingBox(maxLat, maxLon, minLat, minLon); - } - -} diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java deleted file mode 100644 index e2710352a..000000000 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.view.MotionEvent; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.bonuspack.R; -import org.osmdroid.util.BoundingBox; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.views.MapView; - -import java.util.ArrayList; -import java.util.Iterator; - -/** - * Radius-based Clustering algorithm: - * create a cluster using the first point from the cloned list. - * All points that are found within the neighborhood are added to this cluster. - * Then all the neighbors and the main point are removed from the list of points. - * It continues until the list is empty. - * - * Largely inspired from GridMarkerClusterer by M.Kergall - * - * @author sidorovroman92@gmail.com - */ - -public class RadiusMarkerClusterer extends MarkerClusterer { - - protected int mMaxClusteringZoomLevel = 7; - protected int mRadiusInPixels = 100; - protected double mRadiusInMeters; - protected Paint mTextPaint; - private ArrayList mClonedMarkers; - protected boolean mAnimated; - int mDensityDpi; - - /** cluster icon anchor */ - public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER; - /** anchor point to draw the number of markers inside the cluster icon */ - public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER; - - public RadiusMarkerClusterer(Context ctx) { - super(); - mTextPaint = new Paint(); - mTextPaint.setColor(Color.WHITE); - mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density); - mTextPaint.setFakeBoldText(true); - mTextPaint.setTextAlign(Paint.Align.CENTER); - mTextPaint.setAntiAlias(true); - Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster); - Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap(); - setIcon(clusterIcon); - mAnimated = true; - mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi; - } - - /** If you want to change the default text paint (color, size, font) */ - public Paint getTextPaint(){ - return mTextPaint; - } - - /** Set the radius of clustering in pixels. Default is 100px. */ - public void setRadius(int radius){ - mRadiusInPixels = radius; - } - - /** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled. - * You can put a high value to disable this feature. */ - public void setMaxClusteringZoomLevel(int zoom){ - mMaxClusteringZoomLevel = zoom; - } - - /** Radius-Based clustering algorithm */ - @Override public ArrayList clusterer(MapView mapView) { - - ArrayList clusters = new ArrayList(); - convertRadiusToMeters(mapView); - - mClonedMarkers = new ArrayList(mItems); //shallow copy - while (!mClonedMarkers.isEmpty()) { - MarkerWithLabel m = mClonedMarkers.get(0); - StaticCluster cluster = createCluster(m, mapView); - clusters.add(cluster); - } - return clusters; - } - - private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) { - GeoPoint clusterPosition = m.getPosition(); - - StaticCluster cluster = new StaticCluster(clusterPosition); - cluster.add(m); - - mClonedMarkers.remove(m); - - if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) { - //above max level => block clustering: - return cluster; - } - - Iterator it = mClonedMarkers.iterator(); - while (it.hasNext()) { - MarkerWithLabel neighbor = it.next(); - double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition()); - if (distance <= mRadiusInMeters) { - cluster.add(neighbor); - it.remove(); - } - } - - return cluster; - } - - @Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) { - MarkerWithLabel m = new MarkerWithLabel(mapView, "", null); - m.setPosition(cluster.getPosition()); - m.setInfoWindow(null); - m.setAnchor(mAnchorU, mAnchorV); - - Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi), - mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig()); - Canvas iconCanvas = new Canvas(finalIcon); - iconCanvas.drawBitmap(mClusterIcon, 0, 0, null); - String text = "" + cluster.getSize(); - int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent()); - iconCanvas.drawText(text, - mTextAnchorU * finalIcon.getWidth(), - mTextAnchorV * finalIcon.getHeight() - textHeight / 2, - mTextPaint); - m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon)); - - return m; - } - - @Override public void renderer(ArrayList clusters, Canvas canvas, MapView mapView) { - for (StaticCluster cluster : clusters) { - if (cluster.getSize() == 1) { - //cluster has only 1 marker => use it as it is: - cluster.setMarker(cluster.getItem(0)); - } else { - //only draw 1 Marker at Cluster center, displaying number of Markers contained - MarkerWithLabel m = buildClusterMarker(cluster, mapView); - cluster.setMarker(m); - } - } - } - - private void convertRadiusToMeters(MapView mapView) { - - Rect mScreenRect = mapView.getIntrinsicScreenRect(null); - - int screenWidth = mScreenRect.right - mScreenRect.left; - int screenHeight = mScreenRect.bottom - mScreenRect.top; - - BoundingBox bb = mapView.getBoundingBox(); - - double diagonalInMeters = bb.getDiagonalLengthInMeters(); - double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight); - double metersInPixel = diagonalInMeters / diagonalInPixels; - - mRadiusInMeters = mRadiusInPixels * metersInPixel; - } - - public void setAnimation(boolean animate){ - mAnimated = animate; - } - - public void zoomOnCluster(MapView mapView, StaticCluster cluster){ - BoundingBox bb = cluster.getBoundingBox(); - if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) { - bb = bb.increaseByScale(2.3f); - mapView.zoomToBoundingBox(bb, true); - } else //all points exactly at the same place: - mapView.setExpectedCenter(bb.getCenterWithDateLine()); - } - - @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) { - if (mAnimated && cluster.getSize() > 1) - zoomOnCluster(mapView, cluster); - return true; - } - } - return false; - } - -} diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java b/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java deleted file mode 100644 index 324a34b52..000000000 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.util.BoundingBox; -import org.osmdroid.util.GeoPoint; - -import java.util.ArrayList; - -/** - * Cluster of Markers. - * @author M.Kergall - */ -public class StaticCluster { - protected final ArrayList mItems = new ArrayList(); - protected GeoPoint mCenter; - protected MarkerWithLabel mMarker; - - public StaticCluster(GeoPoint center) { - mCenter = center; - } - - public void setPosition(GeoPoint center){ - mCenter = center; - } - - public GeoPoint getPosition() { - return mCenter; - } - - public int getSize() { - return mItems.size(); - } - - public MarkerWithLabel getItem(int index) { - return mItems.get(index); - } - - public boolean add(MarkerWithLabel t) { - return mItems.add(t); - } - - /** set the Marker to be displayed for this cluster */ - public void setMarker(MarkerWithLabel marker){ - mMarker = marker; - } - - /** @return the Marker to be displayed for this cluster */ - public MarkerWithLabel getMarker(){ - return mMarker; - } - - public BoundingBox getBoundingBox(){ - if (getSize()==0) - return null; - GeoPoint p = getItem(0).getPosition(); - BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude()); - for (int i=1; i. - */ -package org.meshtastic.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.Single -import org.meshtastic.core.ui.util.MapViewProvider - -/** OSMDroid implementation of [MapViewProvider]. */ -@Single -class FdroidMapViewProvider : MapViewProvider { - @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { - val mapViewModel: MapViewModel = koinViewModel() - LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - org.meshtastic.app.map.MapView( - modifier = modifier, - mapViewModel = mapViewModel, - navigateToNodeDetails = navigateToNodeDetails, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt deleted file mode 100644 index 1243fdc8a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.content.Context -import android.util.TypedValue -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.log2 -import kotlin.math.pow - -private const val DEGREES_IN_CIRCLE = 360.0 -private const val METERS_PER_DEGREE_LATITUDE = 111320.0 -private const val ZOOM_ADJUSTMENT_FACTOR = 0.8 - -/** - * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. - * - * @return The zoom level as a Double value. - */ -fun BoundingBox.requiredZoomLevel(): Double { - val topLeft = GeoPoint(this.latNorth, this.lonWest) - val bottomRight = GeoPoint(this.latSouth, this.lonEast) - val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) - val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) - val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE)) - val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE)) - return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR -} - -/** - * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. - * - * @return A new [BoundingBox] with added [zoomFactor]. Example: - * ``` - * // Setting the zoom level directly using setZoom() - * map.setZoom(14.0) - * val boundingBoxZoom14 = map.boundingBox - * - * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) - * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) - * ``` - */ -fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { - val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) - val latDiff = latNorth - latSouth - val lonDiff = lonEast - lonWest - - val newLatDiff = latDiff / (2.0.pow(zoomFactor)) - val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) - - return BoundingBox( - center.latitude + newLatDiff / 2, - center.longitude + newLonDiff / 2, - center.latitude - newLatDiff / 2, - center.longitude - newLonDiff / 2, - ) -} - -// Converts SP to pixels. -fun Context.spToPx(sp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt() - -// Converts DP to pixels. -fun Context.dpToPx(dp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt() diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index 657f7ab74..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,968 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.Manifest -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.foundation.background -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -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 -import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.calculating -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.clear -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.getString -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.core.resources.location_disabled -import org.meshtastic.core.resources.map_cache_info -import org.meshtastic.core.resources.map_cache_manager -import org.meshtastic.core.resources.map_cache_size -import org.meshtastic.core.resources.map_cache_tiles -import org.meshtastic.core.resources.map_clear_tiles -import org.meshtastic.core.resources.map_download_complete -import org.meshtastic.core.resources.map_download_errors -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.resources.map_node_popup_details -import org.meshtastic.core.resources.map_offline_manager -import org.meshtastic.core.resources.map_purge_fail -import org.meshtastic.core.resources.map_purge_success -import org.meshtastic.core.resources.map_style_selection -import org.meshtastic.core.resources.map_subDescription -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.waypoint_delete -import org.meshtastic.core.resources.you -import org.meshtastic.core.ui.component.BasicListItem -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.core.ui.util.formatAgo -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.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Waypoint -import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable -import org.osmdroid.config.Configuration -import org.osmdroid.events.MapEventsReceiver -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.cachemanager.CacheManager -import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.MapEventsOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon -import org.osmdroid.views.overlay.infowindow.InfoWindow -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay -import java.io.File -import kotlin.math.roundToInt - -private fun MapView.updateMarkers( - nodeMarkers: List, - waypointMarkers: List, - nodeClusterer: RadiusMarkerClusterer, -) { - Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } - - overlays.removeAll { overlay -> - overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) - } - - overlays.addAll(waypointMarkers) - - nodeClusterer.items.clear() - nodeClusterer.items.addAll(nodeMarkers) - nodeClusterer.invalidate() -} - -private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) = - object : CacheManager.CacheManagerCallback { - override fun onTaskComplete() { - onTaskComplete() - } - - override fun onTaskFailed(errors: Int) { - onTaskFailed(errors) - } - - override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) { - // NOOP since we are using the build in UI - } - - override fun downloadStarted() { - // NOOP since we are using the build in UI - } - - override fun setPossibleTilesInArea(total: Int) { - // NOOP since we are using the build in UI - } - } - -/** - * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user - * interactions for map manipulation, filtering, and offline caching. - * - * @param mapViewModel The [MapViewModel] providing data and state for the map. - * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. - */ -@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongMethod") -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, -) { - var mapFilterExpanded by remember { mutableStateOf(false) } - - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - - var cacheEstimate by remember { mutableStateOf("") } - - var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } - var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } - - var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } - var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } - - var showDownloadButton: Boolean by remember { mutableStateOf(false) } - var showEditWaypointDialog by remember { mutableStateOf(null) } - var showCacheManagerDialog by remember { mutableStateOf(false) } - var showCurrentCacheInfo by remember { mutableStateOf(false) } - var showPurgeTileSourceDialog by remember { mutableStateOf(false) } - var showMapStyleDialog by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - val density = LocalDensity.current - - val haptic = LocalHapticFeedback.current - fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - // Accompanist permissions state for location - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - fun loadOnlineTileSourceBase(): ITileSource { - val id = mapViewModel.mapStyleId - Logger.d { "mapStyleId from prefs: $id" } - return CustomTileSource.getTileSource(id).also { - zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false - } - } - - val initialCameraView = remember { - val nodes = mapViewModel.nodes.value - val nodesWithPosition = nodes.filter { it.validPosition != null } - val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } - BoundingBox.fromGeoPoints(geoPoints) - } - val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) - - val nodeClusterer = remember { RadiusMarkerClusterer(context) } - - fun MapView.toggleMyLocation() { - if (context.gpsDisabled()) { - Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" } - scope.launch { context.showToast(Res.string.location_disabled) } - return - } - - Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" } - if (myLocationOverlay == null) { - myLocationOverlay = - MyLocationNewOverlay(this).apply { - enableMyLocation() - enableFollowLocation() - getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let { - setPersonIcon(it) - setPersonAnchor(0.5f, 0.5f) - } - getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let { - setDirectionIcon(it) - setDirectionAnchor(0.5f, 0.5f) - } - } - overlays.add(myLocationOverlay) - } else { - myLocationOverlay?.apply { - disableMyLocation() - disableFollowLocation() - } - overlays.remove(myLocationOverlay) - myLocationOverlay = null - } - } - - // Effect to toggle MyLocation after permission is granted - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() - triggerLocationToggleAfterPermission = false - } - } - - // Keep screen on while location tracking is active - LaunchedEffect(myLocationOverlay) { - val activity = context as? android.app.Activity ?: return@LaunchedEffect - if (myLocationOverlay != null) { - activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - val myId by mapViewModel.myId.collectAsStateWithLifecycle() - - LaunchedEffect(selectedWaypointId, waypoints) { - if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { - waypoints[selectedWaypointId]?.waypoint?.let { pt -> - val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - map.controller.setCenter(geoPoint) - map.controller.setZoom(WAYPOINT_ZOOM) - } - } - } - - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - fun MapView.onNodesChanged(nodes: Collection): List { - val nodesWithPosition = nodes.filter { it.validPosition != null } - val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC - val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly - return nodesWithPosition.mapNotNull { node -> - if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { - return@mapNotNull null - } - if ( - mapFilterStateValue.lastHeardFilter.seconds != 0L && - (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && - node.num != ourNode?.num - ) { - return@mapNotNull null - } - - val (p, u) = node.position to node.user - val nodePosition = GeoPoint(node.latitude, node.longitude) - MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply { - id = u.id - title = u.long_name - snippet = - getString( - Res.string.map_node_popup_details, - node.gpsString(), - formatAgo(node.lastHeard), - formatAgo(p.time), - if (node.batteryStr != "") node.batteryStr else "?", - ) - ourNode?.distanceStr(node, displayUnits)?.let { dist -> - ourNode.bearing(node)?.let { bearing -> - subDescription = getString(Res.string.map_subDescription, bearing, dist) - } - } - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - position = nodePosition - icon = markerIcon - setNodeColors(node.colors) - if (!mapFilterStateValue.showPrecisionCircle) { - setPrecisionBits(0) - } else { - setPrecisionBits(p.precision_bits) - } - setOnLongClickListener { - navigateToNodeDetails(node.num) - true - } - } - } - } - - fun showDeleteMarkerDialog(waypoint: Waypoint) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(getString(Res.string.waypoint_delete)) - builder.setNeutralButton(getString(Res.string.cancel)) { _, _ -> - Logger.d { "User canceled marker delete dialog" } - } - builder.setNegativeButton(getString(Res.string.delete_for_me)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for me" } - mapViewModel.deleteWaypoint(waypoint.id) - } - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) - mapViewModel.deleteWaypoint(waypoint.id) - } - } - val dialog = builder.show() - for ( - button in - setOf( - androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, - androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, - androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE, - ) - ) { - with(dialog.getButton(button)) { - textSize = 12F - isAllCaps = false - } - } - } - - fun showMarkerLongPressDialog(id: Int) { - performHapticFeedback() - Logger.d { "marker long pressed id=$id" } - val waypoint = waypoints[id]?.waypoint ?: return - // edit only when unlocked or lockedTo myNodeNum - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - showEditWaypointDialog = waypoint - } else { - showDeleteMarkerDialog(waypoint) - } - } - - fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { - getString(Res.string.you) - } else { - mapViewModel.getUser(id).long_name - } - - @Suppress("MagicNumber") - fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { - return waypoints.mapNotNull { waypoint -> - val pt = waypoint.waypoint ?: return@mapNotNull null - if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState - val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else "" - val time = DateFormatter.formatDateTime(waypoint.time) - val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt()) - val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) - val now = nowMillis - val expireTimeMillis = pt.expire * 1000L - val expireTimeStr = - when { - pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never" - expireTimeMillis <= now -> "Expired" - else -> DateFormatter.formatRelativeTime(expireTimeMillis) - } - MarkerWithLabel(this, label, emoji).apply { - id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.from)}$lock)" - 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) { - showInfoWindow() - } - setOnLongClickListener { - showMarkerLongPressDialog(pt.id) - true - } - } - } - } - - val mapEventsReceiver = - object : MapEventsReceiver { - override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { - InfoWindow.closeAllInfoWindowsOn(map) - return true - } - - override fun longPressHelper(p: GeoPoint): Boolean { - performHapticFeedback() - val enabled = isConnected && downloadRegionBoundingBox == null - - if (enabled) { - showEditWaypointDialog = - Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) - } - return true - } - } - - fun MapView.drawOverlays() { - if (overlays.none { it is MapEventsOverlay }) { - overlays.add(0, MapEventsOverlay(mapEventsReceiver)) - } - if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { - overlays.add(myLocationOverlay) - } - if (overlays.none { it is RadiusMarkerClusterer }) { - overlays.add(nodeClusterer) - } - - addCopyright() - addScaleBarOverlay(density) - createLatLongGrid(false) - - invalidate() - } - - fun MapView.generateBoxOverlay() { - overlays.removeAll { it is Polygon } - val zoomFactor = 1.3 - zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) - downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) - val polygon = - Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } - } - overlays.add(polygon) - invalidate() - val tileCount: Int = - CacheManager(this) - .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) - cacheEstimate = getString(Res.string.map_cache_tiles, tileCount) - } - - val boxOverlayListener = - object : MapListener { - override fun onScroll(event: ScrollEvent): Boolean { - if (downloadRegionBoundingBox != null) { - event.source.generateBoxOverlay() - } - return true - } - - override fun onZoom(event: ZoomEvent): Boolean = false - } - - fun startDownload() { - val boundingBox = downloadRegionBoundingBox ?: return - try { - val outputName = buildString { - append(Configuration.getInstance().osmdroidBasePath.absolutePath) - append(File.separator) - append("mainFile.sqlite") - } - val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) - cacheManager.downloadAreaAsync( - context, - boundingBox, - zoomLevelMin.toInt(), - zoomLevelMax.toInt(), - cacheManagerCallback( - onTaskComplete = { - scope.launch { context.showToast(Res.string.map_download_complete) } - writer.onDetach() - }, - onTaskFailed = { errors -> - scope.launch { context.showToast(Res.string.map_download_errors, errors) } - writer.onDetach() - }, - ), - ) - } catch (ex: TileSourcePolicyException) { - Logger.d { "Tile source does not allow archiving: ${ex.message}" } - } catch (ex: Exception) { - Logger.d { "Tile source exception: ${ex.message}" } - } - } - - Scaffold( - modifier = modifier, - floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - AndroidView( - factory = { - map.apply { - setDestroyMode(false) - addMapListener(boxOverlayListener) - } - }, - modifier = Modifier.fillMaxSize(), - update = { mapView -> - with(mapView) { - updateMarkers( - onNodesChanged(nodes), - onWaypointChanged(waypoints.values, selectedWaypointId), - nodeClusterer, - ) - } - mapView.drawOverlays() - }, // Renamed map to mapView to avoid conflict - ) - if (downloadRegionBoundingBox != null) { - CacheLayout( - cacheEstimate = cacheEstimate, - onExecuteJob = { startDownload() }, - onCancelDownload = { - downloadRegionBoundingBox = null - map.overlays.removeAll { it is Polygon } - map.invalidate() - }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterExpanded = true }, - filterDropdownContent = { - FdroidMainMapFilterDropdown( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - mapFilterState = mapFilterState, - mapViewModel = mapViewModel, - ) - }, - mapTypeContent = { - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.map_style_selection), - onClick = { showMapStyleDialog = true }, - ) - }, - isLocationTrackingEnabled = myLocationOverlay != null, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - ) - } - } - } - - if (showMapStyleDialog) { - MapStyleDialog( - selectedMapStyle = mapViewModel.mapStyleId, - onDismiss = { showMapStyleDialog = false }, - onSelectMapStyle = { - mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) - }, - ) - } - - if (showCacheManagerDialog) { - CacheManagerDialog( - onClickOption = { option -> - when (option) { - CacheManagerOption.CurrentCacheSize -> { - scope.launch { context.showToast(Res.string.calculating) } - showCurrentCacheInfo = true - } - CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() - - CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true - CacheManagerOption.Cancel -> Unit - } - showCacheManagerDialog = false - }, - onDismiss = { showCacheManagerDialog = false }, - ) - } - - if (showCurrentCacheInfo) { - CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) - } - - if (showPurgeTileSourceDialog) { - PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) - } - - if (showEditWaypointDialog != null) { - EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, // Safe call - onSendClicked = { waypoint -> - Logger.d { "User clicked send waypoint ${waypoint.id}" } - showEditWaypointDialog = null - - val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id - val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name - val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire - val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0 - val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon - - mapViewModel.sendWaypoint( - waypoint.copy( - id = newId, - name = newName, - expire = newExpire, - locked_to = newLockedTo, - icon = newIcon, - ), - ) - }, - onDeleteClicked = { waypoint -> - Logger.d { "User clicked delete waypoint ${waypoint.id}" } - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Logger.d { "User clicked cancel marker edit dialog" } - showEditWaypointDialog = null - }, - ) - } -} - -/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */ -@Composable -private fun FdroidMainMapFilterDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - mapFilterState: MapFilterState, - mapViewModel: MapViewModel, -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - HorizontalDivider() - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} - -@Composable -private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { - val selected = remember { mutableStateOf(selectedMapStyle) } - - MapsDialog(onDismiss = onDismiss) { - CustomTileSource.mTileSources.values.forEachIndexed { index, style -> - ListItem( - text = style, - trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, - onClick = { - selected.value = index - onSelectMapStyle(index) - onDismiss() - }, - ) - } - } -} - -private enum class CacheManagerOption(val label: StringResource) { - CurrentCacheSize(label = Res.string.map_cache_size), - DownloadRegion(label = Res.string.map_download_region), - ClearTiles(label = Res.string.map_clear_tiles), - Cancel(label = Res.string.cancel), -} - -@Composable -private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { - MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { - CacheManagerOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickOption(option) - onDismiss() - } - } - } -} - -@Composable -private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { - val (cacheCapacity, currentCacheUsage) = - remember(mapView) { - val cacheManager = CacheManager(mapView) - cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() - } - - MapsDialog( - title = stringResource(Res.string.map_cache_manager), - onDismiss = onDismiss, - negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, - ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) - } -} - -@Composable -private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val cache = SqlTileWriterExt() - - val sourceList by derivedStateOf { cache.sources.map { it.source as String } } - - val selected = remember { mutableStateListOf() } - - MapsDialog( - title = stringResource(Res.string.map_tile_source), - positiveButton = { - TextButton( - enabled = selected.isNotEmpty(), - onClick = { - selected.forEach { selectedIndex -> - val source = sourceList[selectedIndex] - scope.launch { - context.showToast( - if (cache.purgeCache(source)) { - getString(Res.string.map_purge_success, source) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } - - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) - } - }, - negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, - onDismiss = onDismiss, - ) { - sourceList.forEachIndexed { index, source -> - val isSelected = selected.contains(index) - BasicListItem( - text = source, - trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, - onClick = { - if (isSelected) { - selected.remove(index) - } else { - selected.add(index) - } - }, - ) {} - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MapsDialog( - title: String? = null, - onDismiss: () -> Unit, - positiveButton: (@Composable () -> Unit)? = null, - negativeButton: (@Composable () -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.wrapContentWidth().wrapContentHeight(), - shape = MaterialTheme.shapes.large, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column { - title?.let { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } - } - } - } -} - -private const val WAYPOINT_ZOOM = 15.0 diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt deleted file mode 100644 index 3cc0dbaf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import android.graphics.Typeface -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import org.meshtastic.app.R -import org.meshtastic.proto.Position -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.CopyrightOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import org.osmdroid.views.overlay.ScaleBarOverlay -import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList -import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 - -/** Adds copyright to map depending on what source is showing */ -fun MapView.addCopyright() { - if (overlays.none { it is CopyrightOverlay }) { - val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return - val copyrightOverlay = CopyrightOverlay(context) - copyrightOverlay.setCopyrightNotice(copyrightNotice) - overlays.add(copyrightOverlay) - } -} - -/** - * Create LatLong Grid line overlay - * - * @param enabled: turn on/off gridlines - */ -fun MapView.createLatLongGrid(enabled: Boolean) { - val latLongGridOverlay = LatLonGridlineOverlay2() - latLongGridOverlay.isEnabled = enabled - if (latLongGridOverlay.isEnabled) { - val textPaint = - Paint().apply { - textSize = 40f - color = Color.GRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - latLongGridOverlay.textPaint = textPaint - latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT) - latLongGridOverlay.setLineWidth(3.0f) - latLongGridOverlay.setLineColor(Color.GRAY) - overlays.add(latLongGridOverlay) - } -} - -fun MapView.addScaleBarOverlay(density: Density) { - if (overlays.none { it is ScaleBarOverlay }) { - val scaleBarOverlay = - ScaleBarOverlay(this).apply { - setAlignBottom(true) - with(density) { - setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt()) - setTextSize(12.sp.toPx()) - } - textPaint.apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } - } - overlays.add(scaleBarOverlay) - } -} - -fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () -> Unit): Polyline { - val polyline = - Polyline(this).apply { - val borderPaint = - Paint().apply { - color = Color.BLACK - isAntiAlias = true - strokeWidth = with(density) { 10.dp.toPx() } - style = Paint.Style.STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(borderPaint)) - val fillPaint = - Paint().apply { - color = Color.WHITE - isAntiAlias = true - strokeWidth = with(density) { 6.dp.toPx() } - style = Paint.Style.FILL_AND_STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(fillPaint)) - setPoints(geoPoints) - setOnClickListener { _, _, _ -> - onClick() - true - } - } - overlays.add(polyline) - - return polyline -} - -fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { - val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = - positions.map { pos -> - Marker(this).apply { - icon = navIcon - rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick(pos.time) - true - } - } - } - overlays.addAll(markers) - - return markers -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index 1ffe68aa1..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.model.RadioController -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.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.LocalConfig - -@Suppress("LongParameterList") -@KoinViewModel -class MapViewModel( - mapPrefs: MapPrefs, - packetRepository: PacketRepository, - nodeRepository: NodeRepository, - radioController: RadioController, - radioConfigRepository: RadioConfigRepository, - buildConfigProvider: BuildConfigProvider, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - } - } - - var mapStyleId: Int - get() = mapPrefs.mapStyle.value - set(value) { - mapPrefs.setMapStyle(value) - } - - val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - - val config - get() = localConfig.value - - val applicationId = buildConfigProvider.applicationId -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt deleted file mode 100644 index c16d87163..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.MapView - -private const val MIN_ZOOM_LEVEL = 1.5 -private const val MAX_ZOOM_LEVEL = 20.0 -private const val DEFAULT_ZOOM_LEVEL = 15.0 - -@Suppress("MagicNumber") -@Composable -fun rememberMapViewWithLifecycle( - applicationId: String, - box: BoundingBox, - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - val zoom = - if (box.requiredZoomLevel().isFinite()) { - (box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL) - } else { - DEFAULT_ZOOM_LEVEL - } - val center = GeoPoint(box.centerLatitude, box.centerLongitude) - return rememberMapViewWithLifecycle( - applicationId = applicationId, - zoomLevel = zoom, - mapCenter = center, - tileSource = tileSource, - ) -} - -@Suppress("LongMethod") -@Composable -internal fun rememberMapViewWithLifecycle( - applicationId: String, - zoomLevel: Double = MIN_ZOOM_LEVEL, - mapCenter: GeoPoint = GeoPoint(0.0, 0.0), - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) } - var savedCenter by - rememberSaveable( - stateSaver = - Saver( - save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, - restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) }, - ), - ) { - mutableStateOf(mapCenter) - } - - val context = LocalContext.current - val mapView = remember { - MapView(context).apply { - clipToOutline = true - - // Required to get online tiles - Configuration.getInstance().userAgentValue = applicationId - setTileSource(tileSource) - isVerticalMapRepetitionEnabled = false // disables map repetition - setMultiTouchControls(true) - val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map - setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0) - // scales the map tiles to the display density of the screen - isTilesScaledToDpi = true - // sets the minimum zoom level (the furthest out you can zoom) - minZoomLevel = MIN_ZOOM_LEVEL - maxZoomLevel = MAX_ZOOM_LEVEL - // Disables default +/- button for zooming - zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) - - controller.setZoom(savedZoom) - controller.setCenter(savedCenter) - } - } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> { - mapView.onPause() - } - - Lifecycle.Event.ON_RESUME -> { - mapView.onResume() - } - - Lifecycle.Event.ON_STOP -> { - savedCenter = mapView.projection.currentCenter - savedZoom = mapView.zoomLevelDouble - } - - else -> {} - } - } - - lifecycle.addObserver(observer) - - onDispose { lifecycle.removeObserver(observer) } - } - return mapView -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt deleted file mode 100644 index 112449d1f..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.database.Cursor -import org.meshtastic.core.common.util.nowMillis -import org.osmdroid.tileprovider.modules.DatabaseFileArchive -import org.osmdroid.tileprovider.modules.SqlTileWriter - -/** - * Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need - * to put these with the osmdroid-android library, thus they were put here as more of an example. - * - * created on 12/21/2016. - * - * @author Alex O'Ree - * @since 5.6.2 - */ -class SqlTileWriterExt : SqlTileWriter() { - fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_KEY + - "," + - COLUMN_EXPIRES + - "," + - DatabaseFileArchive.COLUMN_PROVIDER + - " from " + - DatabaseFileArchive.TABLE + - " limit ? offset ?", - arrayOf(rows.toString() + "", offset.toString() + ""), - ) - - /** - * gets all the tiles sources that we have tiles for in the cache database and their counts - * - * @return - */ - val sources: List - get() { - val db = db - val ret: MutableList = ArrayList() - if (db == null) { - return ret - } - var cur: Cursor? = null - try { - cur = - db.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_PROVIDER + - ",count(*) " + - ",min(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",max(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",sum(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - "from " + - DatabaseFileArchive.TABLE + - " " + - "group by " + - DatabaseFileArchive.COLUMN_PROVIDER, - null, - ) - while (cur.moveToNext()) { - val c = SourceCount() - c.source = cur.getString(0) - c.rowCount = cur.getLong(1) - c.sizeMin = cur.getLong(2) - c.sizeMax = cur.getLong(3) - c.sizeTotal = cur.getLong(4) - c.sizeAvg = c.sizeTotal / c.rowCount - ret.add(c) - } - } catch (e: Exception) { - catchException(e) - } finally { - cur?.close() - } - return ret - } - - val rowCountExpired: Long - get() = getRowCount("$COLUMN_EXPIRES. - */ -package org.meshtastic.app.map.component - -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.map_select_download_region -import org.meshtastic.core.resources.map_start_download -import org.meshtastic.core.resources.map_tile_download_estimate - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun CacheLayout( - cacheEstimate: String, - onExecuteJob: () -> Unit, - onCancelDownload: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight() - .background(color = MaterialTheme.colorScheme.background) - .padding(8.dp), - ) { - Text( - text = stringResource(Res.string.map_select_download_region), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(space = 8.dp), - ) { - Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary) - } - Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun CacheLayoutPreview() { - CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {}) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt deleted file mode 100644 index 7568d695a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.ui.icon.Download -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { - AnimatedVisibility( - visible = enabled, - enter = - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - exit = - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - ) { - FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { - Icon( - imageVector = MeshtasticIcons.Download, - contentDescription = stringResource(Res.string.map_download_region), - modifier = Modifier.scale(1.25f), - ) - } - } -} - -// @Preview(showBackground = true) -// @Composable -// private fun DownloadButtonPreview() { -// DownloadButton(true, onClick = {}) -// } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index c41798bf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import android.app.DatePickerDialog -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.Month -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours -import kotlin.time.Instant - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun EditWaypointDialog( - waypoint: Waypoint, - onSendClicked: (Waypoint) -> Unit, - onDeleteClicked: (Waypoint) -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - var waypointInput by remember { mutableStateOf(waypoint) } - val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit - - @Suppress("MagicNumber") - val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon - var showEmojiPickerView by remember { mutableStateOf(false) } - - // Get current context for dialogs - val context = LocalContext.current - val tz = systemTimeZone - - // Determine locale-specific date format - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - // Check if 24-hour format is preferred - val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - - val currentInstant = - remember(waypointInput.expire) { - val expire = waypointInput.expire - if (expire != 0 && expire != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(expire.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - - // State to hold selected date and time - var selectedDate by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - var selectedTime by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - shape = RoundedCornerShape(16.dp), - text = { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = stringResource(title), - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - ) - EditTextPreference( - title = stringResource(Res.string.name), - value = waypointInput.name, - maxSize = 29, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(name = it) }, - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(emoji)), - modifier = - Modifier.background(MaterialTheme.colorScheme.background, CircleShape) - .padding(4.dp), - fontSize = 24.sp, - color = Color.Unspecified.copy(alpha = 1f), - ) - } - }, - ) - EditTextPreference( - title = stringResource(Res.string.description), - value = waypointInput.description, - maxSize = 99, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(description = it) }, - ) - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Text(stringResource(Res.string.locked)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.locked_to != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - - val ldt = currentInstant.toLocalDateTime(tz) - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val newLdt = - LocalDateTime( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - hour = ldt.hour, - minute = ldt.minute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.ordinal, - ldt.day, - ) - - val timePickerDialog = - android.app.TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val newLdt = - LocalDateTime( - year = ldt.year, - month = ldt.month, - day = ldt.day, - hour = selectedHour, - minute = selectedMinute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - is24Hour, - ) - - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Text(stringResource(Res.string.expires)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0, - onCheckedChange = { isChecked -> - if (isChecked) { - waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt()) - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDate, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTime, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - } - } - } - }, - confirmButton = { - FlowRow( - modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.Center, - ) { - TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } - if (waypoint.id != 0) { - Button( - modifier = modifier.weight(1f), - onClick = { onDeleteClicked(waypointInput) }, - enabled = !(waypointInput.name.isNullOrEmpty()), - ) { - Text(stringResource(Res.string.delete)) - } - } - Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) { - Text(stringResource(Res.string.send)) - } - } - }, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = it.codePointAt(0)) - } - } -} - -@Preview(showBackground = true) -@Composable -@Suppress("MagicNumber") -private fun EditWaypointFormPreview() { - AppTheme { - EditWaypointDialog( - waypoint = - Waypoint( - id = 123, - name = "Test 123", - description = "This is only a test", - icon = 128169, - expire = (nowSeconds.toInt() + 8 * 3600), - ), - onSendClicked = {}, - onDeleteClicked = {}, - onDismissRequest = {}, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index de0f8c6c2..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("UnusedPrivateProperty") -class CustomTileSource { - - companion object { - val OPENWEATHER_RADAR = - OnlineTileSourceAuth( - "Open Weather Map", - 1, - 22, - 256, - ".png", - arrayOf("https://tile.openweathermap.org/map/"), - "Openweathermap", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - "precipitation", - "", - ) - private val ESRI_IMAGERY = - object : - OnlineTileSourceBase( - "ESRI World Overview", - 1, - 20, - 256, - ".jpg", - arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"), - "Esri, Maxar, Earthstar Geographics, and the GIS User Community", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - private val ESRI_WORLD_TOPO = - object : - OnlineTileSourceBase( - "ESRI World TOPO", - 1, - 20, - 256, - ".jpg", - arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"), - "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_HYDRO_CACHE = - object : - OnlineTileSourceBase( - "USGS Hydro Cache", - 0, - 18, - 256, - "", - arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_SHADED_RELIEF = - object : - OnlineTileSourceBase( - "USGS Shaded Relief Only", - 0, - 18, - 256, - "", - arrayOf( - "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/", - ), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - /** WMS TILE SERVER More research is required to get this to function correctly with overlays */ - val NOAA_RADAR_WMS = - NOAAWmsTileSource( - "Recent Weather Radar", - arrayOf( - "https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" + - "radar_meteo_imagery_nexrad_time/MapServer/WmsServer?", - ), - "1", - "1.1.0", - "", - "EPSG%3A3857", - "", - "image/png", - ) - - /** =============================================================================================== */ - private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK - private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO - private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo - private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT - private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP - val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE - - /** Source for each available [ITileSource] and their display names. */ - val mTileSources: Map = - mapOf( - MAPNIK to "OpenStreetMap", - USGS_TOPO to "USGS TOPO", - OPEN_TOPO to "Open TOPO", - ESRI_WORLD_TOPO to "ESRI World TOPO", - USGS_SAT to "USGS Satellite", - ESRI_IMAGERY to "ESRI World Overview", - ) - - fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE - - fun getTileSource(aName: String): ITileSource { - for (tileSource: ITileSource in mTileSources.keys) { - if (tileSource.name().equals(aName)) { - return tileSource - } - } - throw IllegalArgumentException("No such tile source: $aName") - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt deleted file mode 100644 index da94a7725..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.MotionEvent -import org.meshtastic.app.map.dpToPx -import org.meshtastic.app.map.spToPx -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon - -class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) { - - companion object { - private const val LABEL_CORNER_RADIUS_DP = 4f - private const val LABEL_Y_OFFSET_DP = 34f - private const val FONT_SIZE_SP = 14f - private const val EMOJI_FONT_SIZE_SP = 20f - } - - private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 } - - private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 } - - private var nodeColor: Int = Color.GRAY - - fun setNodeColors(colors: Pair) { - nodeColor = colors.second - } - - private var precisionBits: Int? = null - - fun setPrecisionBits(bits: Int) { - precisionBits = bits - } - - @Suppress("MagicNumber") - private fun getPrecisionMeters(): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.53562 - 16 -> 364.7622 - 17 -> 182.375556 - 18 -> 91.182212 - 19 -> 45.58554 - else -> null - } - - private var onLongClickListener: (() -> Boolean)? = null - - fun setOnLongClickListener(listener: () -> Boolean) { - onLongClickListener = listener - } - - private val mLabel = label - private val mEmoji = emoji - private val textPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f - color = Color.DKGRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - private val emojiPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - private val bgPaint = Paint().apply { color = Color.WHITE } - - private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF { - val fontMetrics = textPaint.fontMetrics - val halfTextLength = textPaint.measureText(text) / 2 + 3 - return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom)) - } - - override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { - val touched = hitTest(event, mapView) - if (touched && this.id != null) { - return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView) - } - return super.onLongPress(event, mapView) - } - - @Suppress("MagicNumber") - override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { - super.draw(c, osmv, false) - val p = mPositionPixels - val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat())) - bgRect.inset(-8F, -2F) - - if (mLabel.isNotEmpty()) { - c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint) - c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint) - } - mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } - - getPrecisionMeters()?.let { radius -> - val polygon = - Polygon(osmv).apply { - points = Polygon.pointsAsCircle(position, radius) - fillPaint.apply { - color = nodeColor - alpha = 48 - } - outlinePaint.apply { - color = nodeColor - alpha = 64 - } - } - polygon.draw(c, osmv, false) - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt deleted file mode 100644 index ac438397a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import android.content.res.Resources -import co.touchlab.kermit.Logger -import org.osmdroid.api.IMapView -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex -import kotlin.math.atan -import kotlin.math.pow -import kotlin.math.sinh - -open class NOAAWmsTileSource( - aName: String, - aBaseUrl: Array, - layername: String, - version: String, - time: String?, - srs: String, - style: String?, - format: String, -) : OnlineTileSourceBase( - aName, - 0, - 5, - 256, - "png", - aBaseUrl, - "", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), -) { - - // array indexes for array to hold bounding boxes. - private val minX = 0 - private val maxX = 1 - private val minY = 2 - private val maxY = 3 - - // Web Mercator n/w corner of the map. - private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244) - - // array indexes for that data - private val origX = 0 - private val origY = 1 // " - - // Size of square world map in meters, using WebMerc projection. - private val mapSize = 20037508.34789244 * 2 - private var layer = "" - private var version = "1.1.0" - private var srs = "EPSG%3A3857" // used by geo server - private var format = "" - private var time = "" - private var style: String? = null - private var forceHttps = false - private var forceHttp = false - - init { - Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" } - layer = layername - this.version = version - this.srs = srs - this.style = style - this.format = format - if (time != null) this.time = time - } - - private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180 - - private fun tile2lat(y: Int, z: Int): Double { - val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble()) - return Math.toDegrees(atan(sinh(n))) - } - - // Return a web Mercator bounding box given tile x/y indexes and a zoom - // level. - private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { - val tileSize = mapSize / 2.0.pow(zoom.toDouble()) - val minx = tileOrigin[origX] + x * tileSize - val maxx = tileOrigin[origX] + (x + 1) * tileSize - val miny = tileOrigin[origY] - (y + 1) * tileSize - val maxy = tileOrigin[origY] - y * tileSize - val bbox = DoubleArray(4) - bbox[minX] = minx - bbox[minY] = miny - bbox[maxX] = maxx - bbox[maxY] = maxy - return bbox - } - - fun isForceHttps(): Boolean = forceHttps - - fun setForceHttps(forceHttps: Boolean) { - this.forceHttps = forceHttps - } - - fun isForceHttp(): Boolean = forceHttp - - fun setForceHttp(forceHttp: Boolean) { - this.forceHttp = forceHttp - } - - override fun getTileURLString(pMapTileIndex: Long): String? { - var baseUrl = baseUrl - if (forceHttps) baseUrl = baseUrl.replace("http://", "https://") - if (forceHttp) baseUrl = baseUrl.replace("https://", "http://") - val sb = StringBuilder(baseUrl) - if (!baseUrl.endsWith("&")) sb.append("service=WMS") - sb.append("&request=GetMap") - sb.append("&version=").append(version) - sb.append("&layers=").append(layer) - if (style != null) sb.append("&styles=").append(style) - sb.append("&format=").append(format) - sb.append("&transparent=true") - sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels) - sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels) - sb.append("&srs=").append(srs) - sb.append("&size=").append(getSize()) - sb.append("&bbox=") - val bbox = - getBoundingBox( - MapTileIndex.getX(pMapTileIndex), - MapTileIndex.getY(pMapTileIndex), - MapTileIndex.getZoom(pMapTileIndex), - ) - sb.append(bbox[minX]).append(",") - sb.append(bbox[minY]).append(",") - sb.append(bbox[maxX]).append(",") - sb.append(bbox[maxY]) - Logger.withTag(IMapView.LOGTAG).i { sb.toString() } - return sb.toString() - } - - private fun getSize(): String { - val height = Resources.getSystem().displayMetrics.heightPixels - val width = Resources.getSystem().displayMetrics.widthPixels - return "$width,$height" - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt deleted file mode 100644 index 3d51133bd..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("LongParameterList") -open class OnlineTileSourceAuth( - name: String, - zoomLevel: Int, - zoomMaxLevel: Int, - tileSizePixels: Int, - imageFileNameEnding: String, - baseUrl: Array, - pCopyright: String, - tileSourcePolicy: TileSourcePolicy, - layerName: String?, - apiKey: String, -) : OnlineTileSourceBase( - name, - zoomLevel, - zoomMaxLevel, - tileSizePixels, - imageFileNameEnding, - baseUrl, - pCopyright, - tileSourcePolicy, -) { - private var layerName = "" - private var apiKey = "" - - init { - if (layerName != null) { - this.layerName = layerName - } - this.apiKey = apiKey - } - - override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getX(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex).toString() - ) + - mImageFilenameEnding + - "?appId=$apiKey" -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 77b595d88..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain - * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation - * ([NodeTrackOsmMap]). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - NodeTrackOsmMap( - positions = positions, - applicationId = vm.applicationId, - mapStyleId = vm.mapStyleId, - modifier = modifier, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt deleted file mode 100644 index a6aec4c2d..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -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.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addPolyline -import org.meshtastic.app.map.addPositionMarkers -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.roundToInt - -/** - * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional - * markers for each historical position. - * - * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] - * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a - * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider - * so users can adjust the time range directly from the map. - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - * - * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or - * location tracking. It is designed to be embedded inside the position-log adaptive layout. - */ -@Composable -fun NodeTrackOsmMap( - positions: List, - applicationId: String, - mapStyleId: Int, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, - mapViewModel: MapViewModel = koinViewModel(), -) { - val density = LocalDensity.current - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - - val filteredPositions = - remember(positions, lastHeardTrackFilter) { - positions.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - } - - val geoPoints = - remember(filteredPositions) { - filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } - } - val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = applicationId, - box = cameraView, - tileSource = CustomTileSource.getTileSource(mapStyleId), - ) - - var filterMenuExpanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - AndroidView( - modifier = Modifier.matchParentSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) } - // Center on selected position - if (selectedPositionTime != null) { - val selected = filteredPositions.find { it.time == selectedPositionTime } - if (selected != null) { - val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) - map.controller.animateTo(point) - } - } - }, - ) - - // Track filter controls overlay - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { filterMenuExpanded = true }, - filterDropdownContent = { - DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(lastHeardTrackFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } - }, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index fcf1d47e9..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.traceroute - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation - * ([TracerouteOsmMap]). - */ -@Composable -fun TracerouteMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, -) { - TracerouteOsmMap( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - onMappableCountChanged = onMappableCountChanged, - modifier = modifier, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt deleted file mode 100644 index 55b49154a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt +++ /dev/null @@ -1,288 +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 . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map.traceroute - -import android.graphics.Paint -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.app.map.zoomIn -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS -import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin - -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 -private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 - -/** - * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and - * forward/return offset polylines with auto-centering camera. - * - * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any - * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold. - */ -@Composable -fun TracerouteOsmMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), -) { - val context = LocalContext.current - val density = LocalDensity.current - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - // Resolve which nodes to display for the traceroute - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, nodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = nodes, - ) - } - val displayNodes = tracerouteSelection.nodesForMarkers - val nodeLookup = tracerouteSelection.nodeLookup - - // Report mappable count - LaunchedEffect(tracerouteOverlay, displayNodes) { - if (tracerouteOverlay != null) { - onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) - } - } - - // Compute polyline GeoPoints from node positions - val forwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - val returnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - - // Compute offset polylines for visual separation - val headingReferencePoints = - remember(forwardPoints, returnPoints) { - when { - forwardPoints.size >= 2 -> forwardPoints - returnPoints.size >= 2 -> returnPoints - else -> emptyList() - } - } - val forwardOffsetPoints = - remember(forwardPoints, headingReferencePoints) { - offsetPolyline( - points = forwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = 1.0, - ) - } - val returnOffsetPoints = - remember(returnPoints, headingReferencePoints) { - offsetPolyline( - points = returnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = -1.0, - ) - } - - // Camera auto-center - var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) } - - // Build initial camera from all traceroute points - val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() } - val initialCameraView = - remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) } - - val mapView = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView ?: BoundingBox(), - tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId), - ) - - // Center camera on traceroute bounds - LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) { - if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect - if (allPoints.isNotEmpty()) { - if (allPoints.size == 1) { - mapView.controller.setCenter(allPoints.first()) - mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) - } else { - mapView.zoomToBoundingBox( - BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), - true, - ) - } - hasCentered = true - } - } - - AndroidView( - modifier = modifier, - factory = { mapView.apply { setDestroyMode(false) } }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - - // Render traceroute polylines - buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) } - - // Render simple node markers - displayNodes.forEach { node -> - val position = GeoPoint(node.latitude, node.longitude) - val marker = - MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}") - .apply { - id = node.user.id - title = node.user.long_name - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - this.position = position - icon = markerIcon - setNodeColors(node.colors) - } - map.overlays.add(marker) - } - - map.invalidate() - }, - ) -} - -private fun buildTraceroutePolylines( - forwardPoints: List, - returnPoints: List, - density: androidx.compose.ui.unit.Density, -): List { - val polylines = mutableListOf() - - fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { - setPoints(points) - outlinePaint.apply { - this.color = color - this.strokeWidth = strokeWidth - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - style = Paint.Style.STROKE - } - } - - forwardPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() })) - } - returnPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() })) - } - return polylines -} - -// --- Haversine offset math for OSMDroid (no SphericalUtil available) --- - -private fun Double.toRad(): Double = this * PI / 180.0 - -private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { - val lat1 = from.latitude.toRad() - val lat2 = to.latitude.toRad() - val dLon = (to.longitude - from.longitude).toRad() - return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) -} - -private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { - val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS - val lat1 = latitude.toRad() - val lon1 = longitude.toRad() - val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) - val lon2 = - lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) - return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI) -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> bearingRad(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) - - else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (PI / 2 * sideMultiplier) - point.offsetPoint(perpendicularHeading, abs(offsetMeters)) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index 447765522..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.component - -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import org.meshtastic.core.model.Node -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker - -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val context = androidx.compose.ui.platform.LocalContext.current - - val map = remember { - MapView(context).apply { - layoutParams = - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - // Default osmdroid tile source. - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(false) - - controller.setZoom(15.0) - } - } - - LaunchedEffect(node.num) { - val point = GeoPoint(node.latitude, node.longitude) - - map.overlays.clear() - - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - } - map.overlays.add(marker) - - map.controller.animateTo(point) - } - - AndroidView(factory = { map }, modifier = modifier) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index d6515eeb7..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomEnd, - overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp), - contentHorizontalAlignment = Alignment.End, -) diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml index c4138cb0b..e229afd81 100644 --- a/app/src/google/AndroidManifest.xml +++ b/app/src/google/AndroidManifest.xml @@ -1,6 +1,6 @@