diff --git a/.gitignore b/.gitignore
index 08af968999..c854a8f36c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,13 @@ docs/screenshots/
# VS code
.vscode/settings.json
+# Eclipse / Buildship
+.project
+.classpath
+.settings/
+**/bin/main/
+**/bin/test/
+
# Secrets
/secrets.properties
/fastlane/play-store-credentials.json
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index f6884c99d5..5bd1021666 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -685,8 +685,11 @@ map_clear_tiles
map_download_complete
map_download_errors
map_download_region
+map_empty_state
map_filter
map_layer_formats
+map_load_error
+map_location_unavailable
map_node_popup_details
map_offline_manager
map_purge_fail
@@ -697,8 +700,15 @@ map_reporting_consent_text
map_reporting_interval_seconds
map_reporting_summary
map_select_download_region
+map_showing_filtered
map_start_download
+map_style_dark
+map_style_light
+map_style_osm
+map_style_road_map
+map_style_satellite
map_style_selection
+map_style_terrain
map_subDescription
map_tile_download_estimate
map_tile_source
@@ -706,6 +716,8 @@ map_type_hybrid
map_type_normal
map_type_satellite
map_type_terrain
+map_zoom_in
+map_zoom_out
mark_as_read
match_all
match_any
@@ -887,6 +899,13 @@ notifications_on_message_receipt
now
ntp_server
number_of_records
+### OFFLINE ###
+offline_download
+offline_download_visible_region
+offline_downloaded_regions
+offline_maps
+offline_saves_tiles
+offline_unnamed_region
ok_to_mqtt
okay
oled_type
@@ -1357,16 +1376,21 @@ uv_lux
via_api
via_mqtt
via_udp
+view_details
view_on_map
view_release
voltage
wait_for_bluetooth_duration_seconds
wake_on_tap_or_motion
warning
+### WAYPOINT ###
waypoint_delete
+waypoint_deleted
waypoint_edit
+waypoint_lock_to_my_node
waypoint_new
waypoint_received
+waypoint_sent
weight
### WIFI ###
wifi_config
@@ -1419,3 +1443,4 @@ wind_speed
you
zh_CN
zh_TW
+zoom_to_fit_all
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 480989d8a1..e68e5c6cbd 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -263,11 +263,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
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.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)
@@ -282,10 +277,6 @@ dependencies {
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
- 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/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java
deleted file mode 100644
index 38e51da529..0000000000
--- a/androidApp/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/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java
deleted file mode 100644
index e2710352ab..0000000000
--- a/androidApp/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/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java
deleted file mode 100644
index 324a34b529..0000000000
--- a/androidApp/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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
deleted file mode 100644
index 246d33d78e..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
+++ /dev/null
@@ -1,80 +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
-
-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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
deleted file mode 100644
index cebaf39316..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ /dev/null
@@ -1,968 +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
-
-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.DropdownMenuGroup
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.MenuDefaults
-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,
-) {
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = onDismissRequest,
- modifier = Modifier.background(MaterialTheme.colorScheme.surface),
- ) {
- DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
- 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() },
- )
- }
- 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)) } },
- ) {
- val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
- val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
- Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
- }
-}
-
-@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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
deleted file mode 100644
index 0dcc305195..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
+++ /dev/null
@@ -1,145 +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
-
-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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
deleted file mode 100644
index eefd9df435..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ /dev/null
@@ -1,67 +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
-
-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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
deleted file mode 100644
index 3ce34eb479..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
+++ /dev/null
@@ -1,136 +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
-
-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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
deleted file mode 100644
index 9bd5cac52e..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
+++ /dev/null
@@ -1,111 +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
-
-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", arrayOf(nowMillis.toString()))
-
- class SourceCount {
- var rowCount: Long = 0
- var source: String? = null
- var sizeTotal: Long = 0
- var sizeMin: Long = 0
- var sizeMax: Long = 0
- var sizeAvg: Long = 0
- }
-}
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
deleted file mode 100644
index a2646d854d..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import 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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
deleted file mode 100644
index 0cb1ec5e03..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import 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),
- )
- }
- }
-}
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
deleted file mode 100644
index f013a1253b..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ /dev/null
@@ -1,357 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import android.app.DatePickerDialog
-import android.widget.DatePicker
-import android.widget.TimePicker
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.Month
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.common.util.systemTimeZone
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.cancel
-import org.meshtastic.core.resources.date
-import org.meshtastic.core.resources.delete
-import org.meshtastic.core.resources.description
-import org.meshtastic.core.resources.expires
-import org.meshtastic.core.resources.locked
-import org.meshtastic.core.resources.name
-import org.meshtastic.core.resources.send
-import org.meshtastic.core.resources.time
-import org.meshtastic.core.resources.waypoint_edit
-import org.meshtastic.core.resources.waypoint_new
-import org.meshtastic.core.ui.component.EditTextPreference
-import org.meshtastic.core.ui.emoji.EmojiPickerDialog
-import org.meshtastic.core.ui.icon.CalendarMonth
-import org.meshtastic.core.ui.icon.Lock
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.theme.AppTheme
-import org.meshtastic.proto.Waypoint
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Instant
-
-@Suppress("LongMethod", "CyclomaticComplexMethod")
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-fun EditWaypointDialog(
- waypoint: Waypoint,
- onSendClicked: (Waypoint) -> Unit,
- onDeleteClicked: (Waypoint) -> Unit,
- onDismissRequest: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- var waypointInput by remember { mutableStateOf(waypoint) }
- val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
-
- @Suppress("MagicNumber")
- val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
- var showEmojiPickerView by remember { mutableStateOf(false) }
-
- // Get current context for dialogs
- val context = LocalContext.current
- val tz = systemTimeZone
-
- // Determine locale-specific date format
- val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
- // Check if 24-hour format is preferred
- val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
- val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
-
- val currentInstant =
- remember(waypointInput.expire) {
- val expire = waypointInput.expire
- if (expire != 0 && expire != Int.MAX_VALUE) {
- kotlin.time.Instant.fromEpochSeconds(expire.toLong())
- } else {
- kotlin.time.Clock.System.now() + 8.hours
- }
- }
-
- // State to hold selected date and time
- var selectedDate by
- remember(currentInstant) {
- mutableStateOf(
- if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
- dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
- } else {
- ""
- },
- )
- }
- var selectedTime by
- remember(currentInstant) {
- mutableStateOf(
- if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
- timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
- } else {
- ""
- },
- )
- }
-
- if (!showEmojiPickerView) {
- AlertDialog(
- onDismissRequest = onDismissRequest,
- shape = RoundedCornerShape(16.dp),
- text = {
- Column(modifier = modifier.fillMaxWidth()) {
- Text(
- text = stringResource(title),
- style =
- MaterialTheme.typography.titleLarge.copy(
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- ),
- modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
- )
- EditTextPreference(
- title = stringResource(Res.string.name),
- value = waypointInput.name,
- maxSize = 29,
- enabled = true,
- isError = false,
- keyboardOptions =
- KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = {}),
- onValueChanged = { waypointInput = waypointInput.copy(name = it) },
- trailingIcon = {
- IconButton(onClick = { showEmojiPickerView = true }) {
- Text(
- text = String(Character.toChars(emoji)),
- modifier =
- Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
- .padding(4.dp),
- fontSize = 24.sp,
- color = Color.Unspecified.copy(alpha = 1f),
- )
- }
- },
- )
- EditTextPreference(
- title = stringResource(Res.string.description),
- value = waypointInput.description,
- maxSize = 99,
- enabled = true,
- isError = false,
- keyboardOptions =
- KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = {}),
- onValueChanged = { waypointInput = waypointInput.copy(description = it) },
- )
- Row(
- modifier = Modifier.fillMaxWidth().size(48.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Image(
- imageVector = MeshtasticIcons.Lock,
- contentDescription = stringResource(Res.string.locked),
- )
- Text(stringResource(Res.string.locked))
- Switch(
- modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
- checked = waypointInput.locked_to != 0,
- onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
- )
- }
-
- val ldt = currentInstant.toLocalDateTime(tz)
- val datePickerDialog =
- DatePickerDialog(
- context,
- { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
- val newLdt =
- LocalDateTime(
- year = selectedYear,
- month = Month(selectedMonth + 1),
- day = selectedDay,
- hour = ldt.hour,
- minute = ldt.minute,
- second = ldt.second,
- nanosecond = ldt.nanosecond,
- )
- waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
- },
- ldt.year,
- ldt.month.ordinal,
- ldt.day,
- )
-
- val timePickerDialog =
- android.app.TimePickerDialog(
- context,
- { _: TimePicker, selectedHour: Int, selectedMinute: Int ->
- val newLdt =
- LocalDateTime(
- year = ldt.year,
- month = ldt.month,
- day = ldt.day,
- hour = selectedHour,
- minute = selectedMinute,
- second = ldt.second,
- nanosecond = ldt.nanosecond,
- )
- waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
- },
- ldt.hour,
- ldt.minute,
- is24Hour,
- )
-
- Row(
- modifier = Modifier.fillMaxWidth().size(48.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Image(
- imageVector = MeshtasticIcons.CalendarMonth,
- contentDescription = stringResource(Res.string.expires),
- )
- Text(stringResource(Res.string.expires))
- Switch(
- modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
- checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
- onCheckedChange = { isChecked ->
- if (isChecked) {
- waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
- } else {
- waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
- }
- },
- )
- }
-
- if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = selectedDate,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- )
- }
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = selectedTime,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- FlowRow(
- modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.Center,
- ) {
- TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
- Text(stringResource(Res.string.cancel))
- }
- if (waypoint.id != 0) {
- Button(
- modifier = modifier.weight(1f),
- onClick = { onDeleteClicked(waypointInput) },
- enabled = !(waypointInput.name.isNullOrEmpty()),
- ) {
- Text(stringResource(Res.string.delete))
- }
- }
- Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
- Text(stringResource(Res.string.send))
- }
- }
- },
- )
- } else {
- EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
- showEmojiPickerView = false
- waypointInput = waypointInput.copy(icon = it.codePointAt(0))
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-@Suppress("MagicNumber")
-private fun EditWaypointFormPreview() {
- AppTheme {
- EditWaypointDialog(
- waypoint =
- Waypoint(
- id = 123,
- name = "Test 123",
- description = "This is only a test",
- icon = 128169,
- expire = (nowSeconds.toInt() + 8 * 3600),
- ),
- onSendClicked = {},
- onDeleteClicked = {},
- onDismissRequest = {},
- )
- }
-}
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
deleted file mode 100644
index 722111ab6a..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
+++ /dev/null
@@ -1,150 +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.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
-
-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
- )
- }
-
- /** 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
- 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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
deleted file mode 100644
index 0b16e13188..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
+++ /dev/null
@@ -1,138 +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.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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
deleted file mode 100644
index 55e95e2b68..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
+++ /dev/null
@@ -1,160 +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.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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
deleted file mode 100644
index 23abe72697..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
+++ /dev/null
@@ -1,65 +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.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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
deleted file mode 100644
index 77b595d88e..0000000000
--- a/androidApp/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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
deleted file mode 100644
index a6aec4c2dc..0000000000
--- a/androidApp/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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
deleted file mode 100644
index fcf1d47e97..0000000000
--- a/androidApp/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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
deleted file mode 100644
index 95e9a55682..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
+++ /dev/null
@@ -1,289 +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/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
deleted file mode 100644
index 00d3cf9c76..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ /dev/null
@@ -1,64 +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.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/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
deleted file mode 100644
index 6fc3ea54e7..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
+++ /dev/null
@@ -1,28 +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.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/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 20fe0bff6d..c4692fdb4e 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -17,7 +17,6 @@
package org.meshtastic.app.di
import org.koin.core.annotation.Module
-import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
-@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class])
+@Module(includes = [GoogleNetworkModule::class, GoogleAiModule::class])
class FlavorModule
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
deleted file mode 100644
index 940c4ab5a0..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
+++ /dev/null
@@ -1,39 +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
-
-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
-
-/** Google Maps implementation of [MapViewProvider]. */
-@Single
-class GoogleMapViewProvider : 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/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
deleted file mode 100644
index e15d5b5499..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
+++ /dev/null
@@ -1,139 +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
-
-import android.Manifest
-import android.app.Activity
-import android.content.ActivityNotFoundException
-import android.content.pm.PackageManager
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.IntentSenderRequest
-import androidx.activity.result.contract.ActivityResultContracts
-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.platform.LocalContext
-import androidx.core.content.ContextCompat
-import co.touchlab.kermit.Logger
-import com.google.android.gms.common.api.ResolvableApiException
-import com.google.android.gms.location.LocationRequest
-import com.google.android.gms.location.LocationServices
-import com.google.android.gms.location.LocationSettingsRequest
-import com.google.android.gms.location.Priority
-
-private const val INTERVAL_MILLIS = 10000L
-
-@Suppress("LongMethod")
-@Composable
-fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
- val context = LocalContext.current
- var localHasPermission by remember {
- mutableStateOf(
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED,
- )
- }
-
- val requestLocationPermissionLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
- localHasPermission = isGranted
- // Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
- // onPermissionResult
- // if permission is granted. If not granted, immediately report false.
- if (!isGranted) {
- onPermissionResult(false)
- }
- }
-
- val locationSettingsLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- Logger.d { "Location settings changed by user." }
- // User has enabled location services or improved accuracy.
- onPermissionResult(true) // Settings are now adequate, and permission was already granted.
- } else {
- Logger.d { "Location settings change cancelled by user." }
- // User chose not to change settings. The permission itself is still granted,
- // but the experience might be degraded. For the purpose of enabling map features,
- // we consider this as success if the core permission is there.
- // If stricter handling is needed (e.g., block feature if settings not optimal),
- // this logic might change.
- onPermissionResult(localHasPermission)
- }
- }
-
- LaunchedEffect(Unit) {
- // Initial permission check
- when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
- PackageManager.PERMISSION_GRANTED -> {
- if (!localHasPermission) {
- localHasPermission = true
- }
- // If permission is already granted, proceed to check location settings.
- // The LaunchedEffect(localHasPermission) will handle this.
- // No need to call onPermissionResult(true) here yet, let settings check complete.
- }
-
- else -> {
- // Request permission if not granted. The launcher's callback will update localHasPermission.
- requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
- }
- }
- }
-
- LaunchedEffect(localHasPermission) {
- // Handles logic after permission status is known/updated
- if (localHasPermission) {
- // Permission is granted, now check location settings
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
-
- val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
-
- val client = LocationServices.getSettingsClient(context)
- val task = client.checkLocationSettings(builder.build())
-
- task.addOnSuccessListener {
- Logger.d { "Location settings are satisfied." }
- onPermissionResult(true) // Permission granted and settings are good
- }
-
- task.addOnFailureListener { exception ->
- if (exception is ResolvableApiException) {
- try {
- val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
- locationSettingsLauncher.launch(intentSenderRequest)
- // Result of this launch will be handled by locationSettingsLauncher's callback
- } catch (sendEx: ActivityNotFoundException) {
- Logger.d { "Error launching location settings resolution ${sendEx.message}." }
- onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
- }
- } else {
- Logger.d { "Location settings are not satisfiable.${exception.message}" }
- onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
- }
- }
- } else {
- // If permission is not granted, report false.
- // This case is primarily handled by the requestLocationPermissionLauncher's callback
- // if the initial state was denied, or if user denies it.
- onPermissionResult(false)
- }
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
deleted file mode 100644
index c8102ea61b..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
+++ /dev/null
@@ -1,65 +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
-
-import android.database.sqlite.SQLiteDatabase
-import com.google.android.gms.maps.model.Tile
-import com.google.android.gms.maps.model.TileProvider
-import java.io.File
-
-class MBTilesProvider(private val file: File) :
- TileProvider,
- AutoCloseable {
- private var database: SQLiteDatabase? = null
-
- init {
- openDatabase()
- }
-
- private fun openDatabase() {
- if (database == null && file.exists()) {
- database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
- }
- }
-
- override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
- val db = database ?: return null
-
- var tile: Tile? = null
- // Convert Google Maps y coordinate to standard TMS y coordinate
- val tmsY = (1 shl zoom) - 1 - y
-
- val cursor =
- db.rawQuery(
- "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
- arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
- )
-
- if (cursor.moveToFirst()) {
- val tileData = cursor.getBlob(0)
- tile = Tile(256, 256, tileData)
- }
- cursor.close()
-
- return tile ?: TileProvider.NO_TILE
- }
-
- override fun close() {
- database?.close()
- database = null
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
deleted file mode 100644
index 40f2756d9d..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ /dev/null
@@ -1,1126 +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
-
-import android.Manifest
-import android.app.Activity
-import android.content.Intent
-import android.net.Uri
-import android.view.WindowManager
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
-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.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-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.gms.location.LocationCallback
-import com.google.android.gms.location.LocationRequest
-import com.google.android.gms.location.LocationResult
-import com.google.android.gms.location.LocationServices
-import com.google.android.gms.location.Priority
-import com.google.android.gms.maps.CameraUpdateFactory
-import com.google.android.gms.maps.model.CameraPosition
-import com.google.android.gms.maps.model.JointType
-import com.google.android.gms.maps.model.LatLng
-import com.google.android.gms.maps.model.LatLngBounds
-import com.google.maps.android.SphericalUtil
-import com.google.maps.android.compose.CameraPositionState
-import com.google.maps.android.compose.ComposeMapColorScheme
-import com.google.maps.android.compose.GoogleMap
-import com.google.maps.android.compose.MapEffect
-import com.google.maps.android.compose.MapProperties
-import com.google.maps.android.compose.MapType
-import com.google.maps.android.compose.MapUiSettings
-import com.google.maps.android.compose.MapsComposeExperimentalApi
-import com.google.maps.android.compose.Marker
-import com.google.maps.android.compose.MarkerInfoWindowComposable
-import com.google.maps.android.compose.Polyline
-import com.google.maps.android.compose.TileOverlay
-import com.google.maps.android.compose.rememberCameraPositionState
-import com.google.maps.android.compose.rememberUpdatedMarkerState
-import com.google.maps.android.compose.widgets.ScaleBar
-import com.google.maps.android.data.Layer
-import com.google.maps.android.data.geojson.GeoJsonLayer
-import com.google.maps.android.data.kml.KmlLayer
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.stringResource
-import org.json.JSONObject
-import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.map.component.ClusterItemsListDialog
-import org.meshtastic.app.map.component.CustomMapLayersSheet
-import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
-import org.meshtastic.app.map.component.EditWaypointDialog
-import org.meshtastic.app.map.component.MapFilterDropdown
-import org.meshtastic.app.map.component.MapTypeDropdown
-import org.meshtastic.app.map.component.NodeClusterMarkers
-import org.meshtastic.app.map.component.NodeMapFilterDropdown
-import org.meshtastic.app.map.component.WaypointMarkers
-import org.meshtastic.app.map.component.rememberNodeChipDescriptor
-import org.meshtastic.app.map.model.NodeClusterItem
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.TracerouteOverlay
-import org.meshtastic.core.model.util.GeoConstants.DEG_D
-import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
-import org.meshtastic.core.model.util.metersIn
-import org.meshtastic.core.model.util.mpsToKmph
-import org.meshtastic.core.model.util.mpsToMph
-import org.meshtastic.core.model.util.toString
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.alt
-import org.meshtastic.core.resources.heading
-import org.meshtastic.core.resources.latitude
-import org.meshtastic.core.resources.longitude
-import org.meshtastic.core.resources.manage_map_layers
-import org.meshtastic.core.resources.map_tile_source
-import org.meshtastic.core.resources.position
-import org.meshtastic.core.resources.sats
-import org.meshtastic.core.resources.speed
-import org.meshtastic.core.resources.timestamp
-import org.meshtastic.core.resources.track_point
-import org.meshtastic.core.ui.icon.Layers
-import org.meshtastic.core.ui.icon.Map
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.TripOrigin
-import org.meshtastic.core.ui.theme.TracerouteColors
-import org.meshtastic.core.ui.util.formatAgo
-import org.meshtastic.core.ui.util.formatPositionTime
-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.feature.map.tracerouteNodeSelection
-import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
-import org.meshtastic.proto.Position
-import org.meshtastic.proto.Waypoint
-import kotlin.math.abs
-import kotlin.math.max
-
-// region --- Map Mode ---
-
-/**
- * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed
- * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers,
- * controls overlay) is available in every mode.
- */
-sealed interface GoogleMapMode {
- /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */
- data object Main : GoogleMapMode
-
- /** Focused node position track: polyline + gradient markers for historical positions. */
- data class NodeTrack(
- val focusedNode: Node?,
- val positions: List,
- val selectedPositionTime: Int? = null,
- val onPositionSelected: ((Int) -> Unit)? = null,
- ) : GoogleMapMode
-
- /** Traceroute visualization: offset forward/return polylines + hop markers. */
- data class Traceroute(
- val overlay: TracerouteOverlay?,
- val nodePositions: Map,
- val onMappableCountChanged: (shown: Int, total: Int) -> Unit,
- ) : GoogleMapMode
-}
-
-// endregion
-
-private const val TRACEROUTE_OFFSET_METERS = 100.0
-private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
-
-@Suppress("CyclomaticComplexMethod", "LongMethod")
-@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
-@Composable
-fun MapView(
- modifier: Modifier = Modifier,
- mapViewModel: MapViewModel = koinViewModel(),
- navigateToNodeDetails: (Int) -> Unit = {},
- mode: GoogleMapMode = GoogleMapMode.Main,
-) {
- val context = LocalContext.current
- val coroutineScope = rememberCoroutineScope()
- val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
-
- // --- Location permissions ---
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
-
- // --- Location tracking ---
- var isLocationTrackingEnabled by remember { mutableStateOf(false) }
- var followPhoneBearing by remember { mutableStateOf(false) }
-
- LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
- if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
- isLocationTrackingEnabled = true
- triggerLocationToggleAfterPermission = false
- }
- }
-
- // --- File picker for map layers (Main mode) ---
- val filePickerLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- result.data?.data?.let { uri ->
- val fileName = uri.getFileName(context)
- mapViewModel.addMapLayer(uri, fileName)
- }
- }
- }
-
- // --- UI state ---
- var mapFilterMenuExpanded by remember { mutableStateOf(false) }
- val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
- val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
- var editingWaypoint by remember { mutableStateOf(null) }
-
- val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
- val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
-
- var mapTypeMenuExpanded by remember { mutableStateOf(false) }
- var showCustomTileManagerSheet by remember { mutableStateOf(false) }
-
- // --- Camera ---
- // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
- val cameraPositionState =
- if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
-
- if (mode is GoogleMapMode.Main) {
- LaunchedEffect(cameraPositionState.isMoving) {
- if (!cameraPositionState.isMoving) {
- mapViewModel.saveCameraPosition(cameraPositionState.position)
- }
- }
- }
-
- // --- FusedLocation ---
- val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
- val locationCallback = remember {
- object : LocationCallback() {
- override fun onLocationResult(locationResult: LocationResult) {
- if (isLocationTrackingEnabled) {
- locationResult.lastLocation?.let { location ->
- val latLng = LatLng(location.latitude, location.longitude)
- val cameraUpdate =
- if (followPhoneBearing) {
- val bearing =
- if (location.hasBearing()) {
- location.bearing
- } else {
- cameraPositionState.position.bearing
- }
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(latLng)
- .zoom(cameraPositionState.position.zoom)
- .bearing(bearing)
- .build(),
- )
- } else {
- CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
- }
- coroutineScope.launch {
- try {
- cameraPositionState.animate(cameraUpdate)
- } catch (e: IllegalStateException) {
- Logger.d { "Error animating camera to location: ${e.message}" }
- }
- }
- }
- }
- }
- }
- }
-
- LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
- if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
- val locationRequest =
- LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
- .setMinUpdateIntervalMillis(2000L)
- .build()
- try {
- @Suppress("MissingPermission")
- fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
- Logger.d { "Started location tracking" }
- } catch (e: SecurityException) {
- Logger.d { "Location permission not available: ${e.message}" }
- isLocationTrackingEnabled = false
- }
- } else {
- fusedLocationClient.removeLocationUpdates(locationCallback)
- Logger.d { "Stopped location tracking" }
- }
- }
-
- DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
-
- // --- Node & waypoint data ---
- val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
- val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
- val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
- val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
-
- val filteredNodes =
- allNodes
- .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
- .filter { node ->
- mapFilterState.lastHeardFilter.seconds == 0L ||
- (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
- node.num == ourNodeInfo?.num
- }
-
- val myNodeNum = mapViewModel.myNodeNum
- val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
- val theme by mapViewModel.theme.collectAsStateWithLifecycle()
- val dark =
- when (theme) {
- AppCompatDelegate.MODE_NIGHT_YES -> true
- AppCompatDelegate.MODE_NIGHT_NO -> false
- AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
- else -> isSystemInDarkTheme()
- }
- val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
-
- // --- Mode-specific data ---
- // Node track: apply time filter
- val sortedTrackPositions =
- if (mode is GoogleMapMode.NodeTrack) {
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- remember(mode.positions, lastHeardTrackFilter) {
- mode.positions
- .filter {
- lastHeardTrackFilter == LastHeardFilter.Any ||
- it.time > nowSeconds - lastHeardTrackFilter.seconds
- }
- .sortedBy { it.time }
- }
- } else {
- emptyList()
- }
-
- // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules
- // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all
- // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops
- // whose positions come from snapshots.
- val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf())
- val tracerouteSelection =
- if (mode is GoogleMapMode.Traceroute) {
- remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = mode.overlay,
- tracerouteNodePositions = mode.nodePositions,
- nodes = allNodesForTraceroute,
- )
- }
- } else {
- null
- }
- val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList()
-
- if (mode is GoogleMapMode.Traceroute) {
- LaunchedEffect(mode.overlay, tracerouteDisplayNodes) {
- if (mode.overlay != null) {
- mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size)
- }
- }
- }
-
- val tracerouteForwardPoints: List =
- if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
- val nodeLookup = tracerouteSelection.nodeLookup
- remember(mode.overlay, nodeLookup) {
- mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
- }
- } else {
- emptyList()
- }
- val tracerouteReturnPoints: List =
- if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
- val nodeLookup = tracerouteSelection.nodeLookup
- remember(mode.overlay, nodeLookup) {
- mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
- }
- } else {
- emptyList()
- }
- val tracerouteHeadingReferencePoints =
- remember(tracerouteForwardPoints, tracerouteReturnPoints) {
- when {
- tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
- tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
- else -> emptyList()
- }
- }
- val tracerouteForwardOffsetPoints =
- remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0)
- }
- val tracerouteReturnOffsetPoints =
- remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0)
- }
-
- // Auto-centering for NodeTrack / Traceroute modes
- var hasCentered by remember(mode) { mutableStateOf(false) }
-
- if (mode is GoogleMapMode.NodeTrack) {
- LaunchedEffect(sortedTrackPositions, hasCentered) {
- if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect
- val points = sortedTrackPositions.map { it.toLatLng() }
- val cameraUpdate =
- if (points.size == 1) {
- CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f))
- } else {
- val bounds = LatLngBounds.builder()
- points.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), 80)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCentered = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering track map: ${e.message}" }
- }
- }
-
- // Animate to selected position marker when card is tapped in the list
- LaunchedEffect(mode.selectedPositionTime) {
- val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect
- val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect
- try {
- cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
- } catch (e: IllegalStateException) {
- Logger.d { "Error animating to selected position: ${e.message}" }
- }
- }
- }
-
- if (mode is GoogleMapMode.Traceroute) {
- LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (mode.overlay == null || hasCentered) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- val cameraUpdate =
- if (allPoints.size == 1) {
- CameraUpdateFactory.newLatLngZoom(
- allPoints.first(),
- max(cameraPositionState.position.zoom, 12f),
- )
- } else {
- val bounds = LatLngBounds.builder()
- allPoints.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCentered = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering traceroute overlay: ${e.message}" }
- }
- }
- }
- }
-
- // --- Tile & layers state ---
- var showLayersBottomSheet by remember { mutableStateOf(false) }
-
- val onAddLayerClicked = {
- val intent =
- Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "*/*"
- val mimeTypes =
- arrayOf(
- "application/vnd.google-earth.kml+xml",
- "application/vnd.google-earth.kmz",
- "application/vnd.geo+json",
- "application/geo+json",
- "application/json",
- )
- putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
- }
- filePickerLauncher.launch(intent)
- }
- val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
- val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
-
- val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
-
- var showClusterItemsDialog by remember { mutableStateOf?>(null) }
-
- // --- Keep screen on while location tracking ---
- LaunchedEffect(isLocationTrackingEnabled) {
- val activity = context as? Activity ?: return@LaunchedEffect
- val window = activity.window
- if (isLocationTrackingEnabled) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
-
- // --- Main UI ---
- val isMainMode = mode is GoogleMapMode.Main
-
- Box(modifier = modifier) {
- GoogleMap(
- mapColorScheme = mapColorScheme,
- modifier = Modifier.fillMaxSize(),
- cameraPositionState = cameraPositionState,
- uiSettings =
- MapUiSettings(
- zoomControlsEnabled = true,
- mapToolbarEnabled = isMainMode,
- compassEnabled = false,
- myLocationButtonEnabled = false,
- rotationGesturesEnabled = true,
- scrollGesturesEnabled = true,
- tiltGesturesEnabled = isMainMode,
- zoomGesturesEnabled = true,
- ),
- properties =
- MapProperties(
- mapType = effectiveGoogleMapType,
- isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
- ),
- onMapLongClick = { latLng ->
- if (isMainMode && isConnected) {
- editingWaypoint =
- Waypoint(
- latitude_i = (latLng.latitude / DEG_D).toInt(),
- longitude_i = (latLng.longitude / DEG_D).toInt(),
- )
- }
- },
- ) {
- // Custom tile overlay (all modes)
- key(currentCustomTileProviderUrl) {
- currentCustomTileProviderUrl?.let { url ->
- val config =
- mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
- it.urlTemplate == url || it.localUri == url
- }
- mapViewModel.getTileProvider(config)?.let { tileProvider ->
- TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
- }
- }
- }
-
- when (mode) {
- is GoogleMapMode.Main ->
- MainMapContent(
- nodeClusterItems =
- filteredNodes.map { node ->
- val latLng =
- LatLng(
- (node.position.latitude_i ?: 0) * DEG_D,
- (node.position.longitude_i ?: 0) * DEG_D,
- )
- NodeClusterItem(
- node = node,
- nodePosition = latLng,
- nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
- nodeSnippet = "${node.user.long_name}",
- myNodeNum = myNodeNum,
- )
- },
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- displayableWaypoints = displayableWaypoints,
- myNodeNum = myNodeNum,
- isConnected = isConnected,
- onEditWaypointRequest = { editingWaypoint = it },
- selectedWaypointId = selectedWaypointId,
- mapLayers = mapLayers,
- mapViewModel = mapViewModel,
- cameraPositionState = cameraPositionState,
- coroutineScope = coroutineScope,
- onShowClusterItemsDialog = { showClusterItemsDialog = it },
- )
-
- is GoogleMapMode.NodeTrack -> {
- val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
- if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
- NodeTrackOverlay(
- focusedNode = mode.focusedNode,
- sortedPositions = sortedTrackPositions,
- displayUnits = displayUnits,
- myNodeNum = myNodeNum,
- selectedPositionTime = mode.selectedPositionTime,
- onPositionSelected = mode.onPositionSelected,
- )
- }
- }
-
- is GoogleMapMode.Traceroute ->
- TracerouteMapContent(
- forwardOffsetPoints = tracerouteForwardOffsetPoints,
- returnOffsetPoints = tracerouteReturnOffsetPoints,
- forwardPointCount = tracerouteForwardPoints.size,
- returnPointCount = tracerouteReturnPoints.size,
- displayNodes = tracerouteDisplayNodes,
- )
- }
- }
-
- // Scale bar
- ScaleBar(
- cameraPositionState = cameraPositionState,
- modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp),
- )
-
- // Waypoint edit dialog (Main mode only)
- if (isMainMode) {
- editingWaypoint?.let { waypointToEdit ->
- EditWaypointDialog(
- waypoint = waypointToEdit,
- onSendClicked = { updatedWp ->
- var finalWp = updatedWp
- if (updatedWp.id == 0) {
- finalWp = finalWp.copy(id = mapViewModel.generatePacketId())
- }
- if (updatedWp.icon == 0) {
- finalWp = finalWp.copy(icon = 0x1F4CD)
- }
- mapViewModel.sendWaypoint(finalWp)
- editingWaypoint = null
- },
- onDeleteClicked = { wpToDelete ->
- if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) {
- mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
- }
- mapViewModel.deleteWaypoint(wpToDelete.id)
- editingWaypoint = null
- },
- onDismissRequest = { editingWaypoint = null },
- )
- }
- }
-
- // Controls overlay
- val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
- val showRefresh = visibleNetworkLayers.isNotEmpty()
- val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
-
- MapControlsOverlay(
- modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
- onToggleFilterMenu = { mapFilterMenuExpanded = true },
- filterDropdownContent = {
- if (mode is GoogleMapMode.NodeTrack) {
- NodeMapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = { mapFilterMenuExpanded = false },
- mapViewModel = mapViewModel,
- )
- } else {
- MapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = { mapFilterMenuExpanded = false },
- mapViewModel = mapViewModel,
- )
- }
- },
- mapTypeContent = {
- Box {
- MapButton(
- icon = MeshtasticIcons.Map,
- contentDescription = stringResource(Res.string.map_tile_source),
- onClick = { mapTypeMenuExpanded = true },
- )
- MapTypeDropdown(
- expanded = mapTypeMenuExpanded,
- onDismissRequest = { mapTypeMenuExpanded = false },
- mapViewModel = mapViewModel,
- onManageCustomTileProvidersClicked = {
- mapTypeMenuExpanded = false
- showCustomTileManagerSheet = true
- },
- )
- }
- },
- layersContent = {
- MapButton(
- icon = MeshtasticIcons.Layers,
- contentDescription = stringResource(Res.string.manage_map_layers),
- onClick = { showLayersBottomSheet = true },
- )
- },
- isLocationTrackingEnabled = isLocationTrackingEnabled,
- onToggleLocationTracking = {
- if (locationPermissionsState.allPermissionsGranted) {
- isLocationTrackingEnabled = !isLocationTrackingEnabled
- if (!isLocationTrackingEnabled) {
- followPhoneBearing = false
- }
- } else {
- triggerLocationToggleAfterPermission = true
- locationPermissionsState.launchMultiplePermissionRequest()
- }
- },
- bearing = cameraPositionState.position.bearing,
- onCompassClick = {
- if (isLocationTrackingEnabled) {
- followPhoneBearing = !followPhoneBearing
- } else {
- coroutineScope.launch {
- try {
- val currentPosition = cameraPositionState.position
- val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
- cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
- Logger.d { "Oriented map to north" }
- } catch (e: IllegalStateException) {
- Logger.d { "Error orienting map to north: ${e.message}" }
- }
- }
- }
- },
- followPhoneBearing = followPhoneBearing,
- showRefresh = showRefresh,
- isRefreshing = isRefreshingLayers,
- onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
- )
- }
-
- // --- Bottom sheets & dialogs ---
- if (showLayersBottomSheet) {
- ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
- CustomMapLayersSheet(
- mapLayers = mapLayers,
- onToggleVisibility = onToggleVisibility,
- onRemoveLayer = onRemoveLayer,
- onAddLayerClicked = onAddLayerClicked,
- onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
- onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
- )
- }
- }
- showClusterItemsDialog?.let {
- ClusterItemsListDialog(
- items = it,
- onDismiss = { showClusterItemsDialog = null },
- onItemClick = { item ->
- navigateToNodeDetails(item.node.num)
- showClusterItemsDialog = null
- },
- )
- }
- if (showCustomTileManagerSheet) {
- ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
- CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
- }
- }
-}
-
-// region --- Main Map Content ---
-
-@Suppress("LongParameterList")
-@OptIn(MapsComposeExperimentalApi::class)
-@Composable
-private fun MainMapContent(
- nodeClusterItems: List,
- mapFilterState: MapFilterState,
- navigateToNodeDetails: (Int) -> Unit,
- displayableWaypoints: List,
- myNodeNum: Int?,
- isConnected: Boolean,
- onEditWaypointRequest: (Waypoint) -> Unit,
- selectedWaypointId: Int?,
- mapLayers: List,
- mapViewModel: MapViewModel,
- cameraPositionState: CameraPositionState,
- coroutineScope: CoroutineScope,
- onShowClusterItemsDialog: (List?) -> Unit,
-) {
- NodeClusterMarkers(
- nodeClusterItems = nodeClusterItems,
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- onClusterClick = { cluster ->
- val items = cluster.items.toList()
- val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
- if (allSameLocation) {
- onShowClusterItemsDialog(items)
- } else {
- val bounds = LatLngBounds.builder()
- cluster.items.forEach { bounds.include(it.position) }
- coroutineScope.launch {
- cameraPositionState.animate(
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(bounds.build().center)
- .zoom(cameraPositionState.position.zoom + 1)
- .build(),
- ),
- )
- }
- Logger.d { "Cluster clicked! $cluster" }
- }
- true
- },
- )
-
- WaypointMarkers(
- displayableWaypoints = displayableWaypoints,
- mapFilterState = mapFilterState,
- myNodeNum = myNodeNum ?: 0,
- isConnected = isConnected,
- onEditWaypointRequest = onEditWaypointRequest,
- selectedWaypointId = selectedWaypointId,
- )
-
- mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
-}
-
-// endregion
-
-// region --- Node Track Overlay ---
-
-/**
- * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
- * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
- * [TripOrigin] dot with an info-window on tap.
- *
- * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
- * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
- */
-@OptIn(MapsComposeExperimentalApi::class)
-@Composable
-@Suppress("LongMethod")
-private fun NodeTrackOverlay(
- focusedNode: Node,
- sortedPositions: List,
- displayUnits: DisplayUnits,
- myNodeNum: Int?,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
- val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
- val activeNodeZIndex = if (isHighPriority) 5f else 4f
- val selectedColor = MaterialTheme.colorScheme.primary
-
- sortedPositions.forEachIndexed { index, position ->
- key(position.time) {
- val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
- val alpha =
- if (sortedPositions.size > 1) {
- index.toFloat() / (sortedPositions.size.toFloat() - 1)
- } else {
- 1f
- }
- val isSelected = position.time == selectedPositionTime
- val color =
- if (isSelected) {
- selectedColor
- } else {
- Color(focusedNode.colors.second).copy(alpha = alpha)
- }
-
- if (index == sortedPositions.lastIndex) {
- val chipIcon = rememberNodeChipDescriptor(focusedNode)
- Marker(
- state = markerState,
- icon = chipIcon,
- zIndex = activeNodeZIndex,
- alpha = if (isHighPriority) 1.0f else 0.9f,
- onClick = {
- onPositionSelected?.invoke(position.time)
- false // Allow default info window behavior
- },
- )
- } else {
- MarkerInfoWindowComposable(
- state = markerState,
- title = stringResource(Res.string.position),
- snippet = formatAgo(position.time),
- zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
- onClick = {
- onPositionSelected?.invoke(position.time)
- false // Allow default info window behavior
- },
- infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
- ) {
- Icon(
- imageVector = MeshtasticIcons.TripOrigin,
- contentDescription = stringResource(Res.string.track_point),
- tint = color,
- modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
- )
- }
- }
- }
- }
-
- // Gradient polyline segments
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = index.toFloat() / (segments.size.toFloat() - 1)
- Polyline(
- points = segmentPoints.map { it.toLatLng() },
- jointType = JointType.ROUND,
- color = Color(focusedNode.colors.second).copy(alpha = alpha),
- width = 8f,
- zIndex = 0.6f,
- )
- }
- }
-}
-
-@Composable
-@Suppress("LongMethod")
-private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
- @Composable
- fun PositionRow(label: String, value: String) {
- Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
- Text(label, style = MaterialTheme.typography.labelMedium)
- Spacer(modifier = Modifier.width(16.dp))
- Text(value, style = MaterialTheme.typography.labelMedium)
- }
- }
-
- Card {
- Column(modifier = Modifier.padding(8.dp)) {
- PositionRow(
- label = stringResource(Res.string.latitude),
- value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
- )
- PositionRow(
- label = stringResource(Res.string.longitude),
- value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
- )
- PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString())
- PositionRow(
- label = stringResource(Res.string.alt),
- value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
- )
- PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
- PositionRow(
- label = stringResource(Res.string.heading),
- value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
- )
- PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
- }
- }
-}
-
-@Composable
-private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
- val speedInMps = position.ground_speed ?: 0
- val mpsText = "%d m/s".format(speedInMps)
- return if (speedInMps > 10) {
- when (displayUnits) {
- DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
- DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
- }
- } else {
- mpsText
- }
-}
-
-// endregion
-
-// region --- Traceroute Map Content ---
-
-@Composable
-private fun TracerouteMapContent(
- forwardOffsetPoints: List,
- returnOffsetPoints: List,
- forwardPointCount: Int,
- returnPointCount: Int,
- displayNodes: List,
-) {
- if (forwardPointCount >= 2) {
- Polyline(
- points = forwardOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.OutgoingRoute,
- width = 9f,
- zIndex = 3.0f,
- )
- }
- if (returnPointCount >= 2) {
- Polyline(
- points = returnOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.ReturnRoute,
- width = 7f,
- zIndex = 2.5f,
- )
- }
- displayNodes.forEach { node ->
- val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
- val chipIcon = rememberNodeChipDescriptor(node)
- Marker(state = markerState, icon = chipIcon, zIndex = 4f)
- }
-}
-
-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 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
-
- headingPoints.lastIndex ->
- SphericalUtil.computeHeading(
- headingPoints[headingPoints.lastIndex - 1],
- headingPoints[headingPoints.lastIndex],
- )
-
- else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
- }
- }
-
- return points.mapIndexed { index, point ->
- val heading = headings[index.coerceIn(0, headings.lastIndex)]
- val perpendicularHeading = heading + (90.0 * sideMultiplier)
- SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
- }
-}
-
-// endregion
-
-// region --- Map Layers ---
-
-@Composable
-private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
- val context = LocalContext.current
- var currentLayer by remember { mutableStateOf(null) }
-
- MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
- currentLayer?.safeRemoveLayerFromMap()
- currentLayer = null
- val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
- val layer =
- try {
- when (layerItem.layerType) {
- LayerType.KML -> KmlLayer(map, inputStream, context)
-
- LayerType.GEOJSON ->
- GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
- }
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
- null
- }
- layer?.let {
- if (layerItem.isVisible) it.safeAddLayerToMap()
- currentLayer = it
- }
- }
-
- DisposableEffect(layerItem.id) {
- onDispose {
- currentLayer?.safeRemoveLayerFromMap()
- currentLayer = null
- }
- }
-
- LaunchedEffect(layerItem.isVisible) {
- val layer = currentLayer ?: return@LaunchedEffect
- if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap()
- }
-}
-
-private fun Layer.safeRemoveLayerFromMap() {
- try {
- removeLayerFromMap()
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error removing map layer" }
- }
-}
-
-private fun Layer.safeAddLayerToMap() {
- try {
- if (!isLayerOnMap) addLayerToMap()
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error adding map layer" }
- }
-}
-
-// endregion
-
-// region --- Utilities ---
-
-internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
- String(Character.toChars(unicodeCodePoint))
-} catch (e: IllegalArgumentException) {
- Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
- "\uD83D\uDCCD"
-}
-
-@Suppress("NestedBlockDepth")
-fun Uri.getFileName(context: android.content.Context): String {
- var name = this.lastPathSegment ?: "layer_$nowMillis"
- if (this.scheme == "content") {
- context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
- if (displayNameIndex != -1) {
- name = cursor.getString(displayNameIndex)
- }
- }
- }
- }
- return name
-}
-
-/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
-internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
-
-// endregion
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
deleted file mode 100644
index 8a4a798a81..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ /dev/null
@@ -1,690 +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
-
-import android.app.Application
-import android.net.Uri
-import androidx.core.net.toFile
-import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
-import co.touchlab.kermit.Logger
-import com.google.android.gms.maps.model.CameraPosition
-import com.google.android.gms.maps.model.LatLng
-import com.google.android.gms.maps.model.TileProvider
-import com.google.android.gms.maps.model.UrlTileProvider
-import com.google.maps.android.compose.CameraPositionState
-import com.google.maps.android.compose.MapType
-import io.ktor.client.HttpClient
-import io.ktor.client.request.get
-import io.ktor.client.statement.bodyAsChannel
-import io.ktor.http.isSuccess
-import io.ktor.utils.io.jvm.javaio.toInputStream
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.mapNotNull
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.Serializable
-import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.app.map.model.CustomTileProviderConfig
-import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
-import org.meshtastic.app.map.repository.CustomTileProviderRepository
-import org.meshtastic.core.di.CoroutineDispatchers
-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.repository.UiPrefs
-import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.feature.map.BaseMapViewModel
-import org.meshtastic.proto.Config
-import java.io.File
-import java.io.FileOutputStream
-import java.io.IOException
-import java.io.InputStream
-import java.net.MalformedURLException
-import java.net.URL
-import kotlin.uuid.Uuid
-
-private const val TILE_SIZE = 256
-
-@Serializable
-data class MapCameraPosition(
- val targetLat: Double,
- val targetLng: Double,
- val zoom: Float,
- val tilt: Float,
- val bearing: Float,
-)
-
-@Suppress("TooManyFunctions", "LongParameterList")
-@KoinViewModel
-class MapViewModel(
- private val application: Application,
- private val dispatchers: CoroutineDispatchers,
- private val httpClient: HttpClient,
- mapPrefs: MapPrefs,
- private val googleMapsPrefs: GoogleMapsPrefs,
- nodeRepository: NodeRepository,
- packetRepository: PacketRepository,
- radioConfigRepository: RadioConfigRepository,
- radioController: RadioController,
- private val customTileProviderRepository: CustomTileProviderRepository,
- uiPrefs: UiPrefs,
- 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
- if (id != null) {
- viewModelScope.launch {
- val wpMap = waypoints.first { it.containsKey(id) }
- wpMap[id]?.let { packet ->
- val waypoint = packet.waypoint!!
- val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
- cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
- }
- }
- }
- }
- }
-
- private val targetLatLng =
- googleMapsPrefs.cameraTargetLat.value
- .takeIf { it != 0.0 }
- ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
- ?: ourNodeInfo.value?.position?.toLatLng()
- ?: LatLng(0.0, 0.0)
-
- val cameraPositionState =
- CameraPositionState(
- position =
- CameraPosition(
- targetLatLng,
- googleMapsPrefs.cameraZoom.value,
- googleMapsPrefs.cameraTilt.value,
- googleMapsPrefs.cameraBearing.value,
- ),
- )
-
- val theme: StateFlow = uiPrefs.theme
-
- private val _errorFlow = MutableSharedFlow()
- val errorFlow: Flow = _errorFlow.asFlow()
-
- val customTileProviderConfigs: StateFlow> =
- customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
-
- private val _selectedCustomTileProviderUrl = MutableStateFlow(null)
- val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow()
-
- private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
- val selectedGoogleMapType: StateFlow = _selectedGoogleMapType.asStateFlow()
-
- val displayUnits =
- radioConfigRepository.deviceProfileFlow
- .mapNotNull { it.config?.display?.units }
- .stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
-
- fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
- viewModelScope.launch {
- if (
- name.isBlank() ||
- (urlTemplate.isBlank() && localUri == null) ||
- (localUri == null && !isValidTileUrlTemplate(urlTemplate))
- ) {
- _errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.")
- return@launch
- }
- if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
- _errorFlow.emit("Custom tile provider with name '$name' already exists.")
- return@launch
- }
-
- var finalLocalUri = localUri
- if (localUri != null) {
- try {
- val uri = Uri.parse(localUri)
- val extension = "mbtiles"
- val finalFileName = "mbtiles_${Uuid.random()}.$extension"
- val copiedUri = copyFileToInternalStorage(uri, finalFileName)
- if (copiedUri != null) {
- finalLocalUri = copiedUri.toString()
- } else {
- _errorFlow.emit("Failed to copy MBTiles file to internal storage.")
- return@launch
- }
- } catch (e: Exception) {
- Logger.withTag("MapViewModel").e(e) { "Error processing local URI" }
- _errorFlow.emit("Error processing local URI for MBTiles.")
- return@launch
- }
- }
-
- val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
- customTileProviderRepository.addCustomTileProvider(newConfig)
- }
- }
-
- fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
- viewModelScope.launch {
- if (
- configToUpdate.name.isBlank() ||
- (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
- (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
- ) {
- _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
- return@launch
- }
- val existingConfigs = customTileProviderConfigs.value
- if (
- existingConfigs.any {
- it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
- }
- ) {
- _errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
- return@launch
- }
-
- customTileProviderRepository.updateCustomTileProvider(configToUpdate)
-
- val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
- if (
- _selectedCustomTileProviderUrl.value != null &&
- originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
- ) {
- // No change needed if URL didn't change, or handle if it did
- } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
- val currentlySelectedConfig =
- customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
- if (currentlySelectedConfig?.id == configToUpdate.id) {
- _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
- }
- }
- }
- }
-
- fun removeCustomTileProvider(configId: String) {
- viewModelScope.launch {
- val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
- customTileProviderRepository.deleteCustomTileProvider(configId)
-
- if (configToRemove != null) {
- if (
- _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
- _selectedCustomTileProviderUrl.value == configToRemove.localUri
- ) {
- _selectedCustomTileProviderUrl.value = null
- // Also clear from prefs
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- }
-
- if (configToRemove.localUri != null) {
- val uri = Uri.parse(configToRemove.localUri)
- deleteFileToInternalStorage(uri)
- }
- }
- }
- }
-
- fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
- if (config != null) {
- if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
- Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
- _selectedCustomTileProviderUrl.value = null
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- return
- }
- // Use localUri if present, otherwise urlTemplate
- val selectedUrl = config.localUri ?: config.urlTemplate
- _selectedCustomTileProviderUrl.value = selectedUrl
- _selectedGoogleMapType.value = MapType.NONE
- googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
- googleMapsPrefs.setSelectedGoogleMapType(null)
- } else {
- _selectedCustomTileProviderUrl.value = null
- _selectedGoogleMapType.value = MapType.NORMAL
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
- }
- }
-
- fun setSelectedGoogleMapType(mapType: MapType) {
- _selectedGoogleMapType.value = mapType
- _selectedCustomTileProviderUrl.value = null // Clear custom selection
- googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- }
-
- private var currentTileProvider: TileProvider? = null
-
- fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
- if (config == null) {
- (currentTileProvider as? MBTilesProvider)?.close()
- currentTileProvider = null
- return null
- }
-
- val selectedUrl = config.localUri ?: config.urlTemplate
- if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
- return currentTileProvider
- }
-
- // Close previous if it was a local provider
- (currentTileProvider as? MBTilesProvider)?.close()
-
- val newProvider =
- if (config.isLocal) {
- val uri = Uri.parse(config.localUri)
- val file =
- try {
- uri.toFile()
- } catch (e: Exception) {
- File(uri.path ?: "")
- }
- if (file.exists()) {
- MBTilesProvider(file)
- } else {
- Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
- null
- }
- } else {
- val urlString = config.urlTemplate
- if (!isValidTileUrlTemplate(urlString)) {
- Logger.withTag("MapViewModel")
- .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
- null
- } else {
- object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
- override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
- val subdomains = listOf("a", "b", "c")
- val subdomain = subdomains[(x + y) % subdomains.size]
- val formattedUrl =
- urlString
- .replace("{s}", subdomain, ignoreCase = true)
- .replace("{z}", zoom.toString(), ignoreCase = true)
- .replace("{x}", x.toString(), ignoreCase = true)
- .replace("{y}", y.toString(), ignoreCase = true)
- return try {
- URL(formattedUrl)
- } catch (e: MalformedURLException) {
- Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
- null
- }
- }
- }
- }
- }
-
- currentTileProvider = newProvider
- return newProvider
- }
-
- private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
- urlTemplate.contains("{x}", ignoreCase = true) &&
- urlTemplate.contains("{y}", ignoreCase = true)
-
- private val _mapLayers = MutableStateFlow>(emptyList())
- val mapLayers: StateFlow> = _mapLayers.asStateFlow()
-
- init {
- viewModelScope.launch {
- customTileProviderRepository.getCustomTileProviders().first()
- loadPersistedMapType()
- }
- loadPersistedLayers()
-
- selectedWaypointId.value?.let { wpId ->
- viewModelScope.launch {
- val wpMap = waypoints.first { it.containsKey(wpId) }
- wpMap[wpId]?.let { packet ->
- val waypoint = packet.waypoint!!
- val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
- cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
- }
- }
- }
- }
-
- fun saveCameraPosition(cameraPosition: CameraPosition) {
- viewModelScope.launch {
- googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
- googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
- googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
- googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
- googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
- }
- }
-
- private fun loadPersistedMapType() {
- val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
- if (savedCustomUrl != null) {
- // Check if this custom provider still exists
- if (
- customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } &&
- isValidTileUrlTemplate(savedCustomUrl)
- ) {
- _selectedCustomTileProviderUrl.value = savedCustomUrl
- _selectedGoogleMapType.value =
- MapType.NONE // MapType.NONE to hide google basemap when using custom provider
- } else {
- // The saved custom URL is no longer valid or doesn't exist, remove preference
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- // Fallback to default Google Map type
- _selectedGoogleMapType.value = MapType.NORMAL
- }
- } else {
- val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
- try {
- _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
- } catch (e: IllegalArgumentException) {
- Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
- _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
- googleMapsPrefs.setSelectedGoogleMapType(null)
- }
- }
- }
-
- private fun loadPersistedLayers() {
- viewModelScope.launch(dispatchers.io) {
- try {
- val layersDir = File(application.filesDir, "map_layers")
- if (layersDir.exists() && layersDir.isDirectory) {
- val persistedLayerFiles = layersDir.listFiles()
-
- if (persistedLayerFiles != null) {
- val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
- val loadedItems =
- persistedLayerFiles.mapNotNull { file ->
- if (file.isFile) {
- val layerType =
- when (file.extension.lowercase()) {
- "kml",
- "kmz",
- -> LayerType.KML
-
- "geojson",
- "json",
- -> LayerType.GEOJSON
-
- else -> null
- }
-
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
- }
- } else {
- null
- }
- }
-
- val networkItems =
- googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
- try {
- val parts = networkString.split("|:|")
- if (parts.size == 3) {
- val id = parts[0]
- val name = parts[1]
- val uri = Uri.parse(parts[2])
- MapLayerItem(
- id = id,
- name = name,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = LayerType.KML,
- isNetwork = true,
- )
- } else {
- null
- }
- } catch (e: Exception) {
- null
- }
- }
-
- _mapLayers.value = loadedItems + networkItems
- if (_mapLayers.value.isNotEmpty()) {
- Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
- }
- }
- } else {
- Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
- }
- } catch (e: Exception) {
- Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" }
- _mapLayers.value = emptyList()
- }
- }
- }
-
- fun addMapLayer(uri: Uri, fileName: String?) {
- viewModelScope.launch {
- val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
-
- val extension =
- fileName?.substringAfterLast('.', "")?.lowercase()
- ?: application.contentResolver.getType(uri)?.split('/')?.last()
-
- val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
- val geoJsonExtensions = listOf("geojson", "json")
-
- val layerType =
- when (extension) {
- in kmlExtensions -> LayerType.KML
- in geoJsonExtensions -> LayerType.GEOJSON
- else -> null
- }
-
- if (layerType == null) {
- Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension")
- return@launch
- }
-
- val finalFileName =
- if (fileName != null) {
- "$layerName.$extension"
- } else {
- "layer_${Uuid.random()}.$extension"
- }
-
- val localFileUri = copyFileToInternalStorage(uri, finalFileName)
-
- if (localFileUri != null) {
- val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
- _mapLayers.value = _mapLayers.value + newItem
- } else {
- Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.")
- }
- }
- }
-
- fun addNetworkMapLayer(name: String, url: String) {
- viewModelScope.launch {
- if (name.isBlank() || url.isBlank()) {
- _errorFlow.emit("Invalid name or URL for network layer.")
- return@launch
- }
- try {
- val uri = Uri.parse(url)
- if (uri.scheme != "http" && uri.scheme != "https") {
- _errorFlow.emit("URL must be http or https.")
- return@launch
- }
-
- val path = uri.path?.lowercase() ?: ""
- val layerType =
- when {
- path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
- else -> LayerType.KML // Default to KML
- }
-
- val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
- _mapLayers.value = _mapLayers.value + newItem
-
- val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
- googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
- } catch (e: Exception) {
- _errorFlow.emit("Invalid URL.")
- }
- }
- }
-
- private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
- try {
- val inputStream = application.contentResolver.openInputStream(uri)
- val directory = File(application.filesDir, "map_layers")
- if (!directory.exists()) {
- directory.mkdirs()
- }
- val outputFile = File(directory, fileName)
- val outputStream = FileOutputStream(outputFile)
-
- inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
- Uri.fromFile(outputFile)
- } catch (e: IOException) {
- Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" }
- null
- }
- }
-
- fun toggleLayerVisibility(layerId: String) {
- var toggledLayer: MapLayerItem? = null
- val updatedLayers =
- _mapLayers.value.map {
- if (it.id == layerId) {
- toggledLayer = it.copy(isVisible = !it.isVisible)
- toggledLayer
- } else {
- it
- }
- }
- _mapLayers.value = updatedLayers
-
- toggledLayer?.let {
- if (it.isVisible) {
- googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
- } else {
- googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
- }
- }
- }
-
- fun removeMapLayer(layerId: String) {
- viewModelScope.launch {
- val layerToRemove = _mapLayers.value.find { it.id == layerId }
- layerToRemove?.uri?.let { uri ->
- if (layerToRemove.isNetwork) {
- googleMapsPrefs.setNetworkMapLayers(
- googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
- )
- } else {
- deleteFileToInternalStorage(uri)
- }
- googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
- }
- _mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
- }
- }
-
- fun refreshMapLayer(layerId: String) {
- viewModelScope.launch {
- _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
- // By resetting the layer data in the UI (implied by just refreshing),
- // we trigger a reload in the Composable.
- _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
- }
- }
-
- fun refreshAllVisibleNetworkLayers() {
- _mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
- }
-
- private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(dispatchers.io) {
- try {
- val file = uri.toFile()
- if (file.exists()) {
- file.delete()
- }
- } catch (e: Exception) {
- Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" }
- }
- }
- }
-
- @Suppress("Recycle")
- suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
- val uriToLoad = layerItem.uri ?: return null
- return withContext(dispatchers.io) {
- try {
- if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
- val response = httpClient.get(uriToLoad.toString())
- if (!response.status.isSuccess()) {
- Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
- return@withContext null
- }
- response.bodyAsChannel().toInputStream()
- } else {
- application.contentResolver.openInputStream(uriToLoad)
- }
- } catch (e: Exception) {
- Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
- null
- }
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- (currentTileProvider as? MBTilesProvider)?.close()
- }
-
- override fun getUser(userId: String?) =
- nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
-}
-
-enum class LayerType {
- KML,
- GEOJSON,
-}
-
-data class MapLayerItem(
- val id: String = Uuid.random().toString(),
- val name: String,
- val uri: Uri? = null,
- val isVisible: Boolean = true,
- val layerType: LayerType,
- val isNetwork: Boolean = false,
- val isRefreshing: Boolean = false,
-)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
deleted file mode 100644
index d69e3f316b..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.model.NodeClusterItem
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.nodes_at_this_location
-import org.meshtastic.core.resources.okay
-import org.meshtastic.core.ui.component.NodeChip
-
-@Composable
-fun ClusterItemsListDialog(
- items: List,
- onDismiss: () -> Unit,
- onItemClick: (NodeClusterItem) -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(text = stringResource(Res.string.nodes_at_this_location)) },
- text = {
- // Use a LazyColumn for potentially long lists of items
- LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
- items(items, key = { it.node.num }) { item ->
- ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
- }
- }
- },
- confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.okay)) } },
- )
-}
-
-@Composable
-private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
- ListItem(
- leadingContent = { NodeChip(node = item.node) },
- headlineContent = { Text(item.nodeTitle) },
- supportingContent = {
- if (item.nodeSnippet.isNotBlank()) {
- Text(item.nodeSnippet)
- }
- },
- modifier =
- modifier
- .fillMaxWidth()
- .clickable(onClick = onClick)
- .padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
deleted file mode 100644
index 91c5da2d8c..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-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.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconToggleButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-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.unit.dp
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapLayerItem
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.add_layer
-import org.meshtastic.core.resources.add_network_layer
-import org.meshtastic.core.resources.cancel
-import org.meshtastic.core.resources.hide_layer
-import org.meshtastic.core.resources.manage_map_layers
-import org.meshtastic.core.resources.map_layer_formats
-import org.meshtastic.core.resources.name
-import org.meshtastic.core.resources.network_layer_url_hint
-import org.meshtastic.core.resources.no_map_layers_loaded
-import org.meshtastic.core.resources.refresh
-import org.meshtastic.core.resources.remove_layer
-import org.meshtastic.core.resources.save
-import org.meshtastic.core.resources.show_layer
-import org.meshtastic.core.resources.url
-import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.icon.Delete
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.Refresh
-import org.meshtastic.core.ui.icon.Visibility
-import org.meshtastic.core.ui.icon.VisibilityOff
-
-@Suppress("LongMethod")
-@Composable
-@OptIn(ExperimentalMaterial3Api::class)
-fun CustomMapLayersSheet(
- mapLayers: List,
- onToggleVisibility: (String) -> Unit,
- onRemoveLayer: (String) -> Unit,
- onAddLayerClicked: () -> Unit,
- onRefreshLayer: (String) -> Unit,
- onAddNetworkLayer: (String, String) -> Unit,
-) {
- var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
- LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
- item {
- Text(
- modifier = Modifier.padding(16.dp),
- text = stringResource(Res.string.manage_map_layers),
- style = MaterialTheme.typography.headlineSmall,
- )
- HorizontalDivider()
- }
- item {
- Text(
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
- text = stringResource(Res.string.map_layer_formats),
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
- if (mapLayers.isEmpty()) {
- item {
- Text(
- modifier = Modifier.padding(16.dp),
- text = stringResource(Res.string.no_map_layers_loaded),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- } else {
- items(mapLayers, key = { it.id }) { layer ->
- ListItem(
- headlineContent = { Text(layer.name) },
- trailingContent = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (layer.isNetwork) {
- if (layer.isRefreshing) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp).padding(4.dp),
- strokeWidth = 2.dp,
- )
- } else {
- IconButton(onClick = { onRefreshLayer(layer.id) }) {
- Icon(
- imageVector = MeshtasticIcons.Refresh,
- contentDescription = stringResource(Res.string.refresh),
- )
- }
- }
- }
- IconToggleButton(
- checked = layer.isVisible,
- onCheckedChange = { onToggleVisibility(layer.id) },
- ) {
- Icon(
- imageVector =
- if (layer.isVisible) {
- MeshtasticIcons.Visibility
- } else {
- MeshtasticIcons.VisibilityOff
- },
- contentDescription =
- stringResource(
- if (layer.isVisible) {
- Res.string.hide_layer
- } else {
- Res.string.show_layer
- },
- ),
- )
- }
- IconButton(onClick = { onRemoveLayer(layer.id) }) {
- Icon(
- imageVector = MeshtasticIcons.Delete,
- contentDescription = stringResource(Res.string.remove_layer),
- )
- }
- }
- },
- )
- HorizontalDivider()
- }
- }
- item {
- Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
- Text(stringResource(Res.string.add_layer))
- }
- Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
- Text(stringResource(Res.string.add_network_layer))
- }
- }
- }
- }
-
- if (showAddNetworkLayerDialog) {
- AddNetworkLayerDialog(
- onDismiss = { showAddNetworkLayerDialog = false },
- onConfirm = { name, url ->
- onAddNetworkLayer(name, url)
- showAddNetworkLayerDialog = false
- },
- )
- }
-}
-
-@Composable
-fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
- var name by remember { mutableStateOf("") }
- var url by remember { mutableStateOf("") }
-
- MeshtasticDialog(
- onDismiss = onDismiss,
- title = stringResource(Res.string.add_network_layer),
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- label = { Text(stringResource(Res.string.name)) },
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- )
- OutlinedTextField(
- value = url,
- onValueChange = { url = it },
- label = { Text(stringResource(Res.string.url)) },
- placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- )
- }
- },
- onConfirm = { onConfirm(name, url) },
- confirmTextRes = Res.string.save,
- dismissTextRes = Res.string.cancel,
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
deleted file mode 100644
index 198a857a92..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
+++ /dev/null
@@ -1,324 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import android.content.Intent
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Button
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-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.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import kotlinx.coroutines.flow.collectLatest
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.app.map.model.CustomTileProviderConfig
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.add_custom_tile_source
-import org.meshtastic.core.resources.add_local_mbtiles_file
-import org.meshtastic.core.resources.cancel
-import org.meshtastic.core.resources.delete_custom_tile_source
-import org.meshtastic.core.resources.edit_custom_tile_source
-import org.meshtastic.core.resources.local_mbtiles_file
-import org.meshtastic.core.resources.manage_custom_tile_sources
-import org.meshtastic.core.resources.name
-import org.meshtastic.core.resources.name_cannot_be_empty
-import org.meshtastic.core.resources.no_custom_tile_sources_found
-import org.meshtastic.core.resources.provider_name_exists
-import org.meshtastic.core.resources.save
-import org.meshtastic.core.resources.url_cannot_be_empty
-import org.meshtastic.core.resources.url_must_contain_placeholders
-import org.meshtastic.core.resources.url_template
-import org.meshtastic.core.resources.url_template_hint
-import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.icon.Delete
-import org.meshtastic.core.ui.icon.Edit
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.util.showToast
-
-@Suppress("LongMethod")
-@Composable
-fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
- val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
- var editingConfig by remember { mutableStateOf(null) }
- var showEditDialog by remember { mutableStateOf(false) }
- val context = LocalContext.current
-
- val mbtilesPickerLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == android.app.Activity.RESULT_OK) {
- result.data?.data?.let { uri ->
- val fileName = uri.getFileName(context)
- val baseName = fileName.substringBeforeLast('.')
- mapViewModel.addCustomTileProvider(
- name = baseName,
- urlTemplate = "", // Empty for local
- localUri = uri.toString(),
- )
- }
- }
- }
-
- LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
-
- if (showEditDialog) {
- AddEditCustomTileProviderDialog(
- config = editingConfig,
- onDismiss = { showEditDialog = false },
- onSave = { name, url ->
- if (editingConfig == null) { // Adding new
- mapViewModel.addCustomTileProvider(name, url)
- } else { // Editing existing
- mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
- }
- showEditDialog = false
- },
- mapViewModel = mapViewModel,
- )
- }
-
- LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
- item {
- Text(
- text = stringResource(Res.string.manage_custom_tile_sources),
- style = MaterialTheme.typography.headlineSmall,
- modifier = Modifier.padding(16.dp),
- )
- HorizontalDivider()
- }
-
- if (customTileProviders.isEmpty()) {
- item {
- Text(
- text = stringResource(Res.string.no_custom_tile_sources_found),
- modifier = Modifier.padding(16.dp),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- } else {
- items(customTileProviders, key = { it.id }) { config ->
- ListItem(
- headlineContent = { Text(config.name) },
- supportingContent = {
- if (config.isLocal) {
- Text(
- stringResource(Res.string.local_mbtiles_file),
- style = MaterialTheme.typography.bodySmall,
- )
- } else {
- Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
- }
- },
- trailingContent = {
- Row {
- IconButton(
- onClick = {
- editingConfig = config
- showEditDialog = true
- },
- ) {
- Icon(
- MeshtasticIcons.Edit,
- contentDescription = stringResource(Res.string.edit_custom_tile_source),
- )
- }
- IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
- Icon(
- MeshtasticIcons.Delete,
- contentDescription = stringResource(Res.string.delete_custom_tile_source),
- )
- }
- }
- },
- )
- HorizontalDivider()
- }
- }
-
- item {
- Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- editingConfig = null
- showEditDialog = true
- },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(Res.string.add_custom_tile_source))
- }
-
- Button(
- onClick = {
- val intent =
- Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "*/*"
- }
- mbtilesPickerLauncher.launch(intent)
- },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(Res.string.add_local_mbtiles_file))
- }
- }
- }
- }
-}
-
-@Suppress("LongMethod")
-@Composable
-private fun AddEditCustomTileProviderDialog(
- config: CustomTileProviderConfig?,
- onDismiss: () -> Unit,
- onSave: (String, String) -> Unit,
- mapViewModel: MapViewModel,
-) {
- var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
- var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
- var nameError by remember { mutableStateOf(null) }
- var urlError by remember { mutableStateOf(null) }
- val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
-
- val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
- val providerNameExistsError = stringResource(Res.string.provider_name_exists)
- val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
- val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
-
- fun validateAndSave() {
- val currentNameError =
- validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
- val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
-
- nameError = currentNameError
- urlError = currentUrlError
-
- if (currentNameError == null && currentUrlError == null) {
- onSave(name, url)
- }
- }
-
- MeshtasticDialog(
- onDismiss = onDismiss,
- title =
- if (config == null) {
- stringResource(Res.string.add_custom_tile_source)
- } else {
- stringResource(Res.string.edit_custom_tile_source)
- },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(
- value = name,
- onValueChange = {
- name = it
- nameError = null
- },
- label = { Text(stringResource(Res.string.name)) },
- isError = nameError != null,
- supportingText = { nameError?.let { Text(it) } },
- singleLine = true,
- )
- OutlinedTextField(
- value = url,
- onValueChange = {
- url = it
- urlError = null
- },
- label = { Text(stringResource(Res.string.url_template)) },
- isError = urlError != null,
- supportingText = {
- if (urlError != null) {
- Text(urlError!!)
- } else {
- Text(stringResource(Res.string.url_template_hint))
- }
- },
- singleLine = false,
- maxLines = 2,
- )
- }
- },
- onConfirm = { validateAndSave() },
- confirmTextRes = Res.string.save,
- dismissTextRes = Res.string.cancel,
- )
-}
-
-private fun validateName(
- name: String,
- providers: List,
- currentId: String?,
- emptyNameError: String,
- nameExistsError: String,
-): String? = if (name.isBlank()) {
- emptyNameError
-} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
- nameExistsError
-} else {
- null
-}
-
-private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
- if (url.isBlank()) {
- emptyUrlError
- } else if (
- !url.contains("{z}", ignoreCase = true) ||
- !url.contains("{x}", ignoreCase = true) ||
- !url.contains("{y}", ignoreCase = true)
- ) {
- mustContainPlaceholdersError
- } else {
- null
- }
-
-private fun android.net.Uri.getFileName(context: android.content.Context): String {
- var name = this.lastPathSegment ?: "mbtiles_file"
- if (this.scheme == "content") {
- context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
- if (displayNameIndex != -1) {
- name = cursor.getString(displayNameIndex)
- }
- }
- }
- }
- return name
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
deleted file mode 100644
index 06a063ddf0..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ /dev/null
@@ -1,372 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import android.app.DatePickerDialog
-import android.app.TimePickerDialog
-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.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-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.ExperimentalMaterial3Api
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-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.Alignment
-import androidx.compose.ui.Modifier
-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.unit.dp
-import androidx.compose.ui.unit.sp
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.Month
-import kotlinx.datetime.atTime
-import kotlinx.datetime.number
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.systemTimeZone
-import org.meshtastic.core.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.emoji.EmojiPickerDialog
-import org.meshtastic.core.ui.icon.CalendarMonth
-import org.meshtastic.core.ui.icon.Lock
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.proto.Waypoint
-import kotlin.time.Duration.Companion.hours
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
-@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
- val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
- val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
- var showEmojiPickerView by remember { mutableStateOf(false) }
-
- val context = LocalContext.current
- val tz = systemTimeZone
-
- // Initialize date and time states from waypointInput.expire
- var selectedDateString by remember { mutableStateOf("") }
- var selectedTimeString by remember { mutableStateOf("") }
- var isExpiryEnabled by remember {
- mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE)
- }
-
- val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
- val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
- dateFormat.timeZone = java.util.TimeZone.getDefault()
- timeFormat.timeZone = java.util.TimeZone.getDefault()
-
- LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
- val expireValue = waypointInput.expire
- if (isExpiryEnabled) {
- if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
- val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong())
- val date = java.util.Date(instant.toEpochMilliseconds())
- selectedDateString = dateFormat.format(date)
- selectedTimeString = timeFormat.format(date)
- } else { // If enabled but not set, default to 8 hours from now
- val futureInstant = kotlin.time.Clock.System.now() + 8.hours
- val date = java.util.Date(futureInstant.toEpochMilliseconds())
- selectedDateString = dateFormat.format(date)
- selectedTimeString = timeFormat.format(date)
- waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
- }
- } else {
- selectedDateString = ""
- selectedTimeString = ""
- }
- }
-
- if (!showEmojiPickerView) {
- AlertDialog(
- onDismissRequest = onDismissRequest,
- title = {
- Text(
- text = stringResource(title),
- style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
- },
- text = {
- Column(modifier = modifier.fillMaxWidth()) {
- OutlinedTextField(
- value = waypointInput.name,
- onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) },
- label = { Text(stringResource(Res.string.name)) },
- singleLine = true,
- keyboardOptions =
- KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
- modifier = Modifier.fillMaxWidth(),
- trailingIcon = {
- IconButton(onClick = { showEmojiPickerView = true }) {
- Text(
- text = String(Character.toChars(currentEmojiCodepoint)),
- modifier =
- Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
- .padding(6.dp),
- fontSize = 20.sp,
- )
- }
- },
- )
- Spacer(modifier = Modifier.size(8.dp))
- OutlinedTextField(
- value = waypointInput.description,
- onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) },
- label = { Text(stringResource(Res.string.description)) },
- keyboardOptions =
- KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = {}),
- modifier = Modifier.fillMaxWidth(),
- minLines = 2,
- maxLines = 3,
- )
- Spacer(modifier = Modifier.size(8.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Image(
- imageVector = MeshtasticIcons.Lock,
- contentDescription = stringResource(Res.string.locked),
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(stringResource(Res.string.locked))
- }
- Switch(
- checked = waypointInput.locked_to != 0,
- onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
- )
- }
- Spacer(modifier = Modifier.size(8.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Image(
- imageVector = MeshtasticIcons.CalendarMonth,
- contentDescription = stringResource(Res.string.expires),
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(stringResource(Res.string.expires))
- }
- Switch(
- checked = isExpiryEnabled,
- onCheckedChange = { checked ->
- isExpiryEnabled = checked
- if (checked) {
- val expireValue = waypointInput.expire
- // Default to 8 hours from now if not already set
- if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
- val futureInstant = kotlin.time.Clock.System.now() + 8.hours
- waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
- }
- } else {
- waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
- }
- },
- )
- }
-
- if (isExpiryEnabled) {
- val currentInstant =
- (waypointInput.expire).let {
- if (it != 0 && it != Int.MAX_VALUE) {
- kotlin.time.Instant.fromEpochSeconds(it.toLong())
- } else {
- kotlin.time.Clock.System.now() + 8.hours
- }
- }
- val ldt = currentInstant.toLocalDateTime(tz)
-
- val datePickerDialog =
- DatePickerDialog(
- context,
- { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
- val currentLdt =
- (waypointInput.expire)
- .let {
- if (it != 0 && it != Int.MAX_VALUE) {
- kotlin.time.Instant.fromEpochSeconds(it.toLong())
- } else {
- kotlin.time.Clock.System.now() + 8.hours
- }
- }
- .toLocalDateTime(tz)
-
- val newLdt =
- LocalDate(
- year = selectedYear,
- month = Month(selectedMonth + 1),
- day = selectedDay,
- )
- .atTime(
- hour = currentLdt.hour,
- minute = currentLdt.minute,
- second = currentLdt.second,
- nanosecond = currentLdt.nanosecond,
- )
- waypointInput =
- waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
- },
- ldt.year,
- ldt.month.number - 1,
- ldt.day,
- )
-
- val timePickerDialog =
- TimePickerDialog(
- context,
- { _: TimePicker, selectedHour: Int, selectedMinute: Int ->
- val currentLdt =
- (waypointInput.expire)
- .let {
- if (it != 0 && it != Int.MAX_VALUE) {
- kotlin.time.Instant.fromEpochSeconds(it.toLong())
- } else {
- kotlin.time.Clock.System.now() + 8.hours
- }
- }
- .toLocalDateTime(tz)
-
- val newLdt =
- LocalDate(
- year = currentLdt.year,
- month = currentLdt.month,
- day = currentLdt.day,
- )
- .atTime(
- hour = selectedHour,
- minute = selectedMinute,
- second = currentLdt.second,
- nanosecond = currentLdt.nanosecond,
- )
- waypointInput =
- waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
- },
- ldt.hour,
- ldt.minute,
- android.text.format.DateFormat.is24HourFormat(context),
- )
- Spacer(modifier = Modifier.size(8.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = selectedDateString,
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
- Text(
- modifier = Modifier.padding(top = 4.dp),
- text = selectedTimeString,
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- Row(
- modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
- horizontalArrangement = Arrangement.End,
- ) {
- if (waypoint.id != 0) {
- TextButton(
- onClick = { onDeleteClicked(waypointInput) },
- modifier = Modifier.padding(end = 8.dp),
- ) {
- Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
- }
- }
- Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
- TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
- Text(stringResource(Res.string.cancel))
- }
- Button(onClick = { onSendClicked(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) {
- Text(stringResource(Res.string.send))
- }
- }
- },
- dismissButton = null, // Using custom buttons in confirmButton Row
- modifier = modifier,
- )
- } else {
- EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
- showEmojiPickerView = false
- waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0))
- }
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
deleted file mode 100644
index 8c8d63c2ac..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
+++ /dev/null
@@ -1,165 +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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
-
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuGroup
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.MenuDefaults
-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.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.last_heard_filter_label
-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.ui.icon.Favorite
-import org.meshtastic.core.ui.icon.Lens
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.PinDrop
-import org.meshtastic.feature.map.LastHeardFilter
-import kotlin.math.roundToInt
-
-@Composable
-internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
- val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
- DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
- DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.only_favorites)) },
- onClick = { mapViewModel.toggleOnlyFavorites() },
- leadingIcon = {
- Icon(
- imageVector = MeshtasticIcons.Favorite,
- contentDescription = stringResource(Res.string.only_favorites),
- )
- },
- trailingIcon = {
- Checkbox(
- checked = mapFilterState.onlyFavorites,
- onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
- )
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.show_waypoints)) },
- onClick = { mapViewModel.toggleShowWaypointsOnMap() },
- leadingIcon = {
- Icon(
- imageVector = MeshtasticIcons.PinDrop,
- contentDescription = stringResource(Res.string.show_waypoints),
- )
- },
- trailingIcon = {
- Checkbox(
- checked = mapFilterState.showWaypoints,
- onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
- )
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.show_precision_circle)) },
- onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- leadingIcon = {
- Icon(
- imageVector = MeshtasticIcons.Lens,
- contentDescription = stringResource(Res.string.show_precision_circle),
- )
- },
- trailingIcon = {
- Checkbox(
- checked = mapFilterState.showPrecisionCircle,
- onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- )
- },
- )
- }
- 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
-internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
- val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
- DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
- Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
- val filterOptions = LastHeardFilter.entries
- val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter)
- var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
-
- Text(
- text =
- stringResource(
- Res.string.last_heard_filter_label,
- stringResource(mapFilterState.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/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
deleted file mode 100644
index a649e29624..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
+++ /dev/null
@@ -1,120 +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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
-
-package org.meshtastic.app.map.component
-
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuGroup
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MenuDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.google.maps.android.compose.MapType
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.manage_custom_tile_sources
-import org.meshtastic.core.resources.map_type_hybrid
-import org.meshtastic.core.resources.map_type_normal
-import org.meshtastic.core.resources.map_type_satellite
-import org.meshtastic.core.resources.map_type_terrain
-import org.meshtastic.core.resources.selected_map_type
-import org.meshtastic.core.ui.icon.Check
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-
-@Suppress("LongMethod")
-@Composable
-internal fun MapTypeDropdown(
- expanded: Boolean,
- onDismissRequest: () -> Unit,
- mapViewModel: MapViewModel,
- onManageCustomTileProvidersClicked: () -> Unit,
-) {
- val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
- val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
- val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
-
- val googleMapTypes =
- listOf(
- stringResource(Res.string.map_type_normal) to MapType.NORMAL,
- stringResource(Res.string.map_type_satellite) to MapType.SATELLITE,
- stringResource(Res.string.map_type_terrain) to MapType.TERRAIN,
- stringResource(Res.string.map_type_hybrid) to MapType.HYBRID,
- )
-
- DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
- DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
- googleMapTypes.forEach { (name, type) ->
- DropdownMenuItem(
- text = { Text(name) },
- onClick = {
- mapViewModel.setSelectedGoogleMapType(type)
- onDismissRequest()
- },
- trailingIcon =
- if (selectedCustomUrl == null && selectedGoogleMapType == type) {
- {
- Icon(
- MeshtasticIcons.Check,
- contentDescription = stringResource(Res.string.selected_map_type),
- )
- }
- } else {
- null
- },
- )
- }
- }
-
- if (customTileProviders.isNotEmpty()) {
- DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
- customTileProviders.forEach { config ->
- DropdownMenuItem(
- text = { Text(config.name) },
- onClick = {
- mapViewModel.selectCustomTileProvider(config)
- onDismissRequest()
- },
- trailingIcon =
- if (selectedCustomUrl == config.urlTemplate) {
- {
- Icon(
- MeshtasticIcons.Check,
- contentDescription = stringResource(Res.string.selected_map_type),
- )
- }
- } else {
- null
- },
- )
- }
- }
- }
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
- onClick = {
- onManageCustomTileProvidersClicked()
- onDismissRequest()
- },
- )
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
deleted file mode 100644
index 9b0c161eb8..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.RectF
-import android.graphics.Typeface
-import android.text.TextPaint
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
-import androidx.core.graphics.createBitmap
-import com.google.android.gms.maps.MapsInitializer
-import com.google.android.gms.maps.model.BitmapDescriptor
-import com.google.android.gms.maps.model.BitmapDescriptorFactory
-import org.meshtastic.core.model.Node
-
-private const val CHIP_CORNER_RADIUS_DP = 4f
-private const val CHIP_PADDING_HORIZONTAL_DP = 8f
-private const val CHIP_MIN_WIDTH_DP = 64f
-private const val CHIP_MIN_HEIGHT_DP = 28f
-private const val CHIP_TEXT_SIZE_SP = 14f
-private const val EMOJI_TEXT_SIZE_SP = 32f
-private const val EMOJI_PADDING_DP = 2f
-
-/**
- * Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
- * maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
- * measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
- */
-@Composable
-fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
- val context = LocalContext.current
- val density = LocalDensity.current.density
- val fontScale = LocalDensity.current.fontScale
- return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
- ensureMapsInitialized(context)
- renderNodeChipBitmap(node, density, fontScale)
- }
-}
-
-/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
-@Composable
-fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
- val context = LocalContext.current
- val density = LocalDensity.current.density
- val fontScale = LocalDensity.current.fontScale
- return remember(codePoint) {
- ensureMapsInitialized(context)
- renderEmojiBitmap(codePoint, density, fontScale)
- }
-}
-
-/**
- * [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a
- * GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the
- * icon is computed before that screen's GoogleMap has loaded the SDK — so [BitmapDescriptorFactory.fromBitmap] crashes
- * with "IBitmapDescriptorFactory is not initialized". Initialize explicitly first; [MapsInitializer.initialize] is
- * synchronous and idempotent, so it is a no-op once the SDK is already up.
- */
-@Suppress("DEPRECATION")
-private fun ensureMapsInitialized(context: Context) {
- MapsInitializer.initialize(context)
-}
-
-private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
- val (textColorInt, nodeColorInt) = node.colors
- val scaledDensity = density * fontScale
-
- val textPaint =
- TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
- textSize = CHIP_TEXT_SIZE_SP * scaledDensity
- typeface = Typeface.DEFAULT_BOLD
- color = textColorInt
- textAlign = Paint.Align.CENTER
- isStrikeThruText = node.isIgnored
- }
- val label = node.user.short_name.ifEmpty { "???" }
-
- val textWidth = textPaint.measureText(label)
- val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
- val minWidth = CHIP_MIN_WIDTH_DP * density
- val minHeight = CHIP_MIN_HEIGHT_DP * density
-
- val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
- val height = minHeight.toInt()
-
- val bitmap = createBitmap(width, height)
- val canvas = Canvas(bitmap)
-
- val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
- val cornerRadius = CHIP_CORNER_RADIUS_DP * density
- canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
-
- val textX = width / 2f
- val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
- canvas.drawText(label, textX, textY, textPaint)
-
- return BitmapDescriptorFactory.fromBitmap(bitmap)
-}
-
-private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
- val scaledDensity = density * fontScale
- val textPaint =
- TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
- textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
- textAlign = Paint.Align.CENTER
- }
- val emoji = String(Character.toChars(codePoint))
- val padding = EMOJI_PADDING_DP * density
-
- val textWidth = textPaint.measureText(emoji)
- val metrics = textPaint.fontMetrics
- val textHeight = metrics.descent - metrics.ascent
-
- val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
- val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
-
- val bitmap = createBitmap(width, height)
- val canvas = Canvas(bitmap)
-
- val textX = width / 2f
- val textY = padding - metrics.ascent
- canvas.drawText(emoji, textX, textY, textPaint)
-
- return BitmapDescriptorFactory.fromBitmap(bitmap)
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
deleted file mode 100644
index 6d38e176af..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalView
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.currentStateAsState
-import androidx.lifecycle.findViewTreeLifecycleOwner
-import androidx.lifecycle.setViewTreeLifecycleOwner
-import androidx.savedstate.compose.LocalSavedStateRegistryOwner
-import androidx.savedstate.findViewTreeSavedStateRegistryOwner
-import androidx.savedstate.setViewTreeSavedStateRegistryOwner
-import com.google.maps.android.clustering.Cluster
-import com.google.maps.android.clustering.view.DefaultClusterRenderer
-import com.google.maps.android.compose.Circle
-import com.google.maps.android.compose.MapsComposeExperimentalApi
-import com.google.maps.android.compose.clustering.Clustering
-import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
-import org.meshtastic.app.map.model.NodeClusterItem
-import org.meshtastic.feature.map.BaseMapViewModel
-
-@OptIn(MapsComposeExperimentalApi::class)
-@Suppress("NestedBlockDepth")
-@Composable
-fun NodeClusterMarkers(
- nodeClusterItems: List,
- mapFilterState: BaseMapViewModel.MapFilterState,
- navigateToNodeDetails: (Int) -> Unit,
- onClusterClick: (Cluster) -> Boolean,
-) {
- val view = LocalView.current
- val lifecycleOwner = LocalLifecycleOwner.current
- val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
- val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
-
- // maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that
- // it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in
- // MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and,
- // when it finds none, crashes with "Composed into the View which doesn't propagate
- // ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) — a FATAL on the map screen.
- //
- // Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor
- // of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately
- // do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is
- // what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader
- // workaround entirely. Scoping to the map host view and restoring the previous owners on dispose
- // keeps the fix local to the map and leaves Popups/DropdownMenus untouched.
- DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) {
- val prevLifecycleOwner = view.findViewTreeLifecycleOwner()
- val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner()
- view.setViewTreeLifecycleOwner(lifecycleOwner)
- view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
- onDispose {
- view.setViewTreeLifecycleOwner(prevLifecycleOwner)
- view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner)
- }
- }
-
- // The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's
- // MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is
- // detached — at which point no owner is reachable regardless of the above. Skip rendering once the
- // lifecycle is no longer at least STARTED to close most of that race.
- if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return
-
- Clustering(
- items = nodeClusterItems,
- onClusterClick = onClusterClick,
- onClusterItemInfoWindowClick = { item ->
- navigateToNodeDetails(item.node.num)
- false
- },
- clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
- onClusterManager = { clusterManager ->
- (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
- },
- clusterItemDecoration = { clusterItem ->
- if (mapFilterState.showPrecisionCircle) {
- clusterItem.getPrecisionMeters()?.let { precisionMeters ->
- if (precisionMeters > 0) {
- Circle(
- center = clusterItem.position,
- radius = precisionMeters,
- fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
- strokeColor = Color(clusterItem.node.colors.second),
- strokeWidth = 2f,
- zIndex = 0f,
- )
- }
- }
- }
- // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
- ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
- },
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
deleted file mode 100644
index be20ad830c..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Box
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.geometry.CornerRadius
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.launch
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.ui.component.NodeChip
-
-@Composable
-fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) {
- val animatedProgress = remember { Animatable(0f) }
-
- LaunchedEffect(node) {
- if ((nowSeconds - node.lastHeard) <= 5) {
- launch {
- animatedProgress.snapTo(0f)
- animatedProgress.animateTo(
- targetValue = 1f,
- animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
- )
- }
- }
- }
-
- Box(
- modifier =
- modifier.drawWithContent {
- drawContent()
- if (animatedProgress.value > 0 && animatedProgress.value < 1f) {
- val alpha = (1f - animatedProgress.value) * 0.3f
- drawRoundRect(
- size = size,
- cornerRadius = CornerRadius(8.dp.toPx()),
- color = Color.White.copy(alpha = alpha),
- )
- }
- },
- ) {
- NodeChip(node = node)
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
deleted file mode 100644
index ed1044fc2e..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.platform.LocalContext
-import com.google.android.gms.maps.model.LatLng
-import com.google.maps.android.compose.Marker
-import com.google.maps.android.compose.rememberUpdatedMarkerState
-import kotlinx.coroutines.launch
-import org.meshtastic.core.model.util.GeoConstants.DEG_D
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.locked
-import org.meshtastic.core.ui.util.showToast
-import org.meshtastic.feature.map.BaseMapViewModel
-import org.meshtastic.proto.Waypoint
-
-@Composable
-fun WaypointMarkers(
- displayableWaypoints: List,
- mapFilterState: BaseMapViewModel.MapFilterState,
- myNodeNum: Int,
- isConnected: Boolean,
- onEditWaypointRequest: (Waypoint) -> Unit,
- selectedWaypointId: Int? = null,
-) {
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- if (mapFilterState.showWaypoints) {
- displayableWaypoints.forEach { waypoint ->
- val markerState =
- rememberUpdatedMarkerState(
- position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D),
- )
-
- LaunchedEffect(selectedWaypointId) {
- if (selectedWaypointId == waypoint.id) {
- markerState.showInfoWindow()
- }
- }
-
- val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
- val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
-
- Marker(
- state = markerState,
- icon = icon,
- title = waypoint.name.replace('\n', ' ').replace('\b', ' '),
- snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '),
- visible = true,
- onInfoWindowClick = {
- if (waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected) {
- onEditWaypointRequest(waypoint)
- } else {
- scope.launch { context.showToast(Res.string.locked) }
- }
- },
- )
- }
- }
-}
-
-private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
deleted file mode 100644
index c43dff841d..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
+++ /dev/null
@@ -1,62 +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.model
-
-import com.google.android.gms.maps.model.LatLng
-import com.google.maps.android.clustering.ClusterItem
-import org.meshtastic.core.model.Node
-
-data class NodeClusterItem(
- val node: Node,
- val nodePosition: LatLng,
- val nodeTitle: String,
- val nodeSnippet: String,
- val myNodeNum: Int? = null,
-) : ClusterItem {
- override fun getPosition(): LatLng = nodePosition
-
- override fun getTitle(): String = nodeTitle
-
- override fun getSnippet(): String = nodeSnippet
-
- override fun getZIndex(): Float = when {
- node.num == myNodeNum -> 5.0f
-
- // My node is always highest
- node.isFavorite -> 5.0f
-
- // Favorites are equally high priority
- else -> 4.0f
- }
-
- fun getPrecisionMeters(): Double? {
- val precisionMap =
- mapOf(
- 10 to 23345.484932,
- 11 to 11672.7369,
- 12 to 5836.36288,
- 13 to 2918.175876,
- 14 to 1459.0823719999053,
- 15 to 729.53562,
- 16 to 364.7622,
- 17 to 182.375556,
- 18 to 91.182212,
- 19 to 45.58554,
- )
- return precisionMap[this.node.position.precision_bits]
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
deleted file mode 100644
index 19ce8cff75..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ /dev/null
@@ -1,54 +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.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.meshtastic.app.map.GoogleMapMode
-import org.meshtastic.app.map.MapView
-import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.feature.map.node.NodeMapViewModel
-
-@Composable
-fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
- val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
-
- Scaffold(
- topBar = {
- MainAppBar(
- title = node?.user?.long_name ?: "",
- ourNode = null,
- showNodeChip = false,
- canNavigateUp = true,
- onNavigateUp = onNavigateUp,
- actions = {},
- onClickChip = {},
- )
- },
- ) { paddingValues ->
- MapView(
- modifier = Modifier.fillMaxSize().padding(paddingValues),
- mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
- )
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
deleted file mode 100644
index 2f7244b979..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
+++ /dev/null
@@ -1,58 +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.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.map.GoogleMapMode
-import org.meshtastic.app.map.MapView
-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 a
- * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
- * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
- * filter).
- *
- * 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)
- val focusedNode by vm.node.collectAsStateWithLifecycle()
- MapView(
- modifier = modifier,
- mode =
- GoogleMapMode.NodeTrack(
- focusedNode = focusedNode,
- positions = positions,
- selectedPositionTime = selectedPositionTime,
- onPositionSelected = onPositionSelected,
- ),
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
deleted file mode 100644
index 668dedbaaa..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ /dev/null
@@ -1,45 +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.prefs.di
-
-import android.content.Context
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.SharedPreferencesMigration
-import androidx.datastore.preferences.core.PreferenceDataStoreFactory
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.preferencesDataStoreFile
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import org.koin.core.annotation.ComponentScan
-import org.koin.core.annotation.Module
-import org.koin.core.annotation.Named
-import org.koin.core.annotation.Single
-import org.meshtastic.core.di.CoroutineDispatchers
-
-@Module
-@ComponentScan("org.meshtastic.app.map")
-class GoogleMapsKoinModule {
-
- @Single
- @Named("GoogleMapsDataStore")
- fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore =
- PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(dispatchers.io + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
deleted file mode 100644
index 2bb06406e4..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
+++ /dev/null
@@ -1,196 +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.prefs.map
-
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.doublePreferencesKey
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.floatPreferencesKey
-import androidx.datastore.preferences.core.stringPreferencesKey
-import androidx.datastore.preferences.core.stringSetPreferencesKey
-import com.google.maps.android.compose.MapType
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-import org.koin.core.annotation.Named
-import org.koin.core.annotation.Single
-import org.meshtastic.core.di.CoroutineDispatchers
-
-/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
-interface GoogleMapsPrefs {
- val selectedGoogleMapType: StateFlow
-
- fun setSelectedGoogleMapType(value: String?)
-
- val selectedCustomTileUrl: StateFlow
-
- fun setSelectedCustomTileUrl(value: String?)
-
- val hiddenLayerUrls: StateFlow>
-
- fun setHiddenLayerUrls(value: Set)
-
- val cameraTargetLat: StateFlow
-
- fun setCameraTargetLat(value: Double)
-
- val cameraTargetLng: StateFlow
-
- fun setCameraTargetLng(value: Double)
-
- val cameraZoom: StateFlow
-
- fun setCameraZoom(value: Float)
-
- val cameraTilt: StateFlow
-
- fun setCameraTilt(value: Float)
-
- val cameraBearing: StateFlow
-
- fun setCameraBearing(value: Float)
-
- val networkMapLayers: StateFlow>
-
- fun setNetworkMapLayers(value: Set)
-}
-
-@Single
-class GoogleMapsPrefsImpl(
- @Named("GoogleMapsDataStore") private val dataStore: DataStore,
- dispatchers: CoroutineDispatchers,
-) : GoogleMapsPrefs {
- private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
-
- override val selectedGoogleMapType: StateFlow =
- dataStore.data
- .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
- .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
-
- override fun setSelectedGoogleMapType(value: String?) {
- scope.launch {
- dataStore.edit { prefs ->
- if (value == null) {
- prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
- } else {
- prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
- }
- }
- }
- }
-
- override val selectedCustomTileUrl: StateFlow =
- dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
-
- override fun setSelectedCustomTileUrl(value: String?) {
- scope.launch {
- dataStore.edit { prefs ->
- if (value == null) {
- prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
- } else {
- prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
- }
- }
- }
- }
-
- override val hiddenLayerUrls: StateFlow> =
- dataStore.data
- .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
- .stateIn(scope, SharingStarted.Eagerly, emptySet())
-
- override fun setHiddenLayerUrls(value: Set) {
- scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
- }
-
- override val cameraTargetLat: StateFlow =
- dataStore.data
- .map {
- try {
- it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0
- } catch (_: ClassCastException) {
- it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0
- }
- }
- .stateIn(scope, SharingStarted.Eagerly, 0.0)
-
- override fun setCameraTargetLat(value: Double) {
- scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
- }
-
- override val cameraTargetLng: StateFlow =
- dataStore.data
- .map {
- try {
- it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0
- } catch (_: ClassCastException) {
- it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0
- }
- }
- .stateIn(scope, SharingStarted.Eagerly, 0.0)
-
- override fun setCameraTargetLng(value: Double) {
- scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
- }
-
- override val cameraZoom: StateFlow =
- dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
-
- override fun setCameraZoom(value: Float) {
- scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
- }
-
- override val cameraTilt: StateFlow =
- dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
-
- override fun setCameraTilt(value: Float) {
- scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
- }
-
- override val cameraBearing: StateFlow =
- dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
-
- override fun setCameraBearing(value: Float) {
- scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
- }
-
- override val networkMapLayers: StateFlow> =
- dataStore.data
- .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
- .stateIn(scope, SharingStarted.Eagerly, emptySet())
-
- override fun setNetworkMapLayers(value: Set) {
- scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
- }
-
- companion object {
- val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
- val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
- val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
- val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
- val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
- val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
- val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
- val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
- val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
deleted file mode 100644
index 48d89d258c..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
+++ /dev/null
@@ -1,104 +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.repository
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.SerializationException
-import kotlinx.serialization.json.Json
-import org.koin.core.annotation.Single
-import org.meshtastic.app.map.model.CustomTileProviderConfig
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.repository.MapTileProviderPrefs
-
-interface CustomTileProviderRepository {
- fun getCustomTileProviders(): Flow>
-
- suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
-
- suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
-
- suspend fun deleteCustomTileProvider(configId: String)
-
- suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
-}
-
-@Single
-class CustomTileProviderRepositoryImpl(
- private val json: Json,
- private val dispatchers: CoroutineDispatchers,
- private val mapTileProviderPrefs: MapTileProviderPrefs,
-) : CustomTileProviderRepository {
-
- private val customTileProvidersStateFlow = MutableStateFlow>(emptyList())
-
- init {
- loadDataFromPrefs()
- }
-
- override fun getCustomTileProviders(): Flow> =
- customTileProvidersStateFlow.asStateFlow()
-
- override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
- val newList = customTileProvidersStateFlow.value + config
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
- val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun deleteCustomTileProvider(configId: String) {
- val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
- customTileProvidersStateFlow.value.find { it.id == configId }
-
- private fun loadDataFromPrefs() {
- val jsonString = mapTileProviderPrefs.customTileProviders.value
- if (jsonString != null) {
- try {
- customTileProvidersStateFlow.value = json.decodeFromString>(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error deserializing tile providers" }
- customTileProvidersStateFlow.value = emptyList()
- }
- } else {
- customTileProvidersStateFlow.value = emptyList()
- }
- }
-
- private suspend fun saveDataToPrefs(providers: List) {
- withContext(dispatchers.io) {
- try {
- val jsonString = json.encodeToString(providers)
- mapTileProviderPrefs.setCustomTileProviders(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error serializing tile providers" }
- }
- }
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
deleted file mode 100644
index d725537c82..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
+++ /dev/null
@@ -1,46 +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.app.map.GoogleMapMode
-import org.meshtastic.app.map.MapView
-import org.meshtastic.core.model.TracerouteOverlay
-import org.meshtastic.proto.Position
-
-/**
- * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
- * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
- */
-@Composable
-fun TracerouteMap(
- tracerouteOverlay: TracerouteOverlay?,
- tracerouteNodePositions: Map,
- onMappableCountChanged: (shown: Int, total: Int) -> Unit,
- modifier: Modifier = Modifier,
-) {
- MapView(
- modifier = modifier,
- mode =
- GoogleMapMode.Traceroute(
- overlay = tracerouteOverlay,
- nodePositions = tracerouteNodePositions,
- onMappableCountChanged = onMappableCountChanged,
- ),
- )
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
deleted file mode 100644
index f77f8aec18..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ /dev/null
@@ -1,84 +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.node.component
-
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.key
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import com.google.android.gms.maps.model.CameraPosition
-import com.google.android.gms.maps.model.LatLng
-import com.google.maps.android.compose.Circle
-import com.google.maps.android.compose.ComposeMapColorScheme
-import com.google.maps.android.compose.GoogleMap
-import com.google.maps.android.compose.MapUiSettings
-import com.google.maps.android.compose.Marker
-import com.google.maps.android.compose.rememberCameraPositionState
-import com.google.maps.android.compose.rememberUpdatedMarkerState
-import org.meshtastic.app.map.component.rememberNodeChipDescriptor
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.ui.component.precisionBitsToMeters
-
-private const val DEFAULT_ZOOM = 15f
-
-@Composable
-fun InlineMap(node: Node, modifier: Modifier = Modifier) {
- val dark = isSystemInDarkTheme()
- val mapColorScheme =
- when (dark) {
- true -> ComposeMapColorScheme.DARK
- else -> ComposeMapColorScheme.LIGHT
- }
- key(node.num) {
- val location = LatLng(node.latitude, node.longitude)
- val cameraState = rememberCameraPositionState {
- position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
- }
- val markerIcon = rememberNodeChipDescriptor(node)
-
- GoogleMap(
- mapColorScheme = mapColorScheme,
- modifier = modifier,
- uiSettings =
- MapUiSettings(
- zoomControlsEnabled = true,
- mapToolbarEnabled = false,
- compassEnabled = false,
- myLocationButtonEnabled = false,
- rotationGesturesEnabled = false,
- scrollGesturesEnabled = false,
- tiltGesturesEnabled = false,
- zoomGesturesEnabled = false,
- ),
- cameraPositionState = cameraState,
- ) {
- val precisionMeters = precisionBitsToMeters(node.position.precision_bits)
- val latLng = LatLng(node.latitude, node.longitude)
- if (precisionMeters > 0) {
- Circle(
- center = latLng,
- radius = precisionMeters,
- fillColor = Color(node.colors.second).copy(alpha = 0.2f),
- strokeColor = Color(node.colors.second),
- strokeWidth = 2f,
- )
- }
- Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
- }
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
deleted file mode 100644
index 64224501a5..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
+++ /dev/null
@@ -1,28 +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.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.BottomCenter,
- overlayPadding = PaddingValues(bottom = 16.dp),
- contentHorizontalAlignment = Alignment.CenterHorizontally,
-)
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 78e8ce5592..8b30c8d7fe 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -53,9 +53,6 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
-import org.meshtastic.app.map.getMapViewProvider
-import org.meshtastic.app.node.component.InlineMap
-import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@@ -70,25 +67,12 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalEventBranding
-import org.meshtastic.core.ui.util.LocalInlineMapProvider
-import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
-import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
-import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
-import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
-import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
-import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
-import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
-import org.meshtastic.feature.map.MapScreen
-import org.meshtastic.feature.map.SharedMapViewModel
-import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.meshtastic.feature.node.metrics.MetricsViewModel
-import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : AppCompatActivity() {
private val model: UIViewModel by viewModel()
@@ -189,56 +173,6 @@ class MainActivity : AppCompatActivity() {
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
- LocalMapViewProvider provides getMapViewProvider(),
- LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
- LocalNodeTrackMapProvider provides
- { destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
- org.meshtastic.app.map.node.NodeTrackMap(
- destNum,
- positions,
- modifier,
- selectedPositionTime,
- onPositionSelected,
- )
- },
- LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
- LocalTracerouteMapProvider provides
- { overlay, nodePositions, onMappableCountChanged, modifier ->
- org.meshtastic.app.map.traceroute.TracerouteMap(
- tracerouteOverlay = overlay,
- tracerouteNodePositions = nodePositions,
- onMappableCountChanged = onMappableCountChanged,
- modifier = modifier,
- )
- },
- LocalNodeMapScreenProvider provides
- { destNum, onNavigateUp ->
- val vm = koinViewModel()
- vm.setDestNum(destNum)
- org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
- },
- LocalTracerouteMapScreenProvider provides
- { destNum, requestId, logUuid, onNavigateUp ->
- val metricsViewModel = koinViewModel { parametersOf(destNum) }
- metricsViewModel.setNodeId(destNum)
-
- TracerouteMapScreen(
- metricsViewModel = metricsViewModel,
- requestId = requestId,
- logUuid = logUuid,
- onNavigateUp = onNavigateUp,
- )
- },
- LocalMapMainScreenProvider provides
- { onClickNodeChip, navigateToNodeDetails, waypointId ->
- val viewModel = koinViewModel()
- MapScreen(
- viewModel = viewModel,
- onClickNodeChip = onClickNodeChip,
- navigateToNodeDetails = navigateToNodeDetails,
- waypointId = waypointId,
- )
- },
content = content,
)
}
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
index 0aa9d2f414..01baaa991b 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
@@ -29,10 +29,10 @@ import org.koin.plugin.module.dsl.koinApplication
import org.koin.test.verify.definition
import org.koin.test.verify.injectedParameters
import org.koin.test.verify.verify
-import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.ble.BleLogFormat
import org.meshtastic.core.ble.BleLogLevel
import org.meshtastic.core.model.util.NodeIdLookup
+import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import kotlin.test.Test
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt
new file mode 100644
index 0000000000..b7f27080bc
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.prefs.map
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.doublePreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MapCameraPrefs
+
+@Single
+class MapCameraPrefsImpl(
+ @Named("MapDataStore") private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MapCameraPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val cameraLat: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_LAT] ?: DEFAULT_LAT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LAT)
+
+ override fun setCameraLat(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_LAT] = value } }
+ }
+
+ override val cameraLng: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_LNG] ?: DEFAULT_LNG }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LNG)
+
+ override fun setCameraLng(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_LNG] = value } }
+ }
+
+ override val cameraZoom: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_ZOOM] ?: DEFAULT_ZOOM }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_ZOOM)
+
+ override fun setCameraZoom(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM] = value } }
+ }
+
+ override val cameraTilt: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_TILT] ?: DEFAULT_TILT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_TILT)
+
+ override fun setCameraTilt(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TILT] = value } }
+ }
+
+ override val cameraBearing: StateFlow =
+ dataStore.data
+ .map { it[KEY_CAMERA_BEARING] ?: DEFAULT_BEARING }
+ .stateIn(scope, SharingStarted.Eagerly, DEFAULT_BEARING)
+
+ override fun setCameraBearing(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING] = value } }
+ }
+
+ override val selectedStyleUri: StateFlow =
+ dataStore.data
+ .map { it[KEY_SELECTED_STYLE_URI] ?: DEFAULT_STYLE_URI }
+ .stateIn(scope, SharingStarted.Eagerly, DEFAULT_STYLE_URI)
+
+ override fun setSelectedStyleUri(value: String) {
+ scope.launch { dataStore.edit { it[KEY_SELECTED_STYLE_URI] = value } }
+ }
+
+ override val hiddenLayerUrls: StateFlow> =
+ dataStore.data
+ .map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } }
+ }
+
+ override val networkMapLayers: StateFlow> =
+ dataStore.data
+ .map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS] = value } }
+ }
+
+ companion object {
+ private const val DEFAULT_LAT = 0.0
+ private const val DEFAULT_LNG = 0.0
+ private const val DEFAULT_ZOOM = 7f
+ private const val DEFAULT_TILT = 0f
+ private const val DEFAULT_BEARING = 0f
+ private const val DEFAULT_STYLE_URI = ""
+
+ val KEY_CAMERA_LAT = doublePreferencesKey("map_camera_lat")
+ val KEY_CAMERA_LNG = doublePreferencesKey("map_camera_lng")
+ val KEY_CAMERA_ZOOM = floatPreferencesKey("map_camera_zoom")
+ val KEY_CAMERA_TILT = floatPreferencesKey("map_camera_tilt")
+ val KEY_CAMERA_BEARING = floatPreferencesKey("map_camera_bearing")
+ val KEY_SELECTED_STYLE_URI = stringPreferencesKey("map_selected_style_uri")
+ val KEY_HIDDEN_LAYER_URLS = stringSetPreferencesKey("map_hidden_layer_urls")
+ val KEY_NETWORK_MAP_LAYERS = stringSetPreferencesKey("map_network_layers")
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 1e0cbd29d7..2dffbf073f 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -242,6 +242,41 @@ interface MapPrefs {
fun setLastHeardTrackFilter(seconds: Long)
}
+/** Reactive interface for map camera position persistence. */
+interface MapCameraPrefs {
+ val cameraLat: StateFlow
+
+ fun setCameraLat(value: Double)
+
+ val cameraLng: StateFlow
+
+ fun setCameraLng(value: Double)
+
+ val cameraZoom: StateFlow
+
+ fun setCameraZoom(value: Float)
+
+ val cameraTilt: StateFlow
+
+ fun setCameraTilt(value: Float)
+
+ val cameraBearing: StateFlow
+
+ fun setCameraBearing(value: Float)
+
+ val selectedStyleUri: StateFlow
+
+ fun setSelectedStyleUri(value: String)
+
+ val hiddenLayerUrls: StateFlow>
+
+ fun setHiddenLayerUrls(value: Set)
+
+ val networkMapLayers: StateFlow>
+
+ fun setNetworkMapLayers(value: Set)
+}
+
/** Reactive interface for map consent. */
interface MapConsentPrefs {
fun shouldReportLocation(nodeNum: Int?): StateFlow
@@ -305,6 +340,7 @@ interface AppPreferences {
val emoji: CustomEmojiPrefs
val ui: UiPrefs
val map: MapPrefs
+ val mapCamera: MapCameraPrefs
val mapConsent: MapConsentPrefs
val mapTileProvider: MapTileProviderPrefs
val radio: RadioPrefs
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml b/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml
new file mode 100644
index 0000000000..089b549919
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 6c59d355d7..58d7eace41 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -715,8 +715,11 @@
Download complete!
Download complete with %1$d errors
Download Region
+ No nodes with position data
Map Filter\n
Map layers support .kml, .kmz, or GeoJSON formats.
+ Map failed to load
+ Location permission required for tracking
%1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s
Offline Manager
SQL Cache purge failed, see logcat for details
@@ -727,8 +730,15 @@
Map reporting interval (seconds)
Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name.
Select download region
+ Showing %1$d of %2$d nodes
Start Download
+ Dark
+ Light
+ OpenStreetMap
+ Road Map
+ Satellite
Map style selection
+ Terrain
bearing: %1$d° distance: %2$s
Tile download estimate:
Tile Source
@@ -736,6 +746,8 @@
Normal
Satellite
Terrain
+ Zoom in
+ Zoom out
Mark as read
Match All | Any
Match Any | All
@@ -917,6 +929,13 @@
Now
NTP server
Number of records
+
+ Download
+ Download visible region
+ Downloaded Regions
+ Offline Maps
+ Saves tiles for offline use
+ Unnamed Region
Ok to MQTT
OK
OLED type
@@ -1402,16 +1421,21 @@
via API
via MQTT
via UDP
+ View Details
View on map
View Release
Voltage
Wait for Bluetooth duration
Wake on tap or motion
Warning
+
Delete waypoint?
+ Waypoint deleted
Edit waypoint
+ Lock to my node
New waypoint
Received waypoint: %1$s
+ Waypoint sent
Weight
WiFi Options
@@ -1464,5 +1488,6 @@
You
简体中文
繁體中文
+ Zoom to Fit All Nodes
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index bef3d2be13..8632281515 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -23,6 +23,7 @@ import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MapCameraPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
@@ -287,6 +288,56 @@ class FakeMapPrefs : MapPrefs {
}
}
+class FakeMapCameraPrefs : MapCameraPrefs {
+ override val cameraLat = MutableStateFlow(0.0)
+
+ override fun setCameraLat(value: Double) {
+ cameraLat.value = value
+ }
+
+ override val cameraLng = MutableStateFlow(0.0)
+
+ override fun setCameraLng(value: Double) {
+ cameraLng.value = value
+ }
+
+ override val cameraZoom = MutableStateFlow(7f)
+
+ override fun setCameraZoom(value: Float) {
+ cameraZoom.value = value
+ }
+
+ override val cameraTilt = MutableStateFlow(0f)
+
+ override fun setCameraTilt(value: Float) {
+ cameraTilt.value = value
+ }
+
+ override val cameraBearing = MutableStateFlow(0f)
+
+ override fun setCameraBearing(value: Float) {
+ cameraBearing.value = value
+ }
+
+ override val selectedStyleUri = MutableStateFlow("")
+
+ override fun setSelectedStyleUri(value: String) {
+ selectedStyleUri.value = value
+ }
+
+ override val hiddenLayerUrls = MutableStateFlow(emptySet())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ hiddenLayerUrls.value = value
+ }
+
+ override val networkMapLayers = MutableStateFlow(emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ networkMapLayers.value = value
+ }
+}
+
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf>()
@@ -344,6 +395,7 @@ class FakeAppPreferences : AppPreferences {
override val emoji = FakeCustomEmojiPrefs()
override val ui = FakeUiPrefs()
override val map = FakeMapPrefs()
+ override val mapCamera = FakeMapCameraPrefs()
override val mapConsent = FakeMapConsentPrefs()
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
index 9da4588c65..c025b7ccb2 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.resources.ic_qr_code
import org.meshtastic.core.resources.ic_qr_code_2
import org.meshtastic.core.resources.ic_qr_code_scanner
import org.meshtastic.core.resources.ic_refresh
+import org.meshtastic.core.resources.ic_remove
import org.meshtastic.core.resources.ic_reply
import org.meshtastic.core.resources.ic_restart_alt
import org.meshtastic.core.resources.ic_restore
@@ -136,3 +137,5 @@ val MeshtasticIcons.BarChart: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bar_chart)
val MeshtasticIcons.List: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_list)
+val MeshtasticIcons.Remove: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_remove)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
deleted file mode 100644
index 70ed07a2b0..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
+++ /dev/null
@@ -1,33 +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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import org.meshtastic.core.ui.component.PlaceholderScreen
-
-/**
- * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it
- * falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalMapMainScreenProvider =
- compositionLocalOf<
- @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
- > {
- { _, _, _ -> PlaceholderScreen("Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
deleted file mode 100644
index 7e54003a52..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
+++ /dev/null
@@ -1,31 +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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import org.meshtastic.core.ui.component.PlaceholderScreen
-
-/**
- * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
- * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalNodeMapScreenProvider =
- compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
- { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
deleted file mode 100644
index d0901f0f9d..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.Modifier
-import org.meshtastic.core.ui.component.PlaceholderScreen
-import org.meshtastic.proto.Position
-
-/**
- * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions].
- * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded
- * inside another screen layout (e.g. the position-log adaptive layout).
- *
- * Supports optional synchronized selection:
- * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When
- * non-null, the map should visually highlight the corresponding marker and center the camera on it.
- * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so
- * the host can synchronize the card list.
- *
- * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalNodeTrackMapProvider =
- compositionLocalOf<
- @Composable (
- destNum: Int,
- positions: List,
- modifier: Modifier,
- selectedPositionTime: Int?,
- onPositionSelected: ((Int) -> Unit)?,
- ) -> Unit,
- > {
- { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt
deleted file mode 100644
index 40b174e8db..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt
+++ /dev/null
@@ -1,30 +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.core.ui.util
-
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.unit.dp
-
-data class TracerouteMapOverlayInsets(
- val overlayAlignment: Alignment = Alignment.BottomCenter,
- val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp),
- val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
-)
-
-val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
deleted file mode 100644
index 139992c540..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
+++ /dev/null
@@ -1,51 +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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.Modifier
-import org.meshtastic.core.model.TracerouteOverlay
-import org.meshtastic.core.ui.component.PlaceholderScreen
-import org.meshtastic.proto.Position
-
-/**
- * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a
- * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location
- * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s
- * scaffold.
- *
- * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
- *
- * Parameters:
- * - `tracerouteOverlay`: The overlay with forward/return route node nums.
- * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes.
- * - `onMappableCountChanged`: Callback with (shown, total) node counts.
- * - `modifier`: Compose modifier for the map.
- */
-@Suppress("Wrapping")
-val LocalTracerouteMapProvider =
- compositionLocalOf<
- @Composable (
- tracerouteOverlay: TracerouteOverlay?,
- tracerouteNodePositions: Map,
- onMappableCountChanged: (Int, Int) -> Unit,
- modifier: Modifier,
- ) -> Unit,
- > {
- { _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
deleted file mode 100644
index 26eb02b7e8..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
+++ /dev/null
@@ -1,31 +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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import org.meshtastic.core.ui.component.PlaceholderScreen
-
-/**
- * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available
- * yet, it falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalTracerouteMapScreenProvider =
- compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> {
- { _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
deleted file mode 100644
index 10d975f3d4..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
+++ /dev/null
@@ -1,31 +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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.Modifier
-
-/**
- * Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map
- * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin.
- */
-interface MapViewProvider {
- @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null)
-}
-
-val LocalMapViewProvider = compositionLocalOf { null }
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index d684fafaef..c54a322059 100644
--- a/desktopApp/build.gradle.kts
+++ b/desktopApp/build.gradle.kts
@@ -233,7 +233,42 @@ compose.desktop {
}
}
+/**
+ * Selects the MapLibre Native desktop backend capability matching the build host.
+ *
+ * MapLibre Compose ships the native renderer as mutually-exclusive per-OS/arch capability variants of
+ * `maplibre-native-bindings-jni`. Gradle can't auto-pick among them, so without an explicit selection no native library
+ * is linked and the map canvas renders black. See https://maplibre.org/maplibre-compose/getting-started/.
+ *
+ * macOS is Apple-Silicon only (Metal); Linux/Windows use OpenGL here for broadest driver/Flatpak/CI compatibility
+ * (Vulkan variants — `*-amd64-vulkan` — are also published if preferred).
+ */
+fun maplibreNativeTarget(): String {
+ val os = System.getProperty("os.name").lowercase()
+ val arch = System.getProperty("os.arch").lowercase()
+ return when {
+ os.contains("mac") || os.contains("darwin") -> {
+ require(arch == "aarch64" || arch == "arm64") {
+ "MapLibre Native desktop ships only a macOS arm64 (Apple Silicon) backend; host arch '$arch' is unsupported."
+ }
+ "macos-aarch64-metal"
+ }
+
+ os.contains("win") -> "windows-amd64-opengl"
+
+ else -> "linux-amd64-opengl"
+ }
+}
+
dependencies {
+ // MapLibre Native renderer for the current desktop host (runtime-only). Selects exactly one OS/arch
+ // capability — required, or the map renders black. See maplibreNativeTarget() above.
+ runtimeOnly("org.maplibre.compose:maplibre-native-bindings-jni:${libs.versions.maplibre.compose.get()}") {
+ capabilities {
+ requireCapability("org.maplibre.compose:maplibre-native-bindings-jni-${maplibreNativeTarget()}")
+ }
+ }
+
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index 2830fed6ee..525cb12fc8 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -25,6 +25,8 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.collections.immutable)
+ api(libs.maplibre.compose)
+ implementation(libs.maplibre.compose.material3)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
new file mode 100644
index 0000000000..f26228c792
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.feature.map
+
+import android.Manifest
+import androidx.compose.runtime.Composable
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import org.maplibre.compose.location.LocationProvider
+import org.maplibre.compose.location.rememberDefaultLocationProvider
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+actual fun rememberLocationProviderOrNull(): LocationProvider? {
+ val locationPermissions =
+ rememberMultiplePermissionsState(
+ permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
+ )
+ return if (locationPermissions.allPermissionsGranted) {
+ rememberDefaultLocationProvider()
+ } else {
+ null
+ }
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
similarity index 81%
rename from androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
rename to feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
index 8a441fa70a..c1f6cc6a02 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
@@ -14,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.map
+package org.meshtastic.feature.map
-import org.meshtastic.core.ui.util.MapViewProvider
-
-fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
+/** Android implements the full MapLibre Compose sources/layers API. */
+actual val mapOverlaysSupported: Boolean = true
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
deleted file mode 100644
index ccbfe6b5d0..0000000000
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
+++ /dev/null
@@ -1,64 +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.feature.map
-
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.map
-import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.core.ui.util.LocalMapViewProvider
-
-@Composable
-fun MapScreen(
- onClickNodeChip: (Int) -> Unit,
- navigateToNodeDetails: (Int) -> Unit,
- modifier: Modifier = Modifier,
- viewModel: SharedMapViewModel,
- waypointId: Int? = null,
-) {
- val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
- val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
-
- @Suppress("ViewModelForwarding")
- Scaffold(
- modifier = modifier,
- topBar = {
- MainAppBar(
- title = stringResource(Res.string.map),
- ourNode = ourNodeInfo,
- showNodeChip = ourNodeInfo != null && isConnected,
- canNavigateUp = false,
- onNavigateUp = {},
- actions = {},
- onClickChip = { onClickNodeChip(it.num) },
- )
- },
- ) { paddingValues ->
- LocalMapViewProvider.current?.MapView(
- modifier = Modifier.fillMaxSize().padding(paddingValues),
- navigateToNodeDetails = navigateToNodeDetails,
- waypointId = waypointId,
- )
- }
-}
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
new file mode 100644
index 0000000000..26856b139e
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.feature.map
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+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.unit.dp
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.maplibre.compose.camera.CameraState
+import org.maplibre.compose.material3.OfflinePackListItem
+import org.maplibre.compose.offline.OfflinePackDefinition
+import org.maplibre.compose.offline.rememberOfflineManager
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.done
+import org.meshtastic.core.resources.offline_download
+import org.meshtastic.core.resources.offline_download_visible_region
+import org.meshtastic.core.resources.offline_downloaded_regions
+import org.meshtastic.core.resources.offline_maps
+import org.meshtastic.core.resources.offline_saves_tiles
+import org.meshtastic.core.resources.offline_unnamed_region
+import org.meshtastic.core.ui.icon.CloudDownload
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+@Suppress("LongMethod", "ModifierMissing")
+@Composable
+actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) {
+ val offlineManager = rememberOfflineManager()
+ val coroutineScope = rememberCoroutineScope()
+ var showDialog by remember { mutableStateOf(false) }
+
+ if (showDialog) {
+ val unnamedRegion = stringResource(Res.string.offline_unnamed_region)
+ AlertDialog(
+ onDismissRequest = { showDialog = false },
+ title = { Text(stringResource(Res.string.offline_maps)) },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // Download button for current viewport
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickable {
+ coroutineScope.launch {
+ val projection = cameraState.awaitProjection()
+ val bounds = projection.queryVisibleBoundingBox()
+ val pack =
+ offlineManager.create(
+ definition =
+ OfflinePackDefinition.TilePyramid(
+ styleUrl = styleUri,
+ bounds = bounds,
+ ),
+ metadata = "Region".encodeToByteArray(),
+ )
+ offlineManager.resume(pack)
+ }
+ }
+ .padding(vertical = 12.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.CloudDownload,
+ contentDescription = stringResource(Res.string.offline_download),
+ modifier = Modifier.padding(end = 16.dp),
+ )
+ Column {
+ Text(
+ text = stringResource(Res.string.offline_download_visible_region),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = stringResource(Res.string.offline_saves_tiles),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ // Existing packs
+ if (offlineManager.packs.isNotEmpty()) {
+ Text(
+ text = stringResource(Res.string.offline_downloaded_regions),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
+ )
+ offlineManager.packs.toList().forEach { pack ->
+ key(pack.hashCode()) {
+ OfflinePackListItem(pack = pack, offlineManager = offlineManager) {
+ Text(pack.metadata?.decodeToString().orEmpty().ifBlank { unnamedRegion })
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = { showDialog = false }) { Text(stringResource(Res.string.done)) } },
+ )
+ }
+
+ // Expose the toggle via a side effect — the parent screen will call this
+ // by rendering OfflineMapContent and using the showDialog state
+ IconButton(onClick = { showDialog = true }) {
+ Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = stringResource(Res.string.offline_maps))
+ }
+}
diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
deleted file mode 100644
index 7fc3ba4c6f..0000000000
--- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
+++ /dev/null
@@ -1,63 +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.feature.map
-
-import android.database.sqlite.SQLiteDatabase
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TemporaryFolder
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.io.File
-import kotlin.test.assertEquals
-
-@RunWith(RobolectricTestRunner::class)
-class MBTilesProviderTest {
-
- @get:Rule val tempFolder = TemporaryFolder()
-
- @Test
- fun `getTile translates y coordinate correctly to TMS`() {
- val dbFile = tempFolder.newFile("test.mbtiles")
- setupMockDatabase(dbFile)
-
- val provider = MBTilesProvider(dbFile)
-
- // Google Maps zoom 1, x=0, y=0
- // TMS y = (1 << 1) - 1 - 0 = 1
- provider.getTile(0, 0, 1)
-
- // We verify the query was correct by checking the database if we could,
- // but here we just ensure it doesn't crash and returns the expected No Tile if missing.
- // To truly test, we'd need to insert data.
-
- val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
- db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')")
- db.close()
-
- val tile = provider.getTile(0, 0, 1)
- assertEquals(256, tile?.width)
- assertEquals(256, tile?.height)
- // Robolectric SQLite might return different blob handling, but let's see.
- }
-
- private fun setupMockDatabase(file: File) {
- val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY)
- db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)")
- db.close()
- }
-}
diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
deleted file mode 100644
index d665bff2dd..0000000000
--- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
+++ /dev/null
@@ -1,154 +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.feature.map
-
-import android.app.Application
-import androidx.lifecycle.SavedStateHandle
-import com.google.android.gms.maps.model.UrlTileProvider
-import dev.mokkery.MockMode
-import dev.mokkery.every
-import dev.mokkery.mock
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.repository.MapPrefs
-import org.meshtastic.core.repository.PacketRepository
-import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.core.repository.UiPrefs
-import org.meshtastic.core.testing.FakeNodeRepository
-import org.meshtastic.core.testing.FakeRadioController
-import org.meshtastic.feature.map.model.CustomTileProviderConfig
-import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs
-import org.meshtastic.feature.map.repository.CustomTileProviderRepository
-import org.robolectric.RobolectricTestRunner
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-@OptIn(ExperimentalCoroutinesApi::class)
-@RunWith(RobolectricTestRunner::class)
-class MapViewModelTest {
-
- private val application = mock(MockMode.autofill)
- private val mapPrefs = mock(MockMode.autofill)
- private val googleMapsPrefs = mock(MockMode.autofill)
- private val nodeRepository = FakeNodeRepository()
- private val packetRepository = mock(MockMode.autofill)
- private val radioConfigRepository = mock(MockMode.autofill)
- private val radioController = FakeRadioController()
- private val customTileProviderRepository = mock(MockMode.autofill)
- private val uiPrefs = mock(MockMode.autofill)
- private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
-
- private val testDispatcher = StandardTestDispatcher()
-
- private lateinit var viewModel: MapViewModel
-
- @Before
- fun setup() {
- Dispatchers.setMain(testDispatcher)
- every { mapPrefs.mapStyle } returns MutableStateFlow(0)
- every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
- every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true)
- every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true)
- every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
- every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
-
- every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0)
- every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0)
- every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f)
- every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f)
- every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f)
- every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
- every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
- every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet())
-
- every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
- every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill))
- every { uiPrefs.theme } returns MutableStateFlow(1)
- every { packetRepository.getWaypoints() } returns flowOf(emptyList())
-
- viewModel =
- MapViewModel(
- application,
- mapPrefs,
- googleMapsPrefs,
- nodeRepository,
- packetRepository,
- radioConfigRepository,
- radioController,
- customTileProviderRepository,
- uiPrefs,
- savedStateHandle,
- )
- }
-
- @After
- fun tearDown() {
- Dispatchers.resetMain()
- }
-
- @Test
- fun `getTileProvider returns UrlTileProvider for remote config`() = runTest {
- val config =
- CustomTileProviderConfig(
- name = "OpenStreetMap",
- urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
- )
-
- val provider = viewModel.getTileProvider(config)
- assertTrue(provider is UrlTileProvider)
- }
-
- @Test
- fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) {
- viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson")
- advanceUntilIdle()
-
- val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" }
- assertEquals(LayerType.GEOJSON, layer?.layerType)
- }
-
- @Test
- fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) {
- viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml")
- advanceUntilIdle()
-
- val layer = viewModel.mapLayers.value.find { it.name == "Test KML" }
- assertEquals(LayerType.KML, layer?.layerType)
- }
-
- @Test
- fun `setWaypointId updates value correctly including null`() = runTest(testDispatcher) {
- // Set to a valid ID
- viewModel.setWaypointId(123)
- assertEquals(123, viewModel.selectedWaypointId.value)
-
- // Set to null should clear the selection
- viewModel.setWaypointId(null)
- assertEquals(null, viewModel.selectedWaypointId.value)
- }
-}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index fdfd3f05a0..4170929f50 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.map
import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import org.jetbrains.compose.resources.StringResource
-import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
@@ -43,12 +43,11 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
+import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher
/**
* Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute
- * overlay state.
- *
- * Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic.
+ * overlay state. [MapViewModel] extends this with camera persistence and map style management.
*/
@Suppress("TooManyFunctions")
open class BaseMapViewModel(
@@ -56,6 +55,7 @@ open class BaseMapViewModel(
protected val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
+ private val ioDispatcher: CoroutineDispatcher = defaultIoDispatcher,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo
@@ -65,14 +65,12 @@ open class BaseMapViewModel(
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
- val myId = nodeRepository.myId
-
val isConnected =
radioController.connectionState
.map { it is org.meshtastic.core.model.ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
- val nodes: StateFlow> =
+ private val nodes: StateFlow> =
nodeRepository
.getNodes()
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
@@ -88,8 +86,8 @@ open class BaseMapViewModel(
.getWaypoints()
.mapLatest { list ->
list
- .filter { it.waypoint != null }
- .associateBy { packet -> packet.waypoint!!.id }
+ .mapNotNull { packet -> packet.waypoint?.let { wpt -> wpt.id to packet } }
+ .toMap()
.filterValues {
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
@@ -141,9 +139,6 @@ open class BaseMapViewModel(
mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
- open fun getUser(userId: String?) =
- nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
-
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
fun deleteWaypoint(id: Int) =
@@ -192,6 +187,20 @@ open class BaseMapViewModel(
lastHeardTrackFilter.value,
),
)
+
+ /** Nodes with position, filtered by favorites and last-heard preferences. */
+ val filteredNodes: StateFlow> =
+ combine(nodesWithPosition, mapFilterStateFlow) { nodes, filter ->
+ val myNum = myNodeNum
+ nodes
+ .filter { node -> !filter.onlyFavorites || node.isFavorite || node.num == myNum }
+ .filter { node ->
+ filter.lastHeardFilter.seconds == 0L ||
+ (nowSeconds - node.lastHeard) <= filter.lastHeardFilter.seconds ||
+ node.num == myNum
+ }
+ }
+ .stateInWhileSubscribed(initialValue = emptyList())
}
/**
@@ -201,14 +210,14 @@ open class BaseMapViewModel(
* @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
* @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
*/
-data class TracerouteNodeSelection(
+internal data class TracerouteNodeSelection(
val overlayNodeNums: Set,
val nodesForMarkers: List,
val nodeLookup: Map,
)
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
-fun BaseMapViewModel.tracerouteNodeSelection(
+internal fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map,
nodes: List,
@@ -225,7 +234,7 @@ fun BaseMapViewModel.tracerouteNodeSelection(
*
* @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
*/
-fun tracerouteNodeSelection(
+internal fun tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map,
nodes: List,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
similarity index 58%
rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt
rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
index bcebdabf62..7bda5766d2 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -16,16 +16,13 @@
*/
package org.meshtastic.feature.map
-import org.koin.core.annotation.KoinViewModel
-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 androidx.compose.runtime.Composable
+import org.maplibre.compose.location.LocationProvider
-@KoinViewModel
-class SharedMapViewModel(
- mapPrefs: MapPrefs,
- nodeRepository: NodeRepository,
- packetRepository: PacketRepository,
- radioController: RadioController,
-) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)
+/**
+ * Returns a platform-appropriate [LocationProvider], or `null` if the platform doesn't support location.
+ * - Android: uses the platform `LocationManager` via `rememberDefaultLocationProvider()`.
+ * - iOS: uses `CLLocationManager` via `rememberDefaultLocationProvider()`.
+ * - Desktop/JS: returns `null` (no location hardware).
+ */
+@Composable expect fun rememberLocationProviderOrNull(): LocationProvider?
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
new file mode 100644
index 0000000000..dc57f4959d
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.feature.map
+
+/**
+ * Whether the current platform's MapLibre Compose target implements programmatic map sources and layers
+ * (`rememberGeoJsonSource`, `CircleLayer`, `SymbolLayer`, `LineLayer`, `HillshadeLayer`, …).
+ *
+ * As of maplibre-compose 0.13.0 the desktop (JVM) target stubs the **entire** sources/layers API with `TODO()`, so
+ * composing any overlay throws `NotImplementedError` ("An operation is not implemented") and tears down the window. The
+ * base map style still renders natively from its style URI. Every source/layer composition must therefore be guarded
+ * behind this flag; when `false`, only the base map is shown.
+ *
+ * Re-evaluate on each maplibre-compose upgrade — once the desktop target implements layers/sources, set this `true` for
+ * JVM and the guards become no-ops.
+ */
+expect val mapOverlaysSupported: Boolean
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
new file mode 100644
index 0000000000..dd104fe584
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
@@ -0,0 +1,380 @@
+/*
+ * 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.feature.map
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.maplibre.compose.camera.CameraMoveReason
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.camera.rememberCameraState
+import org.maplibre.compose.location.BearingUpdate
+import org.maplibre.compose.location.LocationTrackingEffect
+import org.maplibre.compose.location.rememberNullLocationProvider
+import org.maplibre.compose.location.rememberUserLocationState
+import org.maplibre.compose.map.GestureOptions
+import org.maplibre.compose.material3.DisappearingScaleBar
+import org.maplibre.compose.material3.ExpandingAttributionButton
+import org.maplibre.compose.style.rememberStyleState
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.map
+import org.meshtastic.core.resources.map_empty_state
+import org.meshtastic.core.resources.map_load_error
+import org.meshtastic.core.resources.map_location_unavailable
+import org.meshtastic.core.resources.waypoint_deleted
+import org.meshtastic.core.resources.waypoint_sent
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.map.component.EditWaypointDialog
+import org.meshtastic.feature.map.component.MapControlsOverlay
+import org.meshtastic.feature.map.component.MapEmptyState
+import org.meshtastic.feature.map.component.MapFilterDropdown
+import org.meshtastic.feature.map.component.MapStyleSelector
+import org.meshtastic.feature.map.component.MaplibreMapContent
+import org.meshtastic.feature.map.component.NodeInfoSheet
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.computeBoundingBox
+import org.meshtastic.feature.map.util.toGeoPositionOrNull
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+private const val WAYPOINT_ZOOM = 15.0
+private const val MIN_ZOOM = 0.0
+private const val MAX_ZOOM = 24.0
+private val MAP_OVERLAY_PADDING = 16.dp
+
+/**
+ * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers,
+ * waypoints, and overlays.
+ *
+ * This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform
+ * composable.
+ */
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+fun MapScreen(
+ onClickNodeChip: (Int) -> Unit,
+ navigateToNodeDetails: (Int) -> Unit,
+ viewModel: MapViewModel,
+ modifier: Modifier = Modifier,
+ waypointId: Int? = null,
+) {
+ val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
+ val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
+ val filteredNodes by viewModel.filteredNodes.collectAsStateWithLifecycle()
+ val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle()
+ val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
+ val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
+ val selectedMapStyle by viewModel.selectedMapStyle.collectAsStateWithLifecycle()
+
+ LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) }
+
+ val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition)
+ val styleState = rememberStyleState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var filterMenuExpanded by remember { mutableStateOf(false) }
+
+ // Node info sheet state
+ var selectedNodeNum by remember { mutableStateOf(null) }
+
+ // Waypoint dialog state
+ var showWaypointDialog by remember { mutableStateOf(false) }
+ var longPressPosition by remember { mutableStateOf(null) }
+ var editingWaypointId by remember { mutableStateOf(null) }
+
+ val scope = rememberCoroutineScope()
+
+ // Snackbar messages for map load error
+ val mapLoadErrorMsg = stringResource(Res.string.map_load_error)
+ val locationUnavailableMsg = stringResource(Res.string.map_location_unavailable)
+ val waypointSentMsg = stringResource(Res.string.waypoint_sent)
+ val waypointDeletedMsg = stringResource(Res.string.waypoint_deleted)
+
+ // Active filter count for badge
+ val activeFilterCount =
+ remember(filterState) {
+ var count = 0
+ if (filterState.onlyFavorites) count++
+ if (!filterState.showWaypoints) count++
+ if (filterState.lastHeardFilter != LastHeardFilter.Any) count++
+ count
+ }
+
+ // Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off)
+ var isLocationTrackingEnabled by remember { mutableStateOf(false) }
+ var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_AUTOMATIC) }
+ val locationProvider = rememberLocationProviderOrNull()
+ val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
+ val locationAvailable = locationProvider != null
+
+ // Derive gesture options from location tracking state
+ val gestureOptions =
+ remember(isLocationTrackingEnabled, bearingUpdate) {
+ if (isLocationTrackingEnabled) {
+ when (bearingUpdate) {
+ BearingUpdate.IGNORE -> GestureOptions.PositionLocked
+
+ BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly
+
+ BearingUpdate.TRACK_AUTOMATIC,
+ BearingUpdate.TRACK_COURSE,
+ BearingUpdate.TRACK_ORIENTATION,
+ -> GestureOptions.ZoomOnly
+ }
+ } else {
+ GestureOptions.Standard
+ }
+ }
+
+ // Animate to waypoint when waypointId is provided (deep-link)
+ val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle()
+ LaunchedEffect(selectedWaypointId, waypoints) {
+ val wpId = selectedWaypointId ?: return@LaunchedEffect
+ val packet = waypoints[wpId] ?: return@LaunchedEffect
+ val wpt = packet.waypoint ?: return@LaunchedEffect
+ val geoPos = toGeoPositionOrNull(wpt.latitude_i, wpt.longitude_i) ?: return@LaunchedEffect
+ cameraState.animateTo(CameraPosition(target = geoPos, zoom = WAYPOINT_ZOOM))
+ }
+
+ @Suppress("ViewModelForwarding")
+ Scaffold(
+ modifier = modifier,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.map),
+ ourNode = ourNodeInfo,
+ showNodeChip = ourNodeInfo != null && isConnected,
+ canNavigateUp = false,
+ onNavigateUp = {},
+ actions = {},
+ onClickChip = { onClickNodeChip(it.num) },
+ )
+ },
+ ) { paddingValues ->
+ Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
+ MaplibreMapContent(
+ nodes = filteredNodes,
+ waypoints = waypoints,
+ baseStyle = baseStyle,
+ cameraState = cameraState,
+ myNodeNum = viewModel.myNodeNum,
+ showWaypoints = filterState.showWaypoints,
+ showPrecisionCircle = filterState.showPrecisionCircle,
+ showHillshade = selectedMapStyle == MapStyle.Terrain,
+ onNodeClick = { nodeNum -> selectedNodeNum = nodeNum },
+ onMapLongClick = { position ->
+ longPressPosition = position
+ editingWaypointId = null
+ showWaypointDialog = true
+ },
+ modifier = Modifier.fillMaxSize(),
+ gestureOptions = gestureOptions,
+ styleState = styleState,
+ onCameraMove = { position -> viewModel.saveCameraPosition(position) },
+ onWaypointClick = { wpId ->
+ editingWaypointId = wpId
+ longPressPosition = null
+ showWaypointDialog = true
+ },
+ locationState = if (locationAvailable) locationState else null,
+ onMapLoadFail = { _ -> scope.launch { snackbarHostState.showSnackbar(mapLoadErrorMsg) } },
+ )
+
+ // Empty state when no nodes have positions
+ if (nodesWithPosition.isEmpty()) {
+ MapEmptyState(
+ message = stringResource(Res.string.map_empty_state),
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ // Auto-pan camera when location tracking is enabled
+ if (locationAvailable) {
+ LocationTrackingEffect(
+ locationState = locationState,
+ enabled = isLocationTrackingEnabled,
+ trackBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
+ ) {
+ cameraState.updateFromLocation(updateBearing = bearingUpdate)
+ }
+
+ // Cancel tracking when user manually pans the map
+ LaunchedEffect(cameraState.moveReason) {
+ if (cameraState.moveReason == CameraMoveReason.GESTURE && isLocationTrackingEnabled) {
+ isLocationTrackingEnabled = false
+ bearingUpdate = BearingUpdate.IGNORE
+ }
+ }
+ }
+
+ MapControlsOverlay(
+ onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
+ modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
+ bearing = cameraState.position.bearing.toFloat(),
+ onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
+ followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
+ activeFilterCount = activeFilterCount,
+ filterDropdownContent = {
+ MapFilterDropdown(
+ expanded = filterMenuExpanded,
+ onDismissRequest = { filterMenuExpanded = false },
+ filterState = filterState,
+ onToggleFavorites = viewModel::toggleOnlyFavorites,
+ onToggleWaypoints = viewModel::toggleShowWaypointsOnMap,
+ onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap,
+ onSetLastHeardFilter = viewModel::setLastHeardFilter,
+ onZoomToFitAll = {
+ val positions =
+ filteredNodes.mapNotNull { node ->
+ node.validPosition?.let { pos ->
+ toGeoPositionOrNull(pos.latitude_i, pos.longitude_i)
+ }
+ }
+ val bbox = computeBoundingBox(positions) ?: return@MapFilterDropdown
+ scope.launch { cameraState.animateTo(bbox, padding = PaddingValues(48.dp)) }
+ },
+ )
+ },
+ mapTypeContent = {
+ MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle)
+ },
+ layersContent = { OfflineMapContent(styleUri = selectedMapStyle.styleUri, cameraState = cameraState) },
+ isLocationTrackingEnabled = isLocationTrackingEnabled,
+ isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
+ onToggleLocationTracking = {
+ if (!locationAvailable) {
+ scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) }
+ } else if (!isLocationTrackingEnabled) {
+ // Off → Track with bearing
+ bearingUpdate = BearingUpdate.TRACK_AUTOMATIC
+ isLocationTrackingEnabled = true
+ } else {
+ when (bearingUpdate) {
+ BearingUpdate.TRACK_AUTOMATIC -> {
+ // TrackBearing → TrackNorth
+ bearingUpdate = BearingUpdate.ALWAYS_NORTH
+ }
+
+ else -> {
+ // TrackNorth (or any other) → Off
+ isLocationTrackingEnabled = false
+ }
+ }
+ }
+ },
+ onZoomIn = {
+ scope.launch {
+ cameraState.animateTo(
+ cameraState.position.copy(zoom = minOf(cameraState.position.zoom + 1.0, MAX_ZOOM)),
+ )
+ }
+ },
+ onZoomOut = {
+ scope.launch {
+ cameraState.animateTo(
+ cameraState.position.copy(zoom = maxOf(cameraState.position.zoom - 1.0, MIN_ZOOM)),
+ )
+ }
+ },
+ )
+
+ // Scale bar — auto-shows on zoom change, hides after 3 seconds
+ DisappearingScaleBar(
+ metersPerDp = cameraState.metersPerDpAtTarget,
+ zoom = cameraState.position.zoom,
+ modifier = Modifier.align(Alignment.BottomStart).padding(MAP_OVERLAY_PADDING),
+ )
+
+ // Attribution button — shows tile provider attributions (legal compliance)
+ ExpandingAttributionButton(
+ cameraState = cameraState,
+ styleState = styleState,
+ modifier = Modifier.align(Alignment.BottomEnd).padding(MAP_OVERLAY_PADDING),
+ )
+ }
+ }
+
+ // Waypoint creation/edit dialog
+ if (showWaypointDialog) {
+ val editingPacket = editingWaypointId?.let { waypoints[it] }
+ val editingWaypoint = editingPacket?.waypoint
+
+ EditWaypointDialog(
+ onDismiss = {
+ showWaypointDialog = false
+ editingWaypointId = null
+ longPressPosition = null
+ },
+ onSend = { name, description, icon, locked, expire ->
+ viewModel.createAndSendWaypoint(
+ name = name,
+ description = description,
+ icon = icon,
+ locked = locked,
+ expire = expire,
+ existingWaypoint = editingWaypoint,
+ position = longPressPosition,
+ )
+ scope.launch { snackbarHostState.showSnackbar(waypointSentMsg) }
+ },
+ onDelete =
+ editingWaypoint?.let { wpt ->
+ {
+ viewModel.deleteWaypoint(wpt.id)
+ scope.launch { snackbarHostState.showSnackbar(waypointDeletedMsg) }
+ }
+ },
+ initialName = editingWaypoint?.name ?: "",
+ initialDescription = editingWaypoint?.description ?: "",
+ initialIcon = editingWaypoint?.icon ?: 0,
+ initialLocked = (editingWaypoint?.locked_to ?: 0) != 0,
+ isEditing = editingWaypoint != null,
+ position = longPressPosition,
+ )
+ }
+
+ // Node info bottom sheet
+ val selectedNode = selectedNodeNum?.let { num -> filteredNodes.find { it.num == num } }
+ if (selectedNode != null) {
+ NodeInfoSheet(
+ node = selectedNode,
+ onDismiss = { selectedNodeNum = null },
+ onViewDetails = { nodeNum ->
+ selectedNodeNum = null
+ navigateToNodeDetails(nodeNum)
+ },
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt
new file mode 100644
index 0000000000..b9b55d0dc8
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.feature.map
+
+import androidx.lifecycle.SavedStateHandle
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import org.koin.core.annotation.KoinViewModel
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.style.BaseStyle
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.MapCameraPrefs
+import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.COORDINATE_SCALE
+import org.meshtastic.proto.Waypoint
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher
+
+/**
+ * Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels.
+ *
+ * Manages camera state persistence, map style selection, and waypoint selection using MapLibre Compose Multiplatform
+ * types. All map-related state is shared across platforms.
+ */
+@KoinViewModel
+class MapViewModel(
+ mapPrefs: MapPrefs,
+ private val mapCameraPrefs: MapCameraPrefs,
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+ radioController: RadioController,
+ savedStateHandle: SavedStateHandle,
+ ioDispatcher: CoroutineDispatcher = defaultIoDispatcher,
+) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, ioDispatcher) {
+
+ /** Currently selected waypoint to focus on map. */
+ private val selectedWaypointIdInternal = MutableStateFlow(savedStateHandle.get("waypointId"))
+ val selectedWaypointId: StateFlow = selectedWaypointIdInternal.asStateFlow()
+
+ fun setWaypointId(id: Int?) {
+ selectedWaypointIdInternal.value = id
+ }
+
+ /** Initial camera position restored from persistent preferences. */
+ val initialCameraPosition: CameraPosition
+ get() =
+ CameraPosition(
+ target =
+ GeoPosition(longitude = mapCameraPrefs.cameraLng.value, latitude = mapCameraPrefs.cameraLat.value),
+ zoom = mapCameraPrefs.cameraZoom.value.toDouble(),
+ tilt = mapCameraPrefs.cameraTilt.value.toDouble(),
+ bearing = mapCameraPrefs.cameraBearing.value.toDouble(),
+ )
+
+ /** Currently selected map style enum index. */
+ val selectedMapStyle: StateFlow =
+ mapCameraPrefs.selectedStyleUri
+ .map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap }
+ .stateInWhileSubscribed(MapStyle.OpenStreetMap)
+
+ /** Active map base style derived from the selected [MapStyle]. */
+ val baseStyle: StateFlow =
+ selectedMapStyle.map { it.toBaseStyle() }.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle())
+
+ /** Persist camera position to DataStore. */
+ fun saveCameraPosition(position: CameraPosition) {
+ mapCameraPrefs.setCameraLat(position.target.latitude)
+ mapCameraPrefs.setCameraLng(position.target.longitude)
+ mapCameraPrefs.setCameraZoom(position.zoom.toFloat())
+ mapCameraPrefs.setCameraTilt(position.tilt.toFloat())
+ mapCameraPrefs.setCameraBearing(position.bearing.toFloat())
+ }
+
+ /** Select a predefined map style. */
+ fun selectMapStyle(style: MapStyle) {
+ mapCameraPrefs.setSelectedStyleUri(style.styleUri)
+ }
+
+ /**
+ * Create a [Waypoint] proto from user-provided fields, handling coordinate conversion and ID generation.
+ *
+ * @param existingWaypoint If non-null, the waypoint being edited (retains its id and coordinates).
+ * @param position If non-null, the long-press position for a new waypoint.
+ */
+ fun createAndSendWaypoint(
+ name: String,
+ description: String,
+ icon: Int,
+ locked: Boolean,
+ expire: Int,
+ existingWaypoint: Waypoint?,
+ position: GeoPosition?,
+ ) {
+ val wpt =
+ Waypoint(
+ id = existingWaypoint?.id ?: generatePacketId(),
+ name = name,
+ description = description,
+ icon = icon,
+ locked_to = if (locked) (myNodeNum ?: 0) else 0,
+ latitude_i =
+ existingWaypoint?.latitude_i ?: position?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0,
+ longitude_i =
+ existingWaypoint?.longitude_i ?: position?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0,
+ expire = expire,
+ )
+ sendWaypoint(wpt)
+ }
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
similarity index 61%
rename from androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
index 5bd9fdf87f..734afcec1d 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
@@ -14,18 +14,14 @@
* 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
+package org.meshtastic.feature.map
-import kotlinx.serialization.Serializable
-import kotlin.uuid.Uuid
+import androidx.compose.runtime.Composable
-@Serializable
-data class CustomTileProviderConfig(
- val id: String = Uuid.random().toString(),
- val name: String,
- val urlTemplate: String,
- val localUri: String? = null,
-) {
- val isLocal: Boolean
- get() = localUri != null
-}
+/**
+ * Renders platform-specific offline map management UI if the platform supports it. The composable receives the current
+ * style URI and [cameraState] for downloading the visible region.
+ *
+ * On unsupported platforms, this is a no-op.
+ */
+@Composable expect fun OfflineMapContent(styleUri: String, cameraState: org.maplibre.compose.camera.CameraState)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
new file mode 100644
index 0000000000..8578885c4e
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.width
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+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.mutableIntStateOf
+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.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.delete
+import org.meshtastic.core.resources.description
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.send
+import org.meshtastic.core.resources.waypoint_edit
+import org.meshtastic.core.resources.waypoint_lock_to_my_node
+import org.meshtastic.core.resources.waypoint_new
+import org.meshtastic.feature.map.util.convertIntToEmoji
+import kotlin.math.abs
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+private const val MAX_NAME_LENGTH = 29
+private const val MAX_DESCRIPTION_LENGTH = 99
+private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin (📍) — same as PIN_EMOJI in GeoJsonConverters
+private const val FORMAT_DECIMAL_FACTOR = 1_000_000L
+
+/**
+ * Dialog for creating or editing a waypoint on the map.
+ *
+ * Replaces the old Android-specific `EditWaypointDialog` with a fully cross-platform Compose Multiplatform version.
+ * Date/time picker for expiry is deferred (requires platform-specific pickers or CMP M3 DatePicker availability).
+ */
+@Suppress("LongParameterList", "LongMethod")
+@Composable
+fun EditWaypointDialog(
+ onDismiss: () -> Unit,
+ onSend: (name: String, description: String, icon: Int, locked: Boolean, expire: Int) -> Unit,
+ onDelete: (() -> Unit)? = null,
+ initialName: String = "",
+ initialDescription: String = "",
+ initialIcon: Int = DEFAULT_EMOJI,
+ initialLocked: Boolean = false,
+ isEditing: Boolean = false,
+ position: GeoPosition? = null,
+) {
+ var name by remember { mutableStateOf(initialName) }
+ var description by remember { mutableStateOf(initialDescription) }
+ var emojiCodepoint by remember { mutableIntStateOf(if (initialIcon != 0) initialIcon else DEFAULT_EMOJI) }
+ var locked by remember { mutableStateOf(initialLocked) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = stringResource(if (isEditing) Res.string.waypoint_edit else Res.string.waypoint_new),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // Emoji + Name row
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = convertIntToEmoji(emojiCodepoint),
+ style = MaterialTheme.typography.headlineLarge,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ OutlinedTextField(
+ value = name,
+ onValueChange = { if (it.length <= MAX_NAME_LENGTH) name = it },
+ label = { Text(stringResource(Res.string.name)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Description
+ OutlinedTextField(
+ value = description,
+ onValueChange = { if (it.length <= MAX_DESCRIPTION_LENGTH) description = it },
+ label = { Text(stringResource(Res.string.description)) },
+ maxLines = 3,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Lock toggle
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ stringResource(Res.string.waypoint_lock_to_my_node),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Switch(checked = locked, onCheckedChange = { locked = it })
+ }
+
+ // Position info
+ if (position != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${position.latitude.formatCoord()}, ${position.longitude.formatCoord()}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ dismissButton = {
+ Row {
+ if (onDelete != null) {
+ TextButton(
+ onClick = {
+ onDelete()
+ onDismiss()
+ },
+ ) {
+ Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onSend(name, description, emojiCodepoint, locked, 0)
+ onDismiss()
+ },
+ enabled = name.isNotBlank(),
+ ) {
+ Text(stringResource(Res.string.send))
+ }
+ },
+ )
+}
+
+/** Format a coordinate to 6 decimal places without using JVM-only String.format(). */
+@Suppress("MagicNumber")
+private fun Double.formatCoord(): String {
+ val negative = this < 0
+ val absVal = abs(this)
+ val wholePart = absVal.toLong()
+ val fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong()
+ val fracStr = fracPart.toString().padStart(6, '0')
+ return "${if (negative) "-" else ""}$wholePart.$fracStr"
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt
new file mode 100644
index 0000000000..541bd6ec78
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.camera.rememberCameraState
+import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.offset
+import org.maplibre.compose.expressions.value.SymbolAnchor
+import org.maplibre.compose.layers.CircleLayer
+import org.maplibre.compose.layers.SymbolLayer
+import org.maplibre.compose.map.GestureOptions
+import org.maplibre.compose.map.MapOptions
+import org.maplibre.compose.map.MaplibreMap
+import org.maplibre.compose.map.OrnamentOptions
+import org.maplibre.compose.sources.GeoJsonData
+import org.maplibre.compose.sources.rememberGeoJsonSource
+import org.maplibre.spatialk.geojson.Feature
+import org.maplibre.spatialk.geojson.FeatureCollection
+import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.model.Node
+import org.meshtastic.feature.map.mapOverlaysSupported
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
+import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
+import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA
+import org.meshtastic.feature.map.util.precisionBitsToMeters
+import org.meshtastic.feature.map.util.toGeoPositionOrNull
+
+private const val DEFAULT_ZOOM = 15.0
+private const val LOW_PRECISION_ZOOM = 12.0
+private const val PRECISION_THRESHOLD_METERS = 500
+private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f
+private const val LABEL_OFFSET = -2f
+
+/**
+ * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the
+ * Google Maps and OSMDroid inline map implementations.
+ */
+@Composable
+fun InlineMap(node: Node, modifier: Modifier = Modifier) {
+ val position = node.validPosition ?: return
+ val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return
+
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+
+ // Adaptive zoom: zoom out for imprecise positions so the precision circle is visible
+ val precisionMeters = precisionBitsToMeters(position.precision_bits)
+ val zoom = if (precisionMeters > PRECISION_THRESHOLD_METERS) LOW_PRECISION_ZOOM else DEFAULT_ZOOM
+
+ key(node.num) {
+ val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = zoom))
+
+ val nodeFeature =
+ remember(node.num, geoPos) {
+ FeatureCollection(listOf(Feature(geometry = Point(geoPos), properties = null)))
+ }
+
+ MaplibreMap(
+ modifier = modifier,
+ baseStyle = MapStyle.OpenStreetMap.toBaseStyle(),
+ cameraState = cameraState,
+ options =
+ MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled),
+ ) {
+ // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See
+ // [mapOverlaysSupported].
+ if (!mapOverlaysSupported) return@MaplibreMap
+
+ val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature))
+
+ // Node marker dot
+ CircleLayer(
+ id = "inline-node-marker",
+ source = source,
+ radius = const(NODE_MARKER_RADIUS),
+ color = const(Color(node.colors.second)),
+ strokeWidth = const(MARKER_STROKE_WIDTH),
+ strokeColor = const(Color.White),
+ )
+
+ // Short name label above the marker
+ val shortName = node.user.short_name
+ if (!shortName.isNullOrBlank()) {
+ SymbolLayer(
+ id = "inline-node-label",
+ source = source,
+ textField = const(shortName).cast(),
+ textSize = const(0.9f.em),
+ textOffset = offset(0f.em, LABEL_OFFSET.em),
+ textAnchor = const(SymbolAnchor.Bottom),
+ textColor = const(labelColor),
+ )
+ }
+
+ // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp
+ val metersPerDp = cameraState.metersPerDpAtTarget
+ if (precisionMeters > 0 && metersPerDp > 0) {
+ val radiusDp = (precisionMeters / metersPerDp).dp
+ CircleLayer(
+ id = "inline-node-precision",
+ source = source,
+ radius = const(radiusDp),
+ color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
+ strokeWidth = const(1.dp),
+ strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
+ )
+ }
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt
index 3234de5871..c0473afb3d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt
@@ -16,31 +16,42 @@
*/
package org.meshtastic.feature.map.component
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-/**
- * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
- * across both Google and F-Droid flavors.
- */
+/** A compact icon button used in map control overlays. Uses [FilledIconButton] with a hover tooltip for desktop. */
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun MapButton(
+internal fun MapButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = null,
) {
- FilledIconButton(onClick = onClick, modifier = modifier) {
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
- )
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(contentDescription) } },
+ state = rememberTooltipState(),
+ ) {
+ FilledIconButton(onClick = onClick, modifier = modifier) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
+ )
+ }
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index 1445424b3f..8e505c05e9 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -19,30 +19,41 @@
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter
+import org.meshtastic.core.resources.map_zoom_in
+import org.meshtastic.core.resources.map_zoom_out
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
-import org.meshtastic.core.ui.icon.LocationDisabled
+import org.meshtastic.core.ui.icon.Add
+import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
+import org.meshtastic.core.ui.icon.NearMe
import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.icon.Remove
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import kotlin.math.abs
/**
* Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass,
@@ -60,7 +71,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
* @param onRefresh Callback when the refresh button is clicked.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "LongMethod")
@Composable
fun MapControlsOverlay(
onToggleFilterMenu: () -> Unit,
@@ -68,69 +79,107 @@ fun MapControlsOverlay(
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean = false,
+ activeFilterCount: Int = 0,
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
isLocationTrackingEnabled: Boolean = false,
+ isTrackingBearing: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
+ onZoomIn: () -> Unit = {},
+ onZoomOut: () -> Unit = {},
) {
- HorizontalFloatingToolbar(
- expanded = true,
- modifier = modifier,
- colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
- ) {
- // Compass
- CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
+ Column(modifier = modifier, horizontalAlignment = Alignment.End) {
+ HorizontalFloatingToolbar(expanded = true, colors = FloatingToolbarDefaults.standardFloatingToolbarColors()) {
+ // Compass
+ CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
- // Filter button + dropdown
- Box {
- MapButton(
- icon = MeshtasticIcons.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- onClick = onToggleFilterMenu,
- )
- filterDropdownContent()
- }
+ // Filter button + dropdown with badge
+ Box {
+ if (activeFilterCount > 0) {
+ BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) {
+ MapButton(
+ icon = MeshtasticIcons.Tune,
+ contentDescription = stringResource(Res.string.map_filter),
+ onClick = onToggleFilterMenu,
+ )
+ }
+ } else {
+ MapButton(
+ icon = MeshtasticIcons.Tune,
+ contentDescription = stringResource(Res.string.map_filter),
+ onClick = onToggleFilterMenu,
+ )
+ }
+ filterDropdownContent()
+ }
- // Map type selector (flavor-specific)
- mapTypeContent()
+ // Map type selector (flavor-specific)
+ mapTypeContent()
- // Layers button (flavor-specific)
- layersContent()
+ // Layers button (flavor-specific)
+ layersContent()
- // Refresh button (optional)
- if (showRefresh) {
- if (isRefreshing) {
- Box(modifier = Modifier.padding(8.dp)) {
- CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
+ // Refresh button (optional)
+ if (showRefresh) {
+ if (isRefreshing) {
+ Box(modifier = Modifier.padding(8.dp)) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
+ }
+ } else {
+ MapButton(
+ icon = MeshtasticIcons.Refresh,
+ contentDescription = stringResource(Res.string.refresh),
+ onClick = onRefresh,
+ )
}
- } else {
- MapButton(
- icon = MeshtasticIcons.Refresh,
- contentDescription = stringResource(Res.string.refresh),
- onClick = onRefresh,
- )
}
+
+ // Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn)
+ MapButton(
+ icon =
+ when {
+ !isLocationTrackingEnabled -> MeshtasticIcons.MyLocation
+ isTrackingBearing -> MeshtasticIcons.NearMe
+ else -> MeshtasticIcons.LocationOn
+ },
+ contentDescription = stringResource(Res.string.toggle_my_position),
+ iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null,
+ onClick = onToggleLocationTracking,
+ )
}
- // Location tracking button
- MapButton(
- icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation,
- contentDescription = stringResource(Res.string.toggle_my_position),
- onClick = onToggleLocationTracking,
- )
+ // Zoom buttons (useful for desktop/accessibility where pinch isn't natural)
+ HorizontalFloatingToolbar(
+ expanded = true,
+ modifier = Modifier.padding(top = 4.dp),
+ colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
+ ) {
+ MapButton(
+ icon = MeshtasticIcons.Add,
+ contentDescription = stringResource(Res.string.map_zoom_in),
+ onClick = onZoomIn,
+ )
+ MapButton(
+ icon = MeshtasticIcons.Remove,
+ contentDescription = stringResource(Res.string.map_zoom_out),
+ onClick = onZoomOut,
+ )
+ }
}
}
+private const val BEARING_NORTH_THRESHOLD = 0.5f
+
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val iconTint =
when {
isFollowing -> MaterialTheme.colorScheme.primary
- bearing == 0f -> MaterialTheme.colorScheme.StatusRed
+ abs(bearing) < BEARING_NORTH_THRESHOLD -> MaterialTheme.colorScheme.StatusRed
else -> null
}
MapButton(
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt
new file mode 100644
index 0000000000..388562c1c1
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.icon.LocationDisabled
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+/** Centered empty state overlay shown when no nodes have position data. */
+@Composable
+internal fun MapEmptyState(message: String, modifier: Modifier = Modifier) {
+ Surface(modifier = modifier.padding(24.dp), shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.LocationDisabled,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
new file mode 100644
index 0000000000..015fd47564
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+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.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.last_heard_filter_label
+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.zoom_to_fit_all
+import org.meshtastic.core.ui.icon.Favorite
+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.icon.SelectAll
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
+import org.meshtastic.feature.map.LastHeardFilter
+import kotlin.math.roundToInt
+
+/**
+ * Dropdown menu for filtering map markers by favorites, waypoints, precision circles, and last-heard time.
+ *
+ * Mirrors the old Google/F-Droid `MapFilterDropdown` — checkboxes for boolean toggles and a slider for last-heard time
+ * filter.
+ */
+@Composable
+internal fun MapFilterDropdown(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ filterState: MapFilterState,
+ onToggleFavorites: () -> Unit,
+ onToggleWaypoints: () -> Unit,
+ onTogglePrecisionCircle: () -> Unit,
+ onSetLastHeardFilter: (LastHeardFilter) -> Unit,
+ onZoomToFitAll: () -> Unit,
+) {
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.only_favorites)) },
+ onClick = onToggleFavorites,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.Favorite,
+ contentDescription = stringResource(Res.string.only_favorites),
+ )
+ },
+ trailingIcon = { Checkbox(checked = filterState.onlyFavorites, onCheckedChange = { onToggleFavorites() }) },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.show_waypoints)) },
+ onClick = onToggleWaypoints,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = stringResource(Res.string.show_waypoints),
+ )
+ },
+ trailingIcon = { Checkbox(checked = filterState.showWaypoints, onCheckedChange = { onToggleWaypoints() }) },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.show_precision_circle)) },
+ onClick = onTogglePrecisionCircle,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.Lens,
+ contentDescription = stringResource(Res.string.show_precision_circle),
+ )
+ },
+ trailingIcon = {
+ Checkbox(checked = filterState.showPrecisionCircle, onCheckedChange = { onTogglePrecisionCircle() })
+ },
+ )
+ HorizontalDivider()
+ LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter)
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.zoom_to_fit_all)) },
+ onClick = {
+ onZoomToFitAll()
+ onDismissRequest()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.SelectAll,
+ contentDescription = stringResource(Res.string.zoom_to_fit_all),
+ )
+ },
+ )
+ }
+}
+
+@Composable
+private fun LastHeardSlider(currentFilter: LastHeardFilter, onSetFilter: (LastHeardFilter) -> Unit) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(currentFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+
+ Text(
+ text = stringResource(Res.string.last_heard_filter_label, stringResource(currentFilter.label)),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ onSetFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt
new file mode 100644
index 0000000000..9d3993aab3
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+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 org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.map_style_selection
+import org.meshtastic.core.resources.selected_map_type
+import org.meshtastic.core.ui.icon.Check
+import org.meshtastic.core.ui.icon.Layers
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.feature.map.model.MapStyle
+
+/**
+ * Map style selector button + dropdown menu. Shows predefined [MapStyle] entries with a checkmark next to the currently
+ * selected style.
+ */
+@Composable
+internal fun MapStyleSelector(selectedStyle: MapStyle, onSelectStyle: (MapStyle) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Box {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.map_style_selection),
+ onClick = { expanded = true },
+ )
+
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ MapStyle.entries.forEach { style ->
+ DropdownMenuItem(
+ text = { Text(stringResource(style.label)) },
+ onClick = {
+ onSelectStyle(style)
+ expanded = false
+ },
+ trailingIcon =
+ if (selectedStyle == style) {
+ {
+ Icon(
+ MeshtasticIcons.Check,
+ contentDescription = stringResource(Res.string.selected_map_type),
+ )
+ }
+ } else {
+ null
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
new file mode 100644
index 0000000000..887e3003dd
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
@@ -0,0 +1,411 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.material3.MaterialTheme
+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.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.camera.CameraState
+import org.maplibre.compose.expressions.dsl.asString
+import org.maplibre.compose.expressions.dsl.condition
+import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.convertToBoolean
+import org.maplibre.compose.expressions.dsl.convertToColor
+import org.maplibre.compose.expressions.dsl.convertToNumber
+import org.maplibre.compose.expressions.dsl.dp
+import org.maplibre.compose.expressions.dsl.exponential
+import org.maplibre.compose.expressions.dsl.feature
+import org.maplibre.compose.expressions.dsl.format
+import org.maplibre.compose.expressions.dsl.interpolate
+import org.maplibre.compose.expressions.dsl.not
+import org.maplibre.compose.expressions.dsl.offset
+import org.maplibre.compose.expressions.dsl.span
+import org.maplibre.compose.expressions.dsl.switch
+import org.maplibre.compose.expressions.dsl.times
+import org.maplibre.compose.expressions.dsl.zoom
+import org.maplibre.compose.layers.CircleLayer
+import org.maplibre.compose.layers.HillshadeLayer
+import org.maplibre.compose.layers.SymbolLayer
+import org.maplibre.compose.location.LocationPuck
+import org.maplibre.compose.location.UserLocationState
+import org.maplibre.compose.map.GestureOptions
+import org.maplibre.compose.map.MapOptions
+import org.maplibre.compose.map.MaplibreMap
+import org.maplibre.compose.map.OrnamentOptions
+import org.maplibre.compose.material3.LocationPuckDefaults
+import org.maplibre.compose.sources.GeoJsonData
+import org.maplibre.compose.sources.GeoJsonOptions
+import org.maplibre.compose.sources.RasterDemEncoding
+import org.maplibre.compose.sources.rememberGeoJsonSource
+import org.maplibre.compose.sources.rememberRasterDemSource
+import org.maplibre.compose.style.BaseStyle
+import org.maplibre.compose.style.StyleState
+import org.maplibre.compose.style.rememberStyleState
+import org.maplibre.compose.util.ClickResult
+import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.feature.map.mapOverlaysSupported
+import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
+import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
+import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA
+import org.meshtastic.feature.map.util.nodesToFeatureCollection
+import org.meshtastic.feature.map.util.waypointsToFeatureCollection
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+private val OnlineStrokeColor = Color(0xFF4CAF50) // Green — node heard within online threshold
+private val OfflineStrokeColor = Color(0xFF9E9E9E) // Gray — node not heard recently
+private const val CLUSTER_RADIUS = 50
+private const val CLUSTER_MIN_POINTS = 10
+private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f
+
+/**
+ * Ground resolution at the equator: meters per pixel = 156543.03 / 2^zoom. We use an exponential(2) interpolation with
+ * two stops to compute the conversion factor from meters to pixels at each zoom level. The result is multiplied by the
+ * per-feature `precision_meters` property to produce a screen-pixel radius.
+ */
+private const val EQUATORIAL_METERS_PER_PIXEL_ZOOM0 = 156543.03f
+private const val PRECISION_ZOOM_MIN = 0
+private const val PRECISION_ZOOM_MAX = 24
+private const val PRECISION_SCALE_MIN = 1f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0
+
+@Suppress("MagicNumber")
+private const val PRECISION_SCALE_MAX = 16_777_216f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 // 2^24
+private const val CLUSTER_OPACITY = 0.85f
+private const val LABEL_OFFSET_EM = 1.5f
+private const val CLUSTER_ZOOM_INCREMENT = 2.0
+private const val HILLSHADE_EXAGGERATION = 0.5f
+private const val PULSE_DURATION_MS = 1500
+private const val PULSE_MAX_RADIUS_DP = 14f
+private const val PULSE_START_OPACITY = 0.5f
+private const val PULSE_WINDOW_SECONDS = 5L
+private const val PULSE_TICK_INTERVAL_MS = 1000L
+
+/** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */
+private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png")
+
+/**
+ * Main map content composable using MapLibre Compose Multiplatform.
+ *
+ * Renders nodes as clustered markers, waypoints, and optional overlays (position tracks, traceroute routes). Replaces
+ * both the Google Maps and OSMDroid implementations with a single cross-platform composable.
+ */
+@Composable
+fun MaplibreMapContent(
+ nodes: List,
+ waypoints: Map,
+ baseStyle: BaseStyle,
+ cameraState: CameraState,
+ myNodeNum: Int?,
+ showWaypoints: Boolean,
+ showPrecisionCircle: Boolean,
+ showHillshade: Boolean,
+ onNodeClick: (Int) -> Unit,
+ onMapLongClick: (GeoPosition) -> Unit,
+ modifier: Modifier = Modifier,
+ gestureOptions: GestureOptions = GestureOptions.Standard,
+ styleState: StyleState = rememberStyleState(),
+ onCameraMove: (CameraPosition) -> Unit = {},
+ onWaypointClick: (Int) -> Unit = {},
+ onMapLoad: () -> Unit = {},
+ onMapLoadFail: (String?) -> Unit = {},
+ locationState: UserLocationState? = null,
+) {
+ MaplibreMap(
+ modifier = modifier,
+ baseStyle = baseStyle,
+ cameraState = cameraState,
+ styleState = styleState,
+ options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.OnlyLogo),
+ onMapLongClick = { position, _ ->
+ onMapLongClick(position)
+ ClickResult.Consume
+ },
+ onMapLoadFinished = onMapLoad,
+ onMapLoadFailed = onMapLoadFail,
+ ) {
+ // MapLibre Compose layers/sources are stubbed on desktop (maplibre-compose 0.13.0); gate overlays off
+ // there so the base map still renders without throwing NotImplementedError. See [mapOverlaysSupported].
+ if (!mapOverlaysSupported) return@MaplibreMap
+
+ // --- Terrain hillshade overlay ---
+ if (showHillshade) {
+ val demSource = rememberRasterDemSource(tiles = TERRAIN_TILES, encoding = RasterDemEncoding.Terrarium)
+ HillshadeLayer(id = "terrain-hillshade", source = demSource, exaggeration = const(HILLSHADE_EXAGGERATION))
+ }
+
+ // --- Node markers with clustering ---
+ NodeMarkerLayers(
+ nodes = nodes,
+ myNodeNum = myNodeNum,
+ showPrecisionCircle = showPrecisionCircle,
+ cameraState = cameraState,
+ onNodeClick = onNodeClick,
+ )
+
+ // --- Waypoint markers ---
+ if (showWaypoints) {
+ WaypointMarkerLayers(waypoints = waypoints, onWaypointClick = onWaypointClick)
+ }
+
+ // --- User location puck ---
+ if (locationState != null) {
+ LocationPuck(
+ idPrefix = "user-location",
+ location = locationState.location,
+ cameraState = cameraState,
+ colors = LocationPuckDefaults.colors(),
+ )
+ }
+ }
+
+ // Persist camera position when it stops moving
+ val currentOnCameraMove = rememberUpdatedState(onCameraMove)
+ LaunchedEffect(cameraState.isCameraMoving) {
+ if (!cameraState.isCameraMoving) {
+ currentOnCameraMove.value(cameraState.position)
+ }
+ }
+}
+
+/** Node markers rendered as clustered circles with per-node colors and short name labels. */
+@Suppress("LongMethod")
+@Composable
+private fun NodeMarkerLayers(
+ nodes: List,
+ myNodeNum: Int?,
+ showPrecisionCircle: Boolean,
+ cameraState: CameraState,
+ onNodeClick: (Int) -> Unit,
+) {
+ val coroutineScope = rememberCoroutineScope()
+
+ // Tick current time to expire pulse animations after PULSE_WINDOW_SECONDS
+ var nowEpochSeconds by remember { mutableStateOf(Clock.System.now().epochSeconds) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(PULSE_TICK_INTERVAL_MS)
+ nowEpochSeconds = Clock.System.now().epochSeconds
+ }
+ }
+
+ val featureCollection =
+ remember(nodes, myNodeNum, nowEpochSeconds) { nodesToFeatureCollection(nodes, myNodeNum, nowEpochSeconds) }
+
+ // Read M3 semantic colors for map layers (recomposes on theme change)
+ val clusterColor = MaterialTheme.colorScheme.primary
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val clusterLabelColor = MaterialTheme.colorScheme.onPrimary
+
+ // Pulsing ring animation for recently-heard nodes
+ val pulseTransition = rememberInfiniteTransition(label = "node-pulse")
+ val pulseProgress by
+ pulseTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(PULSE_DURATION_MS, easing = LinearEasing), RepeatMode.Restart),
+ label = "pulse-progress",
+ )
+
+ val nodesSource =
+ rememberGeoJsonSource(
+ data = GeoJsonData.Features(featureCollection),
+ options =
+ GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS),
+ )
+
+ // Cluster circles — tap to zoom in toward expansion
+ CircleLayer(
+ id = "node-clusters",
+ source = nodesSource,
+ filter = feature.has("cluster"),
+ radius = const(20.dp),
+ color = const(clusterColor),
+ opacity = const(CLUSTER_OPACITY),
+ strokeWidth = const(MARKER_STROKE_WIDTH),
+ strokeColor = const(clusterLabelColor),
+ onClick = { features ->
+ val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass
+ val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass
+ coroutineScope.launch {
+ cameraState.animateTo(
+ cameraState.position.copy(
+ target = target,
+ zoom = minOf(cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT, PRECISION_ZOOM_MAX.toDouble()),
+ ),
+ )
+ }
+ ClickResult.Consume
+ },
+ )
+
+ // Cluster count labels
+ SymbolLayer(
+ id = "node-cluster-count",
+ source = nodesSource,
+ filter = feature.has("cluster"),
+ textField = feature["point_count"].asString(),
+ textColor = const(clusterLabelColor),
+ textSize = const(1.2f.em),
+ )
+
+ // Pulsing ring behind recently-heard nodes — indicates new packet received
+ val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp
+ val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress)
+ CircleLayer(
+ id = "node-pulse-ring",
+ source = nodesSource,
+ filter = feature["recently_heard"].convertToBoolean(),
+ radius = const(pulseRadius),
+ color = const(OnlineStrokeColor),
+ opacity = const(pulseOpacity),
+ )
+
+ // Individual node markers with per-node background color and online-status stroke
+ CircleLayer(
+ id = "node-markers",
+ source = nodesSource,
+ filter = !feature.has("cluster"),
+ radius = const(NODE_MARKER_RADIUS),
+ color = feature["background_color"].convertToColor(const(clusterColor)),
+ strokeWidth = const(MARKER_STROKE_WIDTH),
+ strokeColor =
+ switch(
+ condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)),
+ fallback = const(OfflineStrokeColor),
+ ),
+ onClick = { features ->
+ val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull()
+ if (nodeNum != null) {
+ onNodeClick(nodeNum)
+ ClickResult.Consume
+ } else {
+ ClickResult.Pass
+ }
+ },
+ )
+
+ // Short name labels with online status dot below node markers
+ SymbolLayer(
+ id = "node-labels",
+ source = nodesSource,
+ filter = !feature.has("cluster"),
+ textField =
+ format(
+ span(feature["short_name"].asString()),
+ span(
+ const(" \u25CF"), // U+25CF Black Circle
+ textColor =
+ switch(
+ condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)),
+ fallback = const(OfflineStrokeColor),
+ ),
+ ),
+ ),
+ textSize = const(0.9f.em),
+ textOffset = offset(0f.em, LABEL_OFFSET_EM.em),
+ textColor = const(labelColor),
+ textAllowOverlap = const(true),
+ iconAllowOverlap = const(true),
+ )
+
+ // Precision circles — sized by precision_meters property converted to screen pixels via zoom interpolation
+ if (showPrecisionCircle) {
+ // Meters-to-pixels factor doubles with each zoom level (equatorial approximation)
+ val metersToPixels =
+ interpolate(
+ exponential(2f),
+ zoom(),
+ PRECISION_ZOOM_MIN to const(PRECISION_SCALE_MIN),
+ PRECISION_ZOOM_MAX to const(PRECISION_SCALE_MAX),
+ )
+ CircleLayer(
+ id = "node-precision",
+ source = nodesSource,
+ filter = !feature.has("cluster"),
+ radius = (feature["precision_meters"].convertToNumber(const(0f)) * metersToPixels).dp,
+ color =
+ feature["background_color"].convertToColor(
+ const(clusterColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
+ ),
+ opacity = const(PRECISION_CIRCLE_FILL_ALPHA),
+ strokeWidth = const(1.dp),
+ strokeColor =
+ feature["background_color"].convertToColor(
+ const(clusterColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
+ ),
+ strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA),
+ )
+ }
+}
+
+/** Waypoint markers rendered as symbol layer with emoji icons and click handling. */
+@Composable
+private fun WaypointMarkerLayers(waypoints: Map, onWaypointClick: (Int) -> Unit) {
+ val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) }
+
+ val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection))
+
+ // Waypoint emoji labels
+ SymbolLayer(
+ id = "waypoint-markers",
+ source = waypointSource,
+ textField = feature["emoji"].asString(),
+ textSize = const(2f.em),
+ textAllowOverlap = const(true),
+ iconAllowOverlap = const(true),
+ onClick = { features ->
+ val waypointId = features.firstOrNull()?.properties?.get("waypoint_id")?.toString()?.toIntOrNull()
+ if (waypointId != null) {
+ onWaypointClick(waypointId)
+ ClickResult.Consume
+ } else {
+ ClickResult.Pass
+ }
+ },
+ )
+
+ // Waypoint name labels below emoji
+ SymbolLayer(
+ id = "waypoint-labels",
+ source = waypointSource,
+ textField = feature["name"].asString(),
+ textSize = const(1.em),
+ textOffset = offset(0f.em, 2f.em),
+ textColor = const(Color.DarkGray),
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt
new file mode 100644
index 0000000000..d8d6ba4535
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.view_details
+import org.meshtastic.core.ui.component.LastHeardInfo
+import org.meshtastic.core.ui.component.MaterialBatteryInfo
+import org.meshtastic.core.ui.component.SignalInfo
+
+/**
+ * A modal bottom sheet showing a compact summary of a node when tapped on the map. Provides quick info (name, last
+ * heard, battery, signal) and a button to navigate to full details.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun NodeInfoSheet(node: Node, onDismiss: () -> Unit, onViewDetails: (Int) -> Unit) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) {
+ // Node name
+ Text(
+ text = node.user.long_name.ifBlank { node.user.short_name },
+ style = MaterialTheme.typography.titleLarge,
+ )
+ if (node.user.long_name.isNotBlank() && node.user.short_name.isNotBlank()) {
+ Text(
+ text = node.user.short_name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Info row: last heard, battery, signal
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LastHeardInfo(lastHeard = node.lastHeard, showLabel = false)
+ MaterialBatteryInfo(level = node.batteryLevel)
+ SignalInfo(node = node)
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ // View details button
+ Button(onClick = { onViewDetails(node.num) }, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(Res.string.view_details))
+ }
+
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt
new file mode 100644
index 0000000000..efb2804b67
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlinx.serialization.json.jsonPrimitive
+import org.maplibre.compose.expressions.dsl.asString
+import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.eq
+import org.maplibre.compose.expressions.dsl.feature
+import org.maplibre.compose.expressions.dsl.interpolate
+import org.maplibre.compose.expressions.dsl.linear
+import org.maplibre.compose.expressions.value.LineCap
+import org.maplibre.compose.expressions.value.LineJoin
+import org.maplibre.compose.layers.CircleLayer
+import org.maplibre.compose.layers.LineLayer
+import org.maplibre.compose.sources.GeoJsonData
+import org.maplibre.compose.sources.GeoJsonOptions
+import org.maplibre.compose.sources.rememberGeoJsonSource
+import org.maplibre.compose.util.ClickResult
+import org.meshtastic.feature.map.util.lineProgress
+import org.meshtastic.feature.map.util.positionsToLineString
+import org.meshtastic.feature.map.util.positionsToPointFeatures
+
+private val TrackColor = Color(0xFF2196F3)
+private val TrackColorFaded = Color(0x662196F3)
+private val SelectedPointColor = Color(0xFFF44336)
+private const val SELECTED_OPACITY = 0.9f
+
+/**
+ * Renders a position history track as a line with marker points. Replaces the Google Maps Polyline + MarkerComposable
+ * and OSMDroid Polyline overlay implementations.
+ */
+@Composable
+internal fun NodeTrackLayers(
+ positions: List,
+ selectedPositionTime: Int? = null,
+ onSelectPosition: ((Int) -> Unit)? = null,
+) {
+ if (positions.size < 2) return
+
+ // Line track source
+ val lineFeatureCollection = remember(positions) { positionsToLineString(positions) }
+
+ val lineSource =
+ rememberGeoJsonSource(
+ data = GeoJsonData.Features(lineFeatureCollection),
+ options = GeoJsonOptions(lineMetrics = true),
+ )
+
+ // Track line with gradient (oldest positions faded → newest positions vivid)
+ LineLayer(
+ id = "node-track-line",
+ source = lineSource,
+ width = const(3.dp),
+ gradient = interpolate(linear(), lineProgress(), 0 to const(TrackColorFaded), 1 to const(TrackColor)),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+
+ // Position marker points
+ val pointFeatureCollection = remember(positions) { positionsToPointFeatures(positions) }
+
+ val pointsSource = rememberGeoJsonSource(data = GeoJsonData.Features(pointFeatureCollection))
+
+ CircleLayer(
+ id = "node-track-points",
+ source = pointsSource,
+ radius = const(5.dp),
+ color = const(TrackColor),
+ strokeWidth = const(1.dp),
+ strokeColor = const(Color.White),
+ onClick = { features ->
+ val time = features.firstOrNull()?.properties?.get("time")?.jsonPrimitive?.content?.toIntOrNull()
+ if (time != null && onSelectPosition != null) {
+ onSelectPosition(time)
+ ClickResult.Consume
+ } else {
+ ClickResult.Pass
+ }
+ },
+ )
+
+ // Highlight selected position
+ if (selectedPositionTime != null) {
+ CircleLayer(
+ id = "node-track-selected",
+ source = pointsSource,
+ filter = feature["time"].asString() eq const(selectedPositionTime.toString()),
+ radius = const(10.dp),
+ color = const(SelectedPointColor), // Red
+ strokeWidth = const(2.dp),
+ strokeColor = const(Color.White),
+ opacity = const(SELECTED_OPACITY),
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt
new file mode 100644
index 0000000000..0d2a42f237
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.camera.rememberCameraState
+import org.maplibre.compose.map.GestureOptions
+import org.maplibre.compose.map.MapOptions
+import org.maplibre.compose.map.MaplibreMap
+import org.maplibre.compose.map.OrnamentOptions
+import org.meshtastic.feature.map.mapOverlaysSupported
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.computeBoundingBox
+import org.meshtastic.feature.map.util.toGeoPositionOrNull
+import org.meshtastic.proto.Position
+
+private const val DEFAULT_TRACK_ZOOM = 13.0
+private const val BOUNDS_PADDING_DP = 48
+
+/**
+ * Embeddable position-track map showing a polyline with markers for the given positions.
+ *
+ * Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onSelectPosition] is
+ * called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list.
+ *
+ * Replaces both the Google Maps and OSMDroid flavor-specific NodeTrackMap implementations.
+ */
+@Composable
+fun NodeTrackMap(
+ positions: List,
+ modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onSelectPosition: ((Int) -> Unit)? = null,
+) {
+ val geoPositions =
+ remember(positions) { positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } }
+
+ val center = remember(geoPositions) { geoPositions.firstOrNull() }
+
+ val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) }
+
+ val cameraState =
+ rememberCameraState(
+ firstPosition =
+ CameraPosition(
+ target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0),
+ zoom = DEFAULT_TRACK_ZOOM,
+ ),
+ )
+
+ // Fit camera to bounds when the track has multiple positions.
+ LaunchedEffect(boundingBox) {
+ boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
+ }
+
+ MaplibreMap(
+ modifier = modifier,
+ baseStyle = MapStyle.OpenStreetMap.toBaseStyle(),
+ cameraState = cameraState,
+ options =
+ MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled),
+ ) {
+ // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported].
+ if (!mapOverlaysSupported) return@MaplibreMap
+
+ NodeTrackLayers(
+ positions = positions,
+ selectedPositionTime = selectedPositionTime,
+ onSelectPosition = onSelectPosition,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt
new file mode 100644
index 0000000000..e1662fb265
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import org.jetbrains.compose.resources.stringResource
+import org.maplibre.compose.expressions.dsl.asString
+import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.feature
+import org.maplibre.compose.expressions.dsl.offset
+import org.maplibre.compose.expressions.value.LineCap
+import org.maplibre.compose.expressions.value.LineJoin
+import org.maplibre.compose.layers.CircleLayer
+import org.maplibre.compose.layers.LineLayer
+import org.maplibre.compose.layers.SymbolLayer
+import org.maplibre.compose.sources.GeoJsonData
+import org.maplibre.compose.sources.rememberGeoJsonSource
+import org.maplibre.spatialk.geojson.Feature
+import org.maplibre.spatialk.geojson.FeatureCollection
+import org.maplibre.spatialk.geojson.LineString
+import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.unknown
+import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
+import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
+import org.meshtastic.feature.map.util.toGeoPositionOrNull
+import org.meshtastic.feature.map.util.typedFeatureCollection
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+private val ForwardRouteColor = Color(0xFF4CAF50) // Success green — forward path
+private val ReturnRouteColor = Color(0xFFF44336) // Error red — return path
+private val HopMarkerColor = Color(0xFF9C27B0) // Tertiary purple — hop points
+private const val HEX_RADIX = 16
+private const val ROUTE_OPACITY = 0.8f
+
+/**
+ * Renders traceroute forward and return routes with hop markers. Replaces the Google Maps and OSMDroid traceroute
+ * polyline implementations.
+ */
+@Composable
+internal fun TracerouteLayers(
+ overlay: TracerouteOverlay?,
+ nodePositions: Map,
+ nodes: Map,
+ onMappableCountChange: (shown: Int, total: Int) -> Unit,
+) {
+ if (overlay == null) return
+
+ val unknownNodeName = stringResource(Res.string.unknown)
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val markerStrokeColor = MaterialTheme.colorScheme.surface
+
+ // Build route line features
+ val routeData =
+ remember(overlay, nodePositions, nodes, unknownNodeName) {
+ buildTracerouteGeoJson(overlay, nodePositions, nodes, unknownNodeName)
+ }
+
+ // Report mappable count via side effect (avoid state updates during composition)
+ val mappableCount = routeData.hopFeatures.features.size
+ val totalCount = overlay.relatedNodeNums.size
+ val currentOnMappableCountChange = rememberUpdatedState(onMappableCountChange)
+ LaunchedEffect(mappableCount, totalCount) { currentOnMappableCountChange.value(mappableCount, totalCount) }
+
+ // Forward route line
+ if (routeData.forwardLine.features.isNotEmpty()) {
+ val forwardSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.forwardLine))
+ LineLayer(
+ id = "traceroute-forward",
+ source = forwardSource,
+ width = const(3.dp),
+ color = const(ForwardRouteColor), // Green
+ opacity = const(ROUTE_OPACITY),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+ }
+
+ // Return route line (dashed)
+ if (routeData.returnLine.features.isNotEmpty()) {
+ val returnSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.returnLine))
+ LineLayer(
+ id = "traceroute-return",
+ source = returnSource,
+ width = const(3.dp),
+ color = const(ReturnRouteColor), // Red
+ opacity = const(ROUTE_OPACITY),
+ dasharray = const(listOf(2f, 1f)),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+ }
+
+ // Hop markers
+ if (routeData.hopFeatures.features.isNotEmpty()) {
+ val hopsSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.hopFeatures))
+ CircleLayer(
+ id = "traceroute-hops",
+ source = hopsSource,
+ radius = const(NODE_MARKER_RADIUS),
+ color = const(HopMarkerColor), // Purple
+ strokeWidth = const(MARKER_STROKE_WIDTH),
+ strokeColor = const(markerStrokeColor),
+ )
+ SymbolLayer(
+ id = "traceroute-hop-labels",
+ source = hopsSource,
+ textField = feature["short_name"].asString(),
+ textSize = const(1.em),
+ textOffset = offset(0f.em, -2f.em),
+ textColor = const(labelColor),
+ )
+ }
+}
+
+private data class TracerouteGeoJsonData(
+ val forwardLine: FeatureCollection,
+ val returnLine: FeatureCollection,
+ val hopFeatures: FeatureCollection,
+)
+
+private fun buildTracerouteGeoJson(
+ overlay: TracerouteOverlay,
+ nodePositions: Map,
+ nodes: Map,
+ unknownNodeName: String,
+): TracerouteGeoJsonData {
+ fun nodeToGeoPosition(nodeNum: Int): GeoPosition? {
+ val pos = nodePositions[nodeNum] ?: return null
+ return toGeoPositionOrNull(pos.latitude_i, pos.longitude_i)
+ }
+
+ // Build forward route line
+ val forwardCoords = overlay.forwardRoute.mapNotNull { nodeToGeoPosition(it) }
+ val forwardLine =
+ if (forwardCoords.size >= 2) {
+ val feature =
+ Feature(
+ geometry = LineString(forwardCoords),
+ properties = buildJsonObject { put("direction", "forward") },
+ )
+ typedFeatureCollection(listOf(feature))
+ } else {
+ typedFeatureCollection(emptyList>())
+ }
+
+ // Build return route line
+ val returnCoords = overlay.returnRoute.mapNotNull { nodeToGeoPosition(it) }
+ val returnLine =
+ if (returnCoords.size >= 2) {
+ val feature =
+ Feature(
+ geometry = LineString(returnCoords),
+ properties = buildJsonObject { put("direction", "return") },
+ )
+ typedFeatureCollection(listOf(feature))
+ } else {
+ typedFeatureCollection(emptyList>())
+ }
+
+ // Build hop marker points
+ val allNodeNums = overlay.relatedNodeNums
+
+ val hopFeatures =
+ allNodeNums.mapNotNull { nodeNum ->
+ val geoPos = nodeToGeoPosition(nodeNum) ?: return@mapNotNull null
+ val node = nodes[nodeNum]
+ Feature(
+ geometry = Point(geoPos),
+ properties =
+ buildJsonObject {
+ put("node_num", nodeNum)
+ put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX))
+ put("long_name", node?.user?.long_name ?: unknownNodeName)
+ },
+ )
+ }
+
+ return TracerouteGeoJsonData(
+ forwardLine = forwardLine,
+ returnLine = returnLine,
+ hopFeatures = typedFeatureCollection(hopFeatures),
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt
new file mode 100644
index 0000000000..064bc33293
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.feature.map.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.maplibre.compose.camera.CameraPosition
+import org.maplibre.compose.camera.rememberCameraState
+import org.maplibre.compose.map.GestureOptions
+import org.maplibre.compose.map.MapOptions
+import org.maplibre.compose.map.MaplibreMap
+import org.maplibre.compose.map.OrnamentOptions
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.feature.map.mapOverlaysSupported
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.computeBoundingBox
+import org.meshtastic.feature.map.util.toGeoPositionOrNull
+import org.meshtastic.proto.Position
+
+private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
+private const val BOUNDS_PADDING_DP = 64
+
+/**
+ * Embeddable traceroute map showing forward/return route polylines with hop markers.
+ *
+ * This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include
+ * its own Scaffold or AppBar.
+ *
+ * @param nodes Node lookup map for resolving short names on hop markers. When empty, hop markers fall back to hex node
+ * numbers. Callers should pass `nodeRepository.nodeDBbyNum.value` (or equivalent) for readable labels.
+ *
+ * Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations.
+ */
+@Composable
+fun TracerouteMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map,
+ onMappableCountChange: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ nodes: Map = emptyMap(),
+) {
+ val geoPositions =
+ remember(tracerouteNodePositions) {
+ tracerouteNodePositions.values.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) }
+ }
+
+ val center = remember(geoPositions) { geoPositions.firstOrNull() }
+
+ val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) }
+
+ val cameraState =
+ rememberCameraState(
+ firstPosition =
+ CameraPosition(
+ target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0),
+ zoom = DEFAULT_TRACEROUTE_ZOOM,
+ ),
+ )
+
+ // Fit camera to bounds when the traceroute has multiple node positions.
+ LaunchedEffect(boundingBox) {
+ boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
+ }
+
+ MaplibreMap(
+ modifier = modifier,
+ baseStyle = MapStyle.OpenStreetMap.toBaseStyle(),
+ cameraState = cameraState,
+ options =
+ MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled),
+ ) {
+ // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported].
+ if (!mapOverlaysSupported) return@MaplibreMap
+
+ TracerouteLayers(
+ overlay = tracerouteOverlay,
+ nodePositions = tracerouteNodePositions,
+ nodes = nodes,
+ onMappableCountChange = onMappableCountChange,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt
index a6ff74b174..d8fdd4e789 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt
@@ -19,6 +19,7 @@ package org.meshtastic.feature.map.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
+/** Koin module for the map feature. Scans [org.meshtastic.feature.map] for annotated dependencies. */
@Module
@ComponentScan("org.meshtastic.feature.map")
class FeatureMapModule
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt
index 82572ef8df..194aff6290 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt
@@ -18,12 +18,14 @@ package org.meshtastic.feature.map.model
import kotlin.uuid.Uuid
-enum class LayerType {
+/** Supported custom overlay layer formats. */
+internal enum class LayerType {
KML,
GEOJSON,
}
-data class MapLayerItem(
+/** A user-importable map overlay layer (KML or GeoJSON file). */
+internal data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uriString: String? = null,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt
new file mode 100644
index 0000000000..3cf5a2adde
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.feature.map.model
+
+import org.jetbrains.compose.resources.StringResource
+import org.maplibre.compose.style.BaseStyle
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.map_style_dark
+import org.meshtastic.core.resources.map_style_light
+import org.meshtastic.core.resources.map_style_osm
+import org.meshtastic.core.resources.map_style_road_map
+import org.meshtastic.core.resources.map_style_satellite
+import org.meshtastic.core.resources.map_style_terrain
+
+/**
+ * Predefined map tile styles available in the app.
+ *
+ * Uses free tile sources that do not require API keys.
+ */
+enum class MapStyle(val label: StringResource, val styleUri: String) {
+ /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */
+ OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"),
+
+ /** Clean, light cartographic style via OpenFreeMap Positron. */
+ Light(label = Res.string.map_style_light, styleUri = "https://tiles.openfreemap.org/styles/positron"),
+
+ /** Topographic style via OpenFreeMap Bright. */
+ Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/bright"),
+
+ /** US road-map style via Americana. */
+ RoadMap(label = Res.string.map_style_road_map, styleUri = "https://americanamap.org/style.json"),
+
+ /** Dark mode style via OpenFreeMap Fiord. */
+ Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"),
+
+ /** Satellite imagery via Esri World Imagery (free for non-commercial use). */
+ Satellite(label = Res.string.map_style_satellite, styleUri = SATELLITE_STYLE_URI),
+ ;
+
+ fun toBaseStyle(): BaseStyle = when (this) {
+ Satellite -> BaseStyle.Json(SATELLITE_STYLE_JSON)
+ else -> BaseStyle.Uri(styleUri)
+ }
+}
+
+/** Stable URI used as persistence key for satellite style selection. */
+private const val SATELLITE_STYLE_URI = "satellite://esri-world-imagery"
+
+/**
+ * Inline MapLibre style JSON for raster satellite imagery.
+ *
+ * Uses Esri World Imagery tiles which are free for non-commercial and educational use.
+ */
+@Suppress("MaxLineLength")
+private const val SATELLITE_STYLE_JSON: String =
+ """{"version":8,"name":"Satellite","sources":{"esri-satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":18,"attribution":"Esri, Maxar, Earthstar Geographics"}},"layers":[{"id":"satellite","type":"raster","source":"esri-satellite"}]}"""
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index 00df4cac3b..da6d246267 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -19,16 +19,21 @@ package org.meshtastic.feature.map.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
+import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
+import org.meshtastic.feature.map.MapScreen
+import org.meshtastic.feature.map.MapViewModel
+/** Registers the map feature's navigation entries into a Navigation 3 [EntryProviderScope]. */
fun EntryProviderScope.mapGraph(backStack: NavBackStack) {
entry { args ->
- val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current
- mapScreen(
- { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip
- { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails
- args.waypointId,
+ val viewModel = koinViewModel()
+ MapScreen(
+ viewModel = viewModel,
+ onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
+ navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) },
+ waypointId = args.waypointId,
)
}
}
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
similarity index 60%
rename from androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
index 949655f292..6bae9a23c3 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.map.node
+package org.meshtastic.feature.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -23,18 +23,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.feature.map.component.NodeTrackMap
+/**
+ * Full-screen map showing a single node's position history.
+ *
+ * Includes a Scaffold with AppBar showing the node's long name. Replaces both the Google Maps and OSMDroid
+ * flavor-specific NodeMapScreen implementations.
+ */
@Composable
-fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
- val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
+fun NodeMapScreen(viewModel: NodeMapViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
+ val node by viewModel.node.collectAsStateWithLifecycle()
+ val positions by viewModel.positionLogs.collectAsStateWithLifecycle()
Scaffold(
+ modifier = modifier,
topBar = {
MainAppBar(
- title = node?.user?.long_name ?: "",
+ title = node?.user?.long_name ?: stringResource(Res.string.map),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@@ -44,11 +54,6 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
- NodeTrackOsmMap(
- positions = positions,
- applicationId = nodeMapViewModel.applicationId,
- mapStyleId = nodeMapViewModel.mapStyleId,
- modifier = Modifier.fillMaxSize().padding(paddingValues),
- )
+ NodeTrackMap(positions = positions, modifier = Modifier.fillMaxSize().padding(paddingValues))
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
index 9390913099..c620800cc9 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
@@ -20,17 +20,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.toList
import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.MeshLog
-import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
@@ -43,18 +38,10 @@ class NodeMapViewModel(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
meshLogRepository: MeshLogRepository,
- buildConfigProvider: BuildConfigProvider,
- private val mapPrefs: MapPrefs,
) : ViewModel() {
- private val destNumFromRoute = savedStateHandle.get("destNum")
- private val manualDestNum = MutableStateFlow(null)
+ private val destNum = savedStateHandle.get("destNum") ?: 0
- private val destNumFlow =
- combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 }
-
- fun setDestNum(num: Int) {
- manualDestNum.value = num
- }
+ private val destNumFlow = MutableStateFlow(destNum)
val node =
destNumFlow
@@ -62,29 +49,37 @@ class NodeMapViewModel(
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = null)
- val applicationId = buildConfigProvider.applicationId
-
private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
val positionLogs: StateFlow> =
- combine(ourNodeNumFlow, destNumFlow) { ourNodeNum, destNum ->
- if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum
- }
+ ourNodeNumFlow
+ .map { ourNodeNum -> if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum }
.distinctUntilChanged()
.flatMapLatest { logId ->
meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets ->
packets
.mapNotNull { it.toPosition() }
- .asFlow()
- .distinctUntilChanged { old, new ->
+ .filterConsecutiveDuplicates { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
- .toList()
}
}
.stateInWhileSubscribed(initialValue = emptyList())
+}
- val mapStyleId: Int
- get() = mapPrefs.mapStyle.value
+/**
+ * Filters consecutive duplicate elements from a list, similar to [Sequence.distinctUntilChanged]. An element is
+ * considered a duplicate if [predicate] returns `true` for it and the previous element.
+ */
+private fun List.filterConsecutiveDuplicates(predicate: (old: T, new: T) -> Boolean): List {
+ if (size <= 1) return this
+ return buildList {
+ add(this@filterConsecutiveDuplicates.first())
+ for (i in 1 until this@filterConsecutiveDuplicates.size) {
+ if (!predicate(this@filterConsecutiveDuplicates[i - 1], this@filterConsecutiveDuplicates[i])) {
+ add(this@filterConsecutiveDuplicates[i])
+ }
+ }
+ }
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
new file mode 100644
index 0000000000..d0d780e4b7
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
@@ -0,0 +1,192 @@
+/*
+ * 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.feature.map.util
+
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import org.maplibre.spatialk.geojson.Feature
+import org.maplibre.spatialk.geojson.FeatureCollection
+import org.maplibre.spatialk.geojson.Geometry
+import org.maplibre.spatialk.geojson.LineString
+import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+
+private const val MIN_PRECISION_BITS = 10
+private const val MAX_PRECISION_BITS = 19
+private const val RECENTLY_HEARD_WINDOW_SECONDS = 5
+
+/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */
+internal fun nodesToFeatureCollection(
+ nodes: List,
+ myNodeNum: Int? = null,
+ nowEpochSeconds: Long = 0L,
+): FeatureCollection {
+ val features =
+ nodes.mapNotNull { node ->
+ val pos = node.validPosition ?: return@mapNotNull null
+ val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
+
+ val colors = node.colors
+ val recentlyHeard =
+ nowEpochSeconds > 0L &&
+ node.lastHeard > 0 &&
+ (nowEpochSeconds - node.lastHeard) <= RECENTLY_HEARD_WINDOW_SECONDS
+ val props = buildJsonObject {
+ put("node_num", node.num)
+ put("short_name", node.user.short_name)
+ put("long_name", node.user.long_name)
+ put("last_heard", node.lastHeard)
+ put("is_favorite", node.isFavorite)
+ put("is_my_node", node.num == myNodeNum)
+ put("is_online", node.isOnline)
+ put("recently_heard", recentlyHeard)
+ put("battery_level", node.batteryLevel ?: -1)
+ put("hops_away", node.hopsAway)
+ put("via_mqtt", node.viaMqtt)
+ put("snr", node.snr.toDouble())
+ put("rssi", node.rssi)
+ put("foreground_color", intToHexColor(colors.first))
+ put("background_color", intToHexColor(colors.second))
+ put("has_precision", pos.precision_bits in MIN_PRECISION_BITS..MAX_PRECISION_BITS)
+ put("precision_meters", precisionBitsToMeters(pos.precision_bits))
+ }
+
+ Feature(geometry = Point(geoPos), properties = props)
+ }
+
+ return typedFeatureCollection(features)
+}
+
+/** Convert waypoints to a GeoJSON [FeatureCollection]. */
+internal fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection {
+ val features =
+ waypoints.values.mapNotNull { packet ->
+ val waypoint = packet.waypoint ?: return@mapNotNull null
+ val geoPos = toGeoPositionOrNull(waypoint.latitude_i, waypoint.longitude_i) ?: return@mapNotNull null
+
+ val emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI
+
+ val props = buildJsonObject {
+ put("waypoint_id", waypoint.id)
+ put("name", waypoint.name)
+ put("description", waypoint.description)
+ put("emoji", emoji)
+ put("icon", waypoint.icon)
+ put("locked_to", waypoint.locked_to)
+ put("expire", waypoint.expire)
+ }
+
+ Feature(geometry = Point(geoPos), properties = props)
+ }
+
+ return typedFeatureCollection(features)
+}
+
+/** Convert position history to a GeoJSON [LineString] for track rendering. */
+internal fun positionsToLineString(
+ positions: List,
+): FeatureCollection {
+ val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) }
+
+ if (coords.size < 2) return FeatureCollection(emptyList())
+
+ val props = buildJsonObject { put("point_count", coords.size) }
+
+ val feature = Feature(geometry = LineString(coords), properties = props)
+
+ return typedFeatureCollection(listOf(feature))
+}
+
+/** Convert position history to individual point features with time metadata. */
+internal fun positionsToPointFeatures(
+ positions: List,
+): FeatureCollection {
+ val features =
+ positions.mapNotNull { pos ->
+ val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
+
+ val props = buildJsonObject {
+ put("time", pos.time.toString())
+ put("altitude", pos.altitude ?: 0)
+ put("ground_speed", pos.ground_speed ?: 0)
+ put("sats_in_view", pos.sats_in_view)
+ }
+
+ Feature(geometry = Point(geoPos), properties = props)
+ }
+
+ return typedFeatureCollection(features)
+}
+
+/** Approximate meters of positional uncertainty from precision_bits (10-19). */
+@Suppress("MagicNumber")
+internal fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) {
+ 10 -> 5886.0
+ 11 -> 2944.0
+ 12 -> 1472.0
+ 13 -> 736.0
+ 14 -> 368.0
+ 15 -> 184.0
+ 16 -> 92.0
+ 17 -> 46.0
+ 18 -> 23.0
+ 19 -> 11.5
+ else -> 0.0
+}
+
+private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog
+
+/**
+ * Wraps [FeatureCollection] constructor with the desired type parameters. Centralizes the typed constructor call
+ * required by the spatialk GeoJSON API.
+ */
+internal fun typedFeatureCollection(features: List>): FeatureCollection =
+ FeatureCollection(features)
+
+private const val BMP_MAX = 0xFFFF
+private const val SUPPLEMENTARY_OFFSET = 0x10000
+private const val HALF_SHIFT = 10
+private const val HIGH_SURROGATE_BASE = 0xD800
+private const val LOW_SURROGATE_BASE = 0xDC00
+private const val SURROGATE_MASK = 0x3FF
+private const val HEX_COLOR_MASK = 0xFFFFFF
+
+/** Convert an ARGB color integer to a hex color string (e.g. "#FF6750A4") for MapLibre expressions. */
+@Suppress("MagicNumber")
+internal fun intToHexColor(argb: Int): String {
+ val rgb = argb and HEX_COLOR_MASK
+ return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
+}
+
+/** Convert a Unicode code point integer to its emoji string representation. */
+internal fun convertIntToEmoji(codePoint: Int): String = try {
+ if (codePoint <= BMP_MAX) {
+ codePoint.toChar().toString()
+ } else {
+ val offset = codePoint - SUPPLEMENTARY_OFFSET
+ val high = (offset shr HALF_SHIFT) + HIGH_SURROGATE_BASE
+ val low = (offset and SURROGATE_MASK) + LOW_SURROGATE_BASE
+ buildString {
+ append(high.toChar())
+ append(low.toChar())
+ }
+ }
+} catch (_: Exception) {
+ PIN_EMOJI
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt
new file mode 100644
index 0000000000..839162631c
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.feature.map.util
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.maplibre.compose.expressions.ast.Expression
+import org.maplibre.compose.expressions.ast.FunctionCall
+import org.maplibre.compose.expressions.value.FloatValue
+import org.maplibre.spatialk.geojson.BoundingBox
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
+internal const val COORDINATE_SCALE = 1e-7
+
+/** Standard radius for node and hop marker circles across all map composables. */
+internal val NODE_MARKER_RADIUS: Dp = 8.dp
+
+/** Standard stroke width for marker circle outlines across all map composables. */
+internal val MARKER_STROKE_WIDTH: Dp = 2.dp
+
+/** Opacity for precision circle strokes (shared between main map and inline map). */
+internal const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f
+
+/**
+ * Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and
+ * longitude are zero (indicating no valid position).
+ */
+internal fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? {
+ val lat = (latI ?: 0) * COORDINATE_SCALE
+ val lng = (lngI ?: 0) * COORDINATE_SCALE
+ return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat)
+}
+
+/**
+ * Compute a [BoundingBox] that encloses all [positions], or `null` if fewer than 2 positions are provided. Used by
+ * [NodeTrackMap][org.meshtastic.feature.map.component.NodeTrackMap] and
+ * [TracerouteMap][org.meshtastic.feature.map.component.TracerouteMap] to fit the camera to track/route bounds.
+ */
+internal fun computeBoundingBox(positions: List): BoundingBox? {
+ if (positions.size < 2) return null
+ val lats = positions.map { it.latitude }
+ val lngs = positions.map { it.longitude }
+ return BoundingBox(
+ southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
+ northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
+ )
+}
+
+/**
+ * Gets the progress along a line feature, from 0 at the start to 1 at the end. Can only be used with GeoJSON sources
+ * that specify `lineMetrics = true`. Use with [interpolate][org.maplibre.compose.expressions.dsl.interpolate] to create
+ * gradient colors.
+ */
+internal fun lineProgress(): Expression = FunctionCall.of("line-progress").cast()
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt
index 336de2a44d..a6f698181a 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt
@@ -29,28 +29,30 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.testing.FakeMapPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
-import org.meshtastic.proto.Waypoint
+import org.meshtastic.proto.Position
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
+@Suppress("MagicNumber")
class BaseMapViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
- private lateinit var waypointPacketsFlow: MutableStateFlow>
- private val mapPrefs: MapPrefs = mock()
+ private lateinit var mapPrefs: FakeMapPrefs
private val packetRepository: PacketRepository = mock()
@BeforeTest
@@ -59,23 +61,9 @@ class BaseMapViewModelTest {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Disconnected)
-
- every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
- every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(false)
- every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false)
- every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
- every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
-
- waypointPacketsFlow = MutableStateFlow(emptyList())
- every { packetRepository.getWaypoints() } returns waypointPacketsFlow
-
- viewModel =
- BaseMapViewModel(
- mapPrefs = mapPrefs,
- nodeRepository = nodeRepository,
- packetRepository = packetRepository,
- radioController = radioController,
- )
+ mapPrefs = FakeMapPrefs()
+ every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
+ viewModel = createViewModel()
}
@AfterTest
@@ -83,6 +71,29 @@ class BaseMapViewModelTest {
Dispatchers.resetMain()
}
+ private fun createViewModel(): BaseMapViewModel = BaseMapViewModel(
+ mapPrefs = mapPrefs,
+ nodeRepository = nodeRepository,
+ packetRepository = packetRepository,
+ radioController = radioController,
+ ioDispatcher = testDispatcher,
+ )
+
+ private fun nodeWithPosition(
+ num: Int,
+ latI: Int = 400000000,
+ lngI: Int = -740000000,
+ isFavorite: Boolean = false,
+ lastHeard: Int = nowSeconds.toInt(),
+ ): Node = Node(
+ num = num,
+ position = Position(latitude_i = latI, longitude_i = lngI),
+ isFavorite = isFavorite,
+ lastHeard = lastHeard,
+ )
+
+ // ---- Initialization ----
+
@Test
fun testInitialization() {
assertNotNull(viewModel)
@@ -107,12 +118,9 @@ class BaseMapViewModelTest {
@Test
fun testConnectionStateFlow() = runTest(testDispatcher) {
viewModel.isConnected.test {
- // Initially reflects radioController state (which is Disconnected in FakeRadioController default)
assertEquals(false, awaitItem())
-
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(true, awaitItem())
-
radioController.setConnectionState(ConnectionState.Disconnected)
assertEquals(false, awaitItem())
cancelAndIgnoreRemainingEvents()
@@ -123,81 +131,196 @@ class BaseMapViewModelTest {
fun testNodeRepositoryIntegration() = runTest(testDispatcher) {
val testNodes = TestDataFactory.createTestNodes(3)
nodeRepository.setNodes(testNodes)
-
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
+ // ---- Filter toggle tests ----
+
@Test
- fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) {
- val now = nowSeconds.toInt()
- val futureWaypoint = waypointPacket(id = 1, expire = now + 60)
+ fun toggleOnlyFavorites_togglesState() {
+ assertFalse(viewModel.showOnlyFavoritesOnMap.value)
+ viewModel.toggleOnlyFavorites()
+ assertTrue(viewModel.showOnlyFavoritesOnMap.value)
+ viewModel.toggleOnlyFavorites()
+ assertFalse(viewModel.showOnlyFavoritesOnMap.value)
+ }
- viewModel.waypoints.test {
- assertEquals(emptyMap(), awaitItem())
+ @Test
+ fun toggleOnlyFavorites_persistsToPrefs() {
+ viewModel.toggleOnlyFavorites()
+ assertTrue(mapPrefs.showOnlyFavorites.value)
+ }
+
+ @Test
+ fun toggleShowWaypointsOnMap_togglesState() {
+ // FakeMapPrefs defaults to true
+ assertTrue(viewModel.showWaypointsOnMap.value)
+ viewModel.toggleShowWaypointsOnMap()
+ assertFalse(viewModel.showWaypointsOnMap.value)
+ }
+
+ @Test
+ fun toggleShowPrecisionCircleOnMap_togglesState() {
+ assertTrue(viewModel.showPrecisionCircleOnMap.value)
+ viewModel.toggleShowPrecisionCircleOnMap()
+ assertFalse(viewModel.showPrecisionCircleOnMap.value)
+ }
+
+ @Test
+ fun setLastHeardFilter_updatesStateAndPrefs() {
+ viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
+ assertEquals(LastHeardFilter.OneHour, viewModel.lastHeardFilter.value)
+ assertEquals(3600L, mapPrefs.lastHeardFilter.value)
+ }
+
+ @Test
+ fun setLastHeardTrackFilter_updatesStateAndPrefs() {
+ viewModel.setLastHeardTrackFilter(LastHeardFilter.OneDay)
+ assertEquals(LastHeardFilter.OneDay, viewModel.lastHeardTrackFilter.value)
+ assertEquals(86400L, mapPrefs.lastHeardTrackFilter.value)
+ }
- waypointPacketsFlow.value = listOf(futureWaypoint)
+ // ---- MapFilterState composition ----
- assertEquals(mapOf(1 to futureWaypoint), awaitItem())
+ @Test
+ fun mapFilterState_reflectsAllFilterValues() = runTest(testDispatcher) {
+ viewModel.mapFilterStateFlow.test {
+ val initial = awaitItem()
+ assertFalse(initial.onlyFavorites)
+ assertTrue(initial.showWaypoints)
+ assertTrue(initial.showPrecisionCircle)
+ assertEquals(LastHeardFilter.Any, initial.lastHeardFilter)
+
+ viewModel.toggleOnlyFavorites()
+ val updated = awaitItem()
+ assertTrue(updated.onlyFavorites)
cancelAndIgnoreRemainingEvents()
}
}
+ // ---- filteredNodes tests ----
+
@Test
- fun testWaypointsExcludeBoundaryExpirations() = runTest(testDispatcher) {
- val now = nowSeconds.toInt()
- val expiredAtNowWaypoint = waypointPacket(id = 2, expire = now)
+ fun filteredNodes_noFilters_returnsAllNodesWithPosition() = runTest(testDispatcher) {
+ val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
+ nodeRepository.setNodes(nodes)
- viewModel.waypoints.test {
- assertEquals(emptyMap(), awaitItem())
+ viewModel.filteredNodes.test {
+ val result = expectMostRecentItem()
+ assertEquals(3, result.size)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
- waypointPacketsFlow.value = listOf(expiredAtNowWaypoint)
+ @Test
+ fun filteredNodes_favoritesFilter_showsOnlyFavoritesAndMyNode() = runTest(testDispatcher) {
+ val myNodeNum = 1
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
+ val nodes =
+ listOf(
+ nodeWithPosition(myNodeNum),
+ nodeWithPosition(2, isFavorite = true),
+ nodeWithPosition(3, isFavorite = false),
+ )
+ nodeRepository.setNodes(nodes)
+
+ viewModel.toggleOnlyFavorites()
- expectNoEvents()
- assertEquals(emptyMap(), viewModel.waypoints.value)
+ viewModel.filteredNodes.test {
+ val result = expectMostRecentItem()
+ val nodeNums = result.map { it.num }.toSet()
+ // My node (1) + favorite node (2) should be present; non-favorite (3) filtered out
+ assertTrue(myNodeNum in nodeNums, "My node should always be visible")
+ assertTrue(2 in nodeNums, "Favorite node should be visible")
+ assertFalse(3 in nodeNums, "Non-favorite node should be filtered out")
cancelAndIgnoreRemainingEvents()
}
}
@Test
- fun testWaypointsIncludeNeverExpiringWaypoints() = runTest(testDispatcher) {
- val neverExpiresWaypoint = waypointPacket(id = 3, expire = 0)
-
- viewModel.waypoints.test {
- assertEquals(emptyMap(), awaitItem())
+ fun filteredNodes_lastHeardFilter_excludesStaleNodes() = runTest(testDispatcher) {
+ val now = nowSeconds.toInt()
+ val nodes =
+ listOf(
+ nodeWithPosition(1, lastHeard = now), // heard just now
+ nodeWithPosition(2, lastHeard = now - 7200), // heard 2 hours ago
+ )
+ nodeRepository.setNodes(nodes)
- waypointPacketsFlow.value = listOf(neverExpiresWaypoint)
+ viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
- assertEquals(mapOf(3 to neverExpiresWaypoint), awaitItem())
+ viewModel.filteredNodes.test {
+ val result = expectMostRecentItem()
+ val nodeNums = result.map { it.num }.toSet()
+ assertTrue(1 in nodeNums, "Recently heard node should be visible")
+ assertFalse(2 in nodeNums, "Stale node should be filtered out with 1-hour filter")
cancelAndIgnoreRemainingEvents()
}
}
@Test
- fun testWaypointsFilterMixedExpiredAndActiveWaypoints() = runTest(testDispatcher) {
+ fun filteredNodes_anyFilter_showsAllNodes() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
- val expiredWaypoint = waypointPacket(id = 4, expire = now - 1)
- val activeWaypoint = waypointPacket(id = 5, expire = now + 60)
- val neverExpiresWaypoint = waypointPacket(id = 6, expire = 0)
+ val nodes =
+ listOf(
+ nodeWithPosition(1, lastHeard = now),
+ nodeWithPosition(2, lastHeard = now - 200000), // very old
+ )
+ nodeRepository.setNodes(nodes)
- viewModel.waypoints.test {
- assertEquals(emptyMap(), awaitItem())
+ viewModel.setLastHeardFilter(LastHeardFilter.Any)
- waypointPacketsFlow.value = listOf(expiredWaypoint, activeWaypoint, neverExpiresWaypoint)
+ viewModel.filteredNodes.test {
+ val result = expectMostRecentItem()
+ assertEquals(2, result.size, "Any filter should show all nodes")
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
- assertEquals(
- mapOf(
- activeWaypoint.waypoint!!.id to activeWaypoint,
- neverExpiresWaypoint.waypoint!!.id to neverExpiresWaypoint,
- ),
- awaitItem(),
+ @Test
+ fun filteredNodes_combinedFavoritesAndLastHeard_filtersCorrectly() = runTest(testDispatcher) {
+ val now = nowSeconds.toInt()
+ val myNodeNum = 1
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
+ val nodes =
+ listOf(
+ nodeWithPosition(myNodeNum, lastHeard = now), // my node — always visible
+ nodeWithPosition(2, isFavorite = true, lastHeard = now), // favorite + recent
+ nodeWithPosition(3, isFavorite = true, lastHeard = now - 7200), // favorite + stale
+ nodeWithPosition(4, isFavorite = false, lastHeard = now), // non-favorite + recent
)
+ nodeRepository.setNodes(nodes)
+
+ // Enable both filters
+ viewModel.toggleOnlyFavorites()
+ viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
+
+ viewModel.filteredNodes.test {
+ val result = expectMostRecentItem()
+ val nodeNums = result.map { it.num }.toSet()
+ // My node always visible, favorite+recent visible, favorite+stale filtered, non-favorite filtered
+ assertTrue(myNodeNum in nodeNums, "My node should always be visible")
+ assertTrue(2 in nodeNums, "Favorite + recent node should be visible")
+ assertFalse(3 in nodeNums, "Favorite + stale node should be filtered out by lastHeard")
+ assertFalse(4 in nodeNums, "Non-favorite node should be filtered out by favorites filter")
cancelAndIgnoreRemainingEvents()
}
}
- private fun waypointPacket(id: Int, expire: Int): DataPacket = DataPacket(
- to = DataPacket.ID_BROADCAST,
- channel = 0,
- waypoint = Waypoint(id = id, name = "Waypoint $id", expire = expire),
- )
+ // ---- getNodeOrFallback ----
+
+ @Test
+ fun getNodeOrFallback_existingNode_returnsNode() {
+ val testNode = TestDataFactory.createTestNode(num = 42, longName = "Found")
+ nodeRepository.setNodes(listOf(testNode))
+ val result = viewModel.getNodeOrFallback(42)
+ assertEquals(42, result.num)
+ assertEquals("Found", result.user.long_name)
+ }
+
+ @Test
+ fun getNodeOrFallback_missingNode_returnsFallback() {
+ val result = viewModel.getNodeOrFallback(9999)
+ assertEquals(9999, result.num)
+ }
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
new file mode 100644
index 0000000000..3b73c3736b
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -0,0 +1,274 @@
+/*
+ * 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.feature.map
+
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.testing.FakeMapCameraPrefs
+import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.proto.Waypoint
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MapViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private lateinit var viewModel: MapViewModel
+ private lateinit var mapCameraPrefs: FakeMapCameraPrefs
+ private lateinit var mapPrefs: FakeMapPrefs
+ private lateinit var radioController: FakeRadioController
+ private val packetRepository: PacketRepository = mock()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ mapCameraPrefs = FakeMapCameraPrefs()
+ mapPrefs = FakeMapPrefs()
+ radioController = FakeRadioController()
+ every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
+ viewModel = createViewModel()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel(
+ savedStateHandle: SavedStateHandle = SavedStateHandle(),
+ nodeRepository: FakeNodeRepository = FakeNodeRepository(),
+ ): MapViewModel = MapViewModel(
+ mapPrefs = mapPrefs,
+ mapCameraPrefs = mapCameraPrefs,
+ nodeRepository = nodeRepository,
+ packetRepository = packetRepository,
+ radioController = radioController,
+ savedStateHandle = savedStateHandle,
+ ioDispatcher = testDispatcher,
+ )
+
+ @Test
+ fun selectedWaypointIdDefaultsToNull() {
+ assertNull(viewModel.selectedWaypointId.value)
+ }
+
+ @Test
+ fun selectedWaypointIdRestoredFromSavedState() {
+ val vm = createViewModel(SavedStateHandle(mapOf("waypointId" to 42)))
+ assertEquals(42, vm.selectedWaypointId.value)
+ }
+
+ @Test
+ fun setWaypointIdUpdatesState() {
+ viewModel.setWaypointId(7)
+ assertEquals(7, viewModel.selectedWaypointId.value)
+
+ viewModel.setWaypointId(null)
+ assertNull(viewModel.selectedWaypointId.value)
+ }
+
+ @Test
+ fun initialCameraPositionReflectsPrefs() {
+ mapCameraPrefs.setCameraLat(51.5)
+ mapCameraPrefs.setCameraLng(-0.1)
+ mapCameraPrefs.setCameraZoom(12f)
+ mapCameraPrefs.setCameraTilt(30f)
+ mapCameraPrefs.setCameraBearing(45f)
+
+ val vm = createViewModel()
+ val pos = vm.initialCameraPosition
+
+ assertEquals(51.5, pos.target.latitude)
+ assertEquals(-0.1, pos.target.longitude)
+ assertEquals(12.0, pos.zoom)
+ assertEquals(30.0, pos.tilt)
+ assertEquals(45.0, pos.bearing)
+ }
+
+ @Test
+ fun saveCameraPositionPersistsToPrefs() {
+ val cameraPosition =
+ org.maplibre.compose.camera.CameraPosition(
+ target = org.maplibre.spatialk.geojson.Position(longitude = -122.4, latitude = 37.8),
+ zoom = 15.0,
+ tilt = 20.0,
+ bearing = 90.0,
+ )
+
+ viewModel.saveCameraPosition(cameraPosition)
+
+ assertEquals(37.8, mapCameraPrefs.cameraLat.value)
+ assertEquals(-122.4, mapCameraPrefs.cameraLng.value)
+ assertEquals(15f, mapCameraPrefs.cameraZoom.value)
+ assertEquals(20f, mapCameraPrefs.cameraTilt.value)
+ assertEquals(90f, mapCameraPrefs.cameraBearing.value)
+ }
+
+ @Test
+ fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) {
+ viewModel.baseStyle.test {
+ val style = awaitItem()
+ assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun selectMapStyleUpdatesBaseStyleAndSelectedMapStyle() = runTest(testDispatcher) {
+ viewModel.selectedMapStyle.test {
+ assertEquals(MapStyle.OpenStreetMap, awaitItem())
+
+ viewModel.selectMapStyle(MapStyle.Dark)
+ assertEquals(MapStyle.Dark, awaitItem())
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun baseStyleEmitsUriOnStyleChange() = runTest(testDispatcher) {
+ viewModel.baseStyle.test {
+ // Initial style
+ awaitItem()
+
+ viewModel.selectMapStyle(MapStyle.Dark)
+ val darkStyle = awaitItem()
+ assertEquals(MapStyle.Dark.toBaseStyle(), darkStyle)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) {
+ // selectedStyleUri defaults to "" in FakeMapCameraPrefs
+ viewModel.baseStyle.test {
+ val style = awaitItem()
+ assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // ---- createAndSendWaypoint tests ----
+
+ @Test
+ fun createAndSendWaypoint_newWaypoint_convertsPositionToIntCoordinates() = runTest(testDispatcher) {
+ val position = org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0)
+
+ viewModel.createAndSendWaypoint(
+ name = "Test WP",
+ description = "A waypoint",
+ icon = 0x1F4CD,
+ locked = false,
+ expire = 0,
+ existingWaypoint = null,
+ position = position,
+ )
+
+ // FakeRadioController.getPacketId() returns 1, and sendMessage appends to sentPackets
+ assertEquals(1, radioController.sentPackets.size)
+ val sent = radioController.sentPackets.first()
+ val wpt = sent.waypoint!!
+ assertEquals("Test WP", wpt.name)
+ assertEquals("A waypoint", wpt.description)
+ assertEquals(0x1F4CD, wpt.icon)
+ assertEquals(0, wpt.locked_to)
+ // 40.0 / 1e-7 = 400000000
+ assertEquals(400000000, wpt.latitude_i)
+ // -74.0 / 1e-7 = -740000000
+ assertEquals(-740000000, wpt.longitude_i)
+ }
+
+ @Test
+ fun createAndSendWaypoint_editExisting_retainsOriginalCoordinates() = runTest(testDispatcher) {
+ val existing = Waypoint(id = 42, name = "Old Name", latitude_i = 515000000, longitude_i = -1000000)
+
+ viewModel.createAndSendWaypoint(
+ name = "New Name",
+ description = "Updated",
+ icon = 0x1F3E0,
+ locked = false,
+ expire = 0,
+ existingWaypoint = existing,
+ position = null,
+ )
+
+ assertEquals(1, radioController.sentPackets.size)
+ val wpt = radioController.sentPackets.first().waypoint!!
+ assertEquals(42, wpt.id) // Retains existing ID
+ assertEquals("New Name", wpt.name)
+ assertEquals(515000000, wpt.latitude_i) // Retains existing coords
+ assertEquals(-1000000, wpt.longitude_i)
+ }
+
+ @Test
+ fun createAndSendWaypoint_locked_setsLockedToMyNodeNum() = runTest(testDispatcher) {
+ val nodeRepository = FakeNodeRepository()
+ nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = 99))
+ val vm = createViewModel(nodeRepository = nodeRepository)
+ val position = org.maplibre.spatialk.geojson.Position(longitude = 0.1, latitude = 0.1)
+
+ vm.createAndSendWaypoint(
+ name = "Locked WP",
+ description = "",
+ icon = 0,
+ locked = true,
+ expire = 0,
+ existingWaypoint = null,
+ position = position,
+ )
+
+ assertEquals(1, radioController.sentPackets.size)
+ assertEquals(99, radioController.sentPackets.first().waypoint!!.locked_to)
+ }
+
+ @Test
+ fun createAndSendWaypoint_noPositionNoExisting_usesZeroCoordinates() = runTest(testDispatcher) {
+ viewModel.createAndSendWaypoint(
+ name = "Nowhere",
+ description = "",
+ icon = 0,
+ locked = false,
+ expire = 0,
+ existingWaypoint = null,
+ position = null,
+ )
+
+ assertEquals(1, radioController.sentPackets.size)
+ val wpt = radioController.sentPackets.first().waypoint!!
+ assertEquals(0, wpt.latitude_i)
+ assertEquals(0, wpt.longitude_i)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt
new file mode 100644
index 0000000000..a4e2ed728e
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.feature.map.model
+
+import org.maplibre.compose.style.BaseStyle
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class MapStyleTest {
+
+ @Test
+ fun toBaseStyle_returnsUriForVectorStyles() {
+ for (style in MapStyle.entries.filter { it != MapStyle.Satellite }) {
+ val baseStyle = style.toBaseStyle()
+ assertIs(baseStyle)
+ assertEquals(style.styleUri, baseStyle.uri)
+ }
+ }
+
+ @Test
+ fun toBaseStyle_returnsJsonForSatellite() {
+ val baseStyle = MapStyle.Satellite.toBaseStyle()
+ assertIs(baseStyle)
+ assertTrue(baseStyle.json.contains("esri-satellite"))
+ }
+
+ @Test
+ fun allStyles_haveNonBlankUri() {
+ for (style in MapStyle.entries) {
+ assertTrue(style.styleUri.isNotBlank(), "${style.name} has a blank styleUri")
+ }
+ }
+
+ @Test
+ fun openStreetMap_isDefault() {
+ // Verify OpenStreetMap is the first entry (used as default throughout the app)
+ assertEquals(MapStyle.OpenStreetMap, MapStyle.entries.first())
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt
new file mode 100644
index 0000000000..d52da3a8f0
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt
@@ -0,0 +1,403 @@
+/*
+ * 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.feature.map.util
+
+import org.maplibre.spatialk.geojson.Feature
+import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
+import org.meshtastic.proto.Waypoint
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@Suppress("MagicNumber")
+class GeoJsonConvertersTest {
+
+ // --- nodesToFeatureCollection ---
+
+ @Test
+ fun nodesToFeatureCollection_emptyList_returnsEmptyCollection() {
+ val result = nodesToFeatureCollection(emptyList())
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_skipsNodesWithoutPosition() {
+ val node = Node(num = 1, position = Position())
+ val result = nodesToFeatureCollection(listOf(node))
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_skipsZeroLatLng() {
+ val node = Node(num = 1, position = Position(latitude_i = 0, longitude_i = 0))
+ val result = nodesToFeatureCollection(listOf(node))
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_convertsValidNode() {
+ val node =
+ Node(
+ num = 42,
+ user = User(short_name = "AB", long_name = "Alpha Bravo"),
+ position = Position(latitude_i = 400000000, longitude_i = -740000000),
+ lastHeard = 1000,
+ isFavorite = true,
+ hopsAway = 2,
+ viaMqtt = false,
+ snr = 5.5f,
+ rssi = -80,
+ )
+ val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42)
+ assertEquals(1, result.features.size)
+
+ val feature = result.features.first()
+ val coords = feature.geometry.coordinates
+ assertEquals(40.0, coords.latitude, 0.001)
+ assertEquals(-74.0, coords.longitude, 0.001)
+
+ val props = feature.properties
+ assertEquals(42, props["node_num"]?.toString()?.toIntOrNull())
+ assertEquals("\"AB\"", props["short_name"].toString())
+ assertEquals("\"Alpha Bravo\"", props["long_name"].toString())
+ assertEquals("true", props["is_favorite"].toString())
+ assertEquals("true", props["is_my_node"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_isMyNodeFalseForOtherNodes() {
+ val node = Node(num = 10, position = Position(latitude_i = 400000000, longitude_i = -740000000))
+ val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42)
+ val props = result.features.first().properties
+ assertEquals("false", props["is_my_node"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_isOnline_offlineByDefault() {
+ // lastHeard defaults to 0 (epoch 1970), always older than the 2-hour online threshold
+ val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000))
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals("false", props["is_online"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_isOnline_trueWhenRecentlyHeard() {
+ val recentTimestamp = nowSeconds.toInt()
+ val node =
+ Node(
+ num = 1,
+ position = Position(latitude_i = 400000000, longitude_i = -740000000),
+ lastHeard = recentTimestamp,
+ )
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals("true", props["is_online"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_batteryLevel_withKnownBattery() {
+ val node =
+ Node(
+ num = 1,
+ position = Position(latitude_i = 400000000, longitude_i = -740000000),
+ deviceMetrics = DeviceMetrics(battery_level = 75),
+ )
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals(75, props["battery_level"]?.toString()?.toIntOrNull())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_batteryLevel_nullDefaultsToNegativeOne() {
+ // Default DeviceMetrics has null battery_level — should map to -1 sentinel
+ val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000))
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals(-1, props["battery_level"]?.toString()?.toIntOrNull())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_multipleNodes() {
+ val nodes =
+ listOf(
+ Node(num = 1, position = Position(latitude_i = 100000000, longitude_i = 200000000)),
+ Node(num = 2, position = Position(latitude_i = 300000000, longitude_i = 400000000)),
+ )
+ val result = nodesToFeatureCollection(nodes)
+ assertEquals(2, result.features.size)
+ }
+
+ // --- waypointsToFeatureCollection ---
+
+ @Test
+ fun waypointsToFeatureCollection_emptyMap_returnsEmptyCollection() {
+ val result = waypointsToFeatureCollection(emptyMap())
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun waypointsToFeatureCollection_skipsZeroLatLng() {
+ val waypoint = Waypoint(id = 1, latitude_i = 0, longitude_i = 0, name = "Test")
+ val packet = DataPacket("dest", 0, waypoint)
+ val result = waypointsToFeatureCollection(mapOf(1 to packet))
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun waypointsToFeatureCollection_convertsValidWaypoint() {
+ val waypoint =
+ Waypoint(
+ id = 99,
+ name = "Home",
+ description = "My house",
+ icon = 0x1F3E0, // House emoji
+ locked_to = 42,
+ latitude_i = 515000000,
+ longitude_i = -1000000,
+ expire = 0,
+ )
+ val packet = DataPacket("dest", 0, waypoint)
+ val result = waypointsToFeatureCollection(mapOf(99 to packet))
+
+ assertEquals(1, result.features.size)
+ val feature = result.features.first()
+ val coords = feature.geometry.coordinates
+ assertEquals(51.5, coords.latitude, 0.001)
+ assertEquals(-0.1, coords.longitude, 0.001)
+
+ val props = feature.properties
+ assertEquals(99, props["waypoint_id"]?.toString()?.toIntOrNull())
+ assertEquals("\"Home\"", props["name"].toString())
+ }
+
+ // --- positionsToLineString ---
+
+ @Test
+ fun positionsToLineString_lessThanTwoPositions_returnsEmptyCollection() {
+ val result = positionsToLineString(listOf(Position(latitude_i = 100000000, longitude_i = 200000000)))
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun positionsToLineString_emptyList_returnsEmptyCollection() {
+ val result = positionsToLineString(emptyList())
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun positionsToLineString_validPositions_createsLineString() {
+ val positions =
+ listOf(
+ Position(latitude_i = 100000000, longitude_i = 200000000),
+ Position(latitude_i = 110000000, longitude_i = 210000000),
+ Position(latitude_i = 120000000, longitude_i = 220000000),
+ )
+ val result = positionsToLineString(positions)
+ assertEquals(1, result.features.size)
+ }
+
+ @Test
+ fun positionsToLineString_skipsZeroCoords() {
+ val positions =
+ listOf(
+ Position(latitude_i = 100000000, longitude_i = 200000000),
+ Position(latitude_i = 0, longitude_i = 0),
+ Position(latitude_i = 120000000, longitude_i = 220000000),
+ )
+ val result = positionsToLineString(positions)
+ assertEquals(1, result.features.size)
+ }
+
+ // --- positionsToPointFeatures ---
+
+ @Test
+ fun positionsToPointFeatures_emptyList_returnsEmptyCollection() {
+ val result = positionsToPointFeatures(emptyList())
+ assertTrue(result.features.isEmpty())
+ }
+
+ @Test
+ fun positionsToPointFeatures_convertsValidPositions() {
+ val positions = listOf(Position(latitude_i = 400000000, longitude_i = -740000000, time = 1000, altitude = 100))
+ val result = positionsToPointFeatures(positions)
+ assertEquals(1, result.features.size)
+ val props = result.features.first().properties
+ assertEquals("\"1000\"", props["time"].toString())
+ assertEquals(100, props["altitude"]?.toString()?.toIntOrNull())
+ }
+
+ // --- precisionBitsToMeters ---
+
+ @Test
+ fun precisionBitsToMeters_knownValues() {
+ assertEquals(5886.0, precisionBitsToMeters(10))
+ assertEquals(2944.0, precisionBitsToMeters(11))
+ assertEquals(1472.0, precisionBitsToMeters(12))
+ assertEquals(736.0, precisionBitsToMeters(13))
+ assertEquals(368.0, precisionBitsToMeters(14))
+ assertEquals(184.0, precisionBitsToMeters(15))
+ assertEquals(92.0, precisionBitsToMeters(16))
+ assertEquals(46.0, precisionBitsToMeters(17))
+ assertEquals(23.0, precisionBitsToMeters(18))
+ assertEquals(11.5, precisionBitsToMeters(19))
+ }
+
+ @Test
+ fun precisionBitsToMeters_outOfRange_returnsZero() {
+ assertEquals(0.0, precisionBitsToMeters(0))
+ assertEquals(0.0, precisionBitsToMeters(9))
+ assertEquals(0.0, precisionBitsToMeters(20))
+ assertEquals(0.0, precisionBitsToMeters(-1))
+ }
+
+ // --- intToHexColor ---
+
+ @Test
+ fun intToHexColor_basicColors() {
+ assertEquals("#FF0000", intToHexColor(0xFFFF0000.toInt())) // Red
+ assertEquals("#00FF00", intToHexColor(0xFF00FF00.toInt())) // Green
+ assertEquals("#0000FF", intToHexColor(0xFF0000FF.toInt())) // Blue
+ assertEquals("#000000", intToHexColor(0xFF000000.toInt())) // Black
+ assertEquals("#FFFFFF", intToHexColor(0xFFFFFFFF.toInt())) // White
+ }
+
+ @Test
+ fun intToHexColor_stripsAlpha() {
+ // Alpha channel should be stripped — only RGB remains
+ assertEquals("#6750A4", intToHexColor(0xFF6750A4.toInt()))
+ assertEquals("#6750A4", intToHexColor(0x006750A4))
+ }
+
+ @Test
+ fun intToHexColor_padsSixDigits() {
+ assertEquals("#000001", intToHexColor(1))
+ assertEquals("#000100", intToHexColor(0x100))
+ }
+
+ // --- convertIntToEmoji ---
+
+ @Test
+ fun convertIntToEmoji_bmpCharacter() {
+ // 0x2764 = Heart character (❤)
+ assertEquals("\u2764", convertIntToEmoji(0x2764))
+ }
+
+ @Test
+ fun convertIntToEmoji_supplementaryCharacter() {
+ // 0x1F4CD = Round Pushpin (📍)
+ assertEquals("\uD83D\uDCCD", convertIntToEmoji(0x1F4CD))
+ }
+
+ @Test
+ fun convertIntToEmoji_houseEmoji() {
+ // 0x1F3E0 = House (🏠)
+ val result = convertIntToEmoji(0x1F3E0)
+ assertEquals(2, result.length) // Surrogate pair
+ }
+
+ @Test
+ fun convertIntToEmoji_maxBmpCharacter() {
+ val result = convertIntToEmoji(0xFFFF)
+ assertEquals(1, result.length)
+ }
+
+ @Test
+ fun convertIntToEmoji_negativeCodepoint_returnsNonEmptyString() {
+ // Negative code points wrap around in char conversion but should not crash
+ val result = convertIntToEmoji(-1)
+ assertTrue(result.isNotEmpty(), "Should return a non-empty string even for invalid code points")
+ }
+
+ // --- toGeoPositionOrNull ---
+
+ @Test
+ fun toGeoPositionOrNull_validCoords_returnsGeoPosition() {
+ val result = toGeoPositionOrNull(400000000, -740000000)
+ assertNotNull(result)
+ assertEquals(40.0, result.latitude, 0.001)
+ assertEquals(-74.0, result.longitude, 0.001)
+ }
+
+ @Test
+ fun toGeoPositionOrNull_zeroCoords_returnsNull() {
+ val result = toGeoPositionOrNull(0, 0)
+ assertNull(result)
+ }
+
+ @Test
+ fun toGeoPositionOrNull_nullCoords_returnsNull() {
+ val result = toGeoPositionOrNull(null, null)
+ assertNull(result)
+ }
+
+ @Test
+ fun toGeoPositionOrNull_onlyLatNull_treatedAsZero() {
+ // null lat = 0, non-zero lng -> lat=0.0 && lng!=0.0 -> not both zero -> returns position
+ val result = toGeoPositionOrNull(null, 100000000)
+ assertNotNull(result)
+ assertEquals(0.0, result.latitude, 0.001)
+ assertEquals(10.0, result.longitude, 0.001)
+ }
+
+ // --- typedFeatureCollection ---
+
+ @Test
+ fun typedFeatureCollection_preservesFeatures() {
+ val features =
+ listOf(
+ Feature(
+ geometry = Point(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)),
+ properties = null,
+ ),
+ )
+ val result = typedFeatureCollection(features)
+ assertEquals(1, result.features.size)
+ }
+
+ // --- computeBoundingBox ---
+
+ @Test
+ fun computeBoundingBox_fewerThanTwoPositions_returnsNull() {
+ assertNull(computeBoundingBox(emptyList()))
+ assertNull(computeBoundingBox(listOf(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0))))
+ }
+
+ @Test
+ fun computeBoundingBox_twoOrMorePositions_returnsBounds() {
+ val positions =
+ listOf(
+ org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0),
+ org.maplibre.spatialk.geojson.Position(longitude = -73.0, latitude = 41.0),
+ org.maplibre.spatialk.geojson.Position(longitude = -75.0, latitude = 39.0),
+ )
+ val bbox = computeBoundingBox(positions)
+ assertNotNull(bbox)
+ assertEquals(39.0, bbox.southwest.latitude, 0.001)
+ assertEquals(-75.0, bbox.southwest.longitude, 0.001)
+ assertEquals(41.0, bbox.northeast.latitude, 0.001)
+ assertEquals(-73.0, bbox.northeast.longitude, 0.001)
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
similarity index 72%
rename from core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt
rename to feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
index e2a3206d11..d98dc681a1 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -14,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.ui.util
+package org.meshtastic.feature.map
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.Modifier
-import org.meshtastic.core.model.Node
+import org.maplibre.compose.location.LocationProvider
+import org.maplibre.compose.location.rememberDefaultLocationProvider
-val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } }
+@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
similarity index 82%
rename from androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
rename to feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
index 48b1aa7fc3..2dfb87c2ea 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
@@ -14,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.map
+package org.meshtastic.feature.map
-import org.meshtastic.core.ui.util.MapViewProvider
-
-fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
+/** iOS implements the full MapLibre Compose sources/layers API. */
+actual val mapOverlaysSupported: Boolean = true
diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
new file mode 100644
index 0000000000..1f4acf1c47
--- /dev/null
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.feature.map
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+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.unit.dp
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.maplibre.compose.camera.CameraState
+import org.maplibre.compose.material3.OfflinePackListItem
+import org.maplibre.compose.offline.OfflinePackDefinition
+import org.maplibre.compose.offline.rememberOfflineManager
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.done
+import org.meshtastic.core.resources.offline_download
+import org.meshtastic.core.resources.offline_download_visible_region
+import org.meshtastic.core.resources.offline_downloaded_regions
+import org.meshtastic.core.resources.offline_maps
+import org.meshtastic.core.resources.offline_saves_tiles
+import org.meshtastic.core.resources.offline_unnamed_region
+import org.meshtastic.core.ui.icon.CloudDownload
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+@Suppress("LongMethod")
+@Composable
+actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) {
+ val offlineManager = rememberOfflineManager()
+ val coroutineScope = rememberCoroutineScope()
+ var showDialog by remember { mutableStateOf(false) }
+
+ if (showDialog) {
+ val unnamedRegion = stringResource(Res.string.offline_unnamed_region)
+ AlertDialog(
+ onDismissRequest = { showDialog = false },
+ title = { Text(stringResource(Res.string.offline_maps)) },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickable {
+ coroutineScope.launch {
+ val projection = cameraState.awaitProjection()
+ val bounds = projection.queryVisibleBoundingBox()
+ val pack =
+ offlineManager.create(
+ definition =
+ OfflinePackDefinition.TilePyramid(
+ styleUrl = styleUri,
+ bounds = bounds,
+ ),
+ metadata = "Region".encodeToByteArray(),
+ )
+ offlineManager.resume(pack)
+ }
+ }
+ .padding(vertical = 12.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.CloudDownload,
+ contentDescription = stringResource(Res.string.offline_download),
+ modifier = Modifier.padding(end = 16.dp),
+ )
+ Column {
+ Text(
+ text = stringResource(Res.string.offline_download_visible_region),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = stringResource(Res.string.offline_saves_tiles),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (offlineManager.packs.isNotEmpty()) {
+ Text(
+ text = stringResource(Res.string.offline_downloaded_regions),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
+ )
+ offlineManager.packs.toList().forEach { pack ->
+ key(pack.hashCode()) {
+ OfflinePackListItem(pack = pack, offlineManager = offlineManager) {
+ Text(pack.metadata?.decodeToString().orEmpty().ifBlank { unnamedRegion })
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = { showDialog = false }) { Text(stringResource(Res.string.done)) } },
+ )
+ }
+
+ IconButton(onClick = { showDialog = true }) {
+ Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = stringResource(Res.string.offline_maps))
+ }
+}
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
new file mode 100644
index 0000000000..717deb534d
--- /dev/null
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.feature.map
+
+import androidx.compose.runtime.Composable
+import org.maplibre.compose.location.LocationProvider
+
+/** Desktop has no location provider — return null so the UI disables location tracking. */
+@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = null
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
new file mode 100644
index 0000000000..4e1df79af6
--- /dev/null
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.feature.map
+
+/**
+ * Desktop (JVM) does NOT implement the MapLibre Compose sources/layers API in maplibre-compose 0.13.0 — every
+ * `Layer`/`Source` is stubbed with `TODO()`. Overlays are gated off so the base map renders without crashing.
+ */
+actual val mapOverlaysSupported: Boolean = false
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
similarity index 72%
rename from androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
rename to feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
index 14db01d4b1..e80a5eed6d 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt
@@ -14,13 +14,12 @@
* 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
+package org.meshtastic.feature.map
-class CustomTileSource {
+import androidx.compose.runtime.Composable
+import org.maplibre.compose.camera.CameraState
- companion object {
- fun getTileSource(index: Int) {
- index
- }
- }
+@Composable
+actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) {
+ // Offline map management is not available on Desktop.
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 13db96d5ad..9ebfb846cf 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -43,7 +43,7 @@ import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.util.LocalInlineMapProvider
+import org.meshtastic.feature.map.component.InlineMap
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
@@ -85,7 +85,7 @@ internal fun PositionInlineContent(
private fun PositionMap(node: Node, distance: String?) {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) {
- LocalInlineMapProvider.current(node, Modifier.fillMaxSize())
+ InlineMap(node, Modifier.fillMaxSize())
}
if (distance != null && distance.isNotEmpty()) {
Surface(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index a52f046c5b..25ed836208 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -31,8 +31,8 @@ import org.meshtastic.core.resources.position_log
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
-import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
+import org.meshtastic.feature.map.component.NodeTrackMap
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@@ -41,9 +41,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) }
- val trackMap = LocalNodeTrackMapProvider.current
- val destNum = state.node?.num ?: 0
-
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = null,
@@ -66,7 +63,12 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
},
chartPart = { modifier, selectedX, _, onPointSelected ->
val selectedTime = selectedX?.toInt()
- trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) }
+ NodeTrackMap(
+ positions = positions,
+ modifier = modifier,
+ selectedPositionTime = selectedTime,
+ onSelectPosition = { time -> onPointSelected(time.toDouble()) },
+ )
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
similarity index 91%
rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index 470bcf6930..2b49b4bdc2 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -52,8 +52,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
-import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
-import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
+import org.meshtastic.feature.map.component.TracerouteMap
import org.meshtastic.proto.Position
@Composable
@@ -102,7 +101,6 @@ private fun TracerouteMapScaffold(
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
- val insets = LocalTracerouteMapOverlayInsetsProvider.current
Scaffold(
topBar = {
MainAppBar(
@@ -117,18 +115,18 @@ private fun TracerouteMapScaffold(
},
) { paddingValues ->
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
- LocalTracerouteMapProvider.current(
- overlay,
- snapshotPositions,
- { shown: Int, total: Int ->
+ TracerouteMap(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = snapshotPositions,
+ onMappableCountChange = { shown: Int, total: Int ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
- Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize(),
)
Column(
- modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),
- horizontalAlignment = insets.contentHorizontalAlignment,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index ee4955c321..43558a0475 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -66,6 +66,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
+import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -125,12 +126,13 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
}
entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
- val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
- tracerouteMapScreen(
- args.destNum,
- args.requestId,
- args.logUuid,
- dropUnlessResumed { backStack.removeLastOrNull() },
+ val metricsViewModel = koinViewModel { parametersOf(args.destNum) }
+ metricsViewModel.setNodeId(args.destNum)
+ TracerouteMapScreen(
+ metricsViewModel = metricsViewModel,
+ requestId = args.requestId,
+ logUuid = args.logUuid,
+ onNavigateUp = { backStack.removeLastOrNull() },
)
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 16324a9850..b5d2d11e6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,14 +47,15 @@ compose-multiplatform-material3 = "1.11.0-alpha07"
# AndroidCompose.kt's resolutionStrategy force-aligns these groups to *this* version
# at resolution time, so it is the source of truth for the Android target.
androidx-compose-bom-aligned = "1.11.2"
-# `androidx-compose-material` (M2) is independent of CMP. Pinned because
-# maps-compose-widgets requests `androidx.compose.material:material` without
-# a version (relying on a BOM that we exclude). M2 is frozen at 1.7.8.
+# `androidx-compose-material` (M2) is independent of CMP. Pinned because some
+# transitive consumers request `androidx.compose.material:material` without a
+# version (relying on a BOM that we exclude). M2 is frozen at 1.7.8.
+# Consumed by build-logic AndroidCompose.kt to force-align the M2 material group.
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-beta01"
-# Google
-maps-compose = "8.3.0"
+# MapLibre
+maplibre-compose = "0.13.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
@@ -80,7 +81,6 @@ google-services-gradle = "4.4.4"
markdownRenderer = "0.41.0"
okio = "3.17.0"
uri-kmp = "0.0.21"
-osmdroid-android = "6.1.20"
spotless = "8.6.0"
wire = "6.4.0"
vico = "3.2.1"
@@ -173,12 +173,10 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" }
-maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
-maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
-maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
+maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" }
+maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibre-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
-play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" }
@@ -247,8 +245,6 @@ jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm"
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
-osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }
-osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" }
kermit = { module = "co.touchlab:kermit", version = "2.1.0" }
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }