feat(map): replace Google Maps + OSMDroid with unified Mapbox SDK in feature:map/androidMain

Replace the dual flavor-specific map implementations (Google Maps in app/src/google,
OSMDroid in app/src/fdroid) with a single Mapbox Maps Compose SDK (v11.21.1)
implementation living in feature:map/androidMain.

- Add Mapbox Maven repo with MAPBOX_DOWNLOADS_TOKEN auth to settings.gradle.kts
- Add mapbox-maps-android, mapbox-maps-compose deps to feature/map/build.gradle.kts
- Remove Google Maps, osmdroid, osmbonuspack deps from app/build.gradle.kts and catalog
- Create unified MapScreen, MapViewModel, MapboxMapContent, GeoJsonConverters,
  EditWaypointDialog, InlineMap, NodeTrackMap, NodeMapScreen, TracerouteMap
- Wire all Local*Provider CompositionLocals in MainActivity to new implementations
- Delete ~8200 lines of flavor-specific map code across google/fdroid source sets
- Delete dead MapViewProvider interface from core:ui
- Keep LocalMapMainScreenProvider for KMP/Desktop compatibility boundary
- Fix FlavorModule.kt, KoinVerificationTest.kt for deleted modules
- Pass spotlessCheck + detekt with zero violations
This commit is contained in:
James Rich 2026-04-14 16:24:29 -05:00
parent 099aea2d81
commit 536b1eba1c
72 changed files with 1170 additions and 7839 deletions

View file

@ -156,12 +156,7 @@ configure<ApplicationExtension> {
// Configure existing product flavors (defined by convention plugin)
// with their dynamic version names.
productFlavors {
configureEach {
versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name"
if (name == "google") {
manifestPlaceholders["MAPS_API_KEY"] = "dummy"
}
}
configureEach { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name" }
}
buildTypes {
@ -277,10 +272,6 @@ dependencies {
debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
@ -293,10 +284,6 @@ dependencies {
googleImplementation(libs.firebase.analytics)
googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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. <br/>
*
* 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<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
protected Point mPoint = new Point();
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
protected int mLastZoomLevel;
protected Bitmap mClusterIcon;
protected String mName, mDescription;
// abstract methods:
/** clustering algorithm */
public abstract ArrayList<StaticCluster> 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<StaticCluster> 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<MarkerWithLabel> 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<StaticCluster> reversedClusters() {
return new Iterable<StaticCluster>() {
@Override
public Iterator<StaticCluster> iterator() {
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
return new Iterator<StaticCluster>() {
@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);
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MarkerWithLabel> 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<StaticCluster> clusterer(MapView mapView) {
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
convertRadiusToMeters(mapView);
mClonedMarkers = new ArrayList<MarkerWithLabel>(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<MarkerWithLabel> 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<StaticCluster> 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;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
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<getSize(); i++) {
p = getItem(i).getPosition();
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
bb.set(maxLat, maxLon, minLat, minLon);
}
return bb;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}

View file

@ -1,80 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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()

View file

@ -1,968 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import android.Manifest
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
import org.meshtastic.core.resources.map_cache_tiles
import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
import org.meshtastic.core.resources.map_purge_success
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.cachemanager.CacheManager
import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
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<Waypoint?>(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<Node>): List<MarkerWithLabel> {
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<DataPacket>, selectedWaypointId: Int?): List<MarkerWithLabel> {
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt())
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val now = nowMillis
val expireTimeMillis = pt.expire * 1000L
val expireTimeStr =
when {
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
if (selectedWaypointId == pt.id) {
showInfoWindow()
}
setOnLongClickListener {
showMarkerLongPressDialog(pt.id)
true
}
}
}
}
val mapEventsReceiver =
object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
InfoWindow.closeAllInfoWindowsOn(map)
return true
}
override fun longPressHelper(p: GeoPoint): Boolean {
performHapticFeedback()
val enabled = isConnected && downloadRegionBoundingBox == null
if (enabled) {
showEditWaypointDialog =
Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt())
}
return true
}
}
fun MapView.drawOverlays() {
if (overlays.none { it is MapEventsOverlay }) {
overlays.add(0, MapEventsOverlay(mapEventsReceiver))
}
if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) {
overlays.add(myLocationOverlay)
}
if (overlays.none { it is RadiusMarkerClusterer }) {
overlays.add(nodeClusterer)
}
addCopyright()
addScaleBarOverlay(density)
createLatLongGrid(false)
invalidate()
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
val polygon =
Polygon().apply {
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
}
overlays.add(polygon)
invalidate()
val tileCount: Int =
CacheManager(this)
.possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt())
cacheEstimate = getString(Res.string.map_cache_tiles, tileCount)
}
val boxOverlayListener =
object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
if (downloadRegionBoundingBox != null) {
event.source.generateBoxOverlay()
}
return true
}
override fun onZoom(event: ZoomEvent): Boolean = false
}
fun startDownload() {
val boundingBox = downloadRegionBoundingBox ?: return
try {
val outputName = buildString {
append(Configuration.getInstance().osmdroidBasePath.absolutePath)
append(File.separator)
append("mainFile.sqlite")
}
val writer = SqliteArchiveTileWriter(outputName)
val cacheManager = CacheManager(map, writer)
cacheManager.downloadAreaAsync(
context,
boundingBox,
zoomLevelMin.toInt(),
zoomLevelMax.toInt(),
cacheManagerCallback(
onTaskComplete = {
scope.launch { context.showToast(Res.string.map_download_complete) }
writer.onDetach()
},
onTaskFailed = { errors ->
scope.launch { context.showToast(Res.string.map_download_errors, errors) }
writer.onDetach()
},
),
)
} catch (ex: TileSourcePolicyException) {
Logger.d { "Tile source does not allow archiving: ${ex.message}" }
} catch (ex: Exception) {
Logger.d { "Tile source exception: ${ex.message}" }
}
}
Scaffold(
modifier = modifier,
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AndroidView(
factory = {
map.apply {
setDestroyMode(false)
addMapListener(boxOverlayListener)
}
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
with(mapView) {
updateMarkers(
onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
nodeClusterer,
)
}
mapView.drawOverlays()
}, // Renamed map to mapView to avoid conflict
)
if (downloadRegionBoundingBox != null) {
CacheLayout(
cacheEstimate = cacheEstimate,
onExecuteJob = { startDownload() },
onCancelDownload = {
downloadRegionBoundingBox = null
map.overlays.removeAll { it is Polygon }
map.invalidate()
},
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { mapFilterExpanded = true },
filterDropdownContent = {
FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
mapFilterState = mapFilterState,
mapViewModel = mapViewModel,
)
},
mapTypeContent = {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { showMapStyleDialog = true },
)
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
},
)
}
}
}
if (showMapStyleDialog) {
MapStyleDialog(
selectedMapStyle = mapViewModel.mapStyleId,
onDismiss = { showMapStyleDialog = false },
onSelectMapStyle = {
mapViewModel.mapStyleId = it
map.setTileSource(loadOnlineTileSourceBase())
},
)
}
if (showCacheManagerDialog) {
CacheManagerDialog(
onClickOption = { option ->
when (option) {
CacheManagerOption.CurrentCacheSize -> {
scope.launch { context.showToast(Res.string.calculating) }
showCurrentCacheInfo = true
}
CacheManagerOption.DownloadRegion -> map.generateBoxOverlay()
CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true
CacheManagerOption.Cancel -> Unit
}
showCacheManagerDialog = false
},
onDismiss = { showCacheManagerDialog = false },
)
}
if (showCurrentCacheInfo) {
CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false })
}
if (showPurgeTileSourceDialog) {
PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false })
}
if (showEditWaypointDialog != null) {
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
onSendClicked = { waypoint ->
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0
val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
mapViewModel.sendWaypoint(
waypoint.copy(
id = newId,
name = newName,
expire = newExpire,
locked_to = newLockedTo,
icon = newIcon,
),
)
},
onDeleteClicked = { waypoint ->
Logger.d { "User clicked delete waypoint ${waypoint.id}" }
showEditWaypointDialog = null
showDeleteMarkerDialog(waypoint)
},
onDismissRequest = {
Logger.d { "User clicked cancel marker edit dialog" }
showEditWaypointDialog = null
},
)
}
}
/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
@Composable
private fun FdroidMainMapFilterDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapFilterState: MapFilterState,
mapViewModel: MapViewModel,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
MapsDialog(onDismiss = onDismiss) {
CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
ListItem(
text = style,
trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null,
onClick = {
selected.value = index
onSelectMapStyle(index)
onDismiss()
},
)
}
}
}
private enum class CacheManagerOption(val label: StringResource) {
CurrentCacheSize(label = Res.string.map_cache_size),
DownloadRegion(label = Res.string.map_download_region),
ClearTiles(label = Res.string.map_clear_tiles),
Cancel(label = Res.string.cancel),
}
@Composable
private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) {
MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) {
CacheManagerOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickOption(option)
onDismiss()
}
}
}
}
@Composable
private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
val (cacheCapacity, currentCacheUsage) =
remember(mapView) {
val cacheManager = CacheManager(mapView)
cacheManager.cacheCapacity() to cacheManager.currentCacheUsage()
}
MapsDialog(
title = stringResource(Res.string.map_cache_manager),
onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) {
Text(
modifier = Modifier.padding(16.dp),
text =
stringResource(
Res.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
),
)
}
}
@Composable
private fun PurgeTileSourceDialog(onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val cache = SqlTileWriterExt()
val sourceList by derivedStateOf { cache.sources.map { it.source as String } }
val selected = remember { mutableStateListOf<Int>() }
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

View file

@ -1,145 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<GeoPoint>, 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<Position>, onClick: (Int) -> Unit): List<Marker> {
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
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _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
}

View file

@ -1,136 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -1,111 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<SourceCount>
get() {
val db = db
val ret: MutableList<SourceCount> = 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
}
}

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 = {})
}

View file

@ -1,65 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.ui.icon.Download
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
AnimatedVisibility(
visible = enabled,
enter =
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
exit =
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
) {
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
Icon(
imageVector = MeshtasticIcons.Download,
contentDescription = stringResource(Res.string.map_download_region),
modifier = Modifier.scale(1.25f),
)
}
}
}
// @Preview(showBackground = true)
// @Composable
// private fun DownloadButtonPreview() {
// DownloadButton(true, onClick = {})
// }

View file

@ -1,357 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import android.app.DatePickerDialog
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@Suppress("MagicNumber")
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
val context = LocalContext.current
val tz = systemTimeZone
// Determine locale-specific date format
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
// Check if 24-hour format is preferred
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
val currentInstant =
remember(waypointInput.expire) {
val expire = waypointInput.expire
if (expire != 0 && expire != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(expire.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
// State to hold selected date and time
var selectedDate by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
var selectedTime by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
EditTextPreference(
title = stringResource(Res.string.name),
value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(name = it) },
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(emoji)),
modifier =
Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
.padding(4.dp),
fontSize = 24.sp,
color = Color.Unspecified.copy(alpha = 1f),
)
}
},
)
EditTextPreference(
title = stringResource(Res.string.description),
value = waypointInput.description,
maxSize = 99,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(description = it) },
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Text(stringResource(Res.string.locked))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.locked_to != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
val newLdt =
LocalDateTime(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
hour = ldt.hour,
minute = ldt.minute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
ldt.month.ordinal,
ldt.day,
)
val timePickerDialog =
android.app.TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
val newLdt =
LocalDateTime(
year = ldt.year,
month = ldt.month,
day = ldt.day,
hour = selectedHour,
minute = selectedMinute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.hour,
ldt.minute,
is24Hour,
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Text(stringResource(Res.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
} else {
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
},
)
}
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedDate,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedTime,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
},
confirmButton = {
FlowRow(
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.Center,
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(Res.string.cancel))
}
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = !(waypointInput.name.isNullOrEmpty()),
) {
Text(stringResource(Res.string.delete))
}
}
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
Text(stringResource(Res.string.send))
}
}
},
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
showEmojiPickerView = false
waypointInput = waypointInput.copy(icon = it.codePointAt(0))
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("MagicNumber")
private fun EditWaypointFormPreview() {
AppTheme {
EditWaypointDialog(
waypoint =
Waypoint(
id = 123,
name = "Test 123",
description = "This is only a test",
icon = 128169,
expire = (nowSeconds.toInt() + 8 * 3600),
),
onSendClicked = {},
onDeleteClicked = {},
onDismissRequest = {},
)
}
}

View file

@ -1,208 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.model
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
@Suppress("UnusedPrivateProperty")
class CustomTileSource {
companion object {
val OPENWEATHER_RADAR =
OnlineTileSourceAuth(
"Open Weather Map",
1,
22,
256,
".png",
arrayOf("https://tile.openweathermap.org/map/"),
"Openweathermap",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
"precipitation",
"",
)
private val ESRI_IMAGERY =
object :
OnlineTileSourceBase(
"ESRI World Overview",
1,
20,
256,
".jpg",
arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"),
"Esri, Maxar, Earthstar Geographics, and the GIS User Community",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val ESRI_WORLD_TOPO =
object :
OnlineTileSourceBase(
"ESRI World TOPO",
1,
20,
256,
".jpg",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"),
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val USGS_HYDRO_CACHE =
object :
OnlineTileSourceBase(
"USGS Hydro Cache",
0,
18,
256,
"",
arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val USGS_SHADED_RELIEF =
object :
OnlineTileSourceBase(
"USGS Shaded Relief Only",
0,
18,
256,
"",
arrayOf(
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/",
),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
/** WMS TILE SERVER More research is required to get this to function correctly with overlays */
val NOAA_RADAR_WMS =
NOAAWmsTileSource(
"Recent Weather Radar",
arrayOf(
"https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" +
"radar_meteo_imagery_nexrad_time/MapServer/WmsServer?",
),
"1",
"1.1.0",
"",
"EPSG%3A3857",
"",
"image/png",
)
/** =============================================================================================== */
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
/** Source for each available [ITileSource] and their display names. */
val mTileSources: Map<ITileSource, String> =
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")
}
}
}

View file

@ -1,138 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Int, Int>) {
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)
}
}
}

View file

@ -1,160 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<String>,
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"
}
}

View file

@ -1,65 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<String>,
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"
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Position>,
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,
)
}
}
},
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View file

@ -1,288 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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<Int, Position>,
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<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, 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<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
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))
}
}

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
)

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 Meshtastic LLC
~ Copyright (c) 2025-2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
@ -18,10 +18,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
</application>
</manifest>

View file

@ -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])
@Module(includes = [GoogleNetworkModule::class])
class FlavorModule

View file

@ -1,21 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}

View file

@ -1,139 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -1,65 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,688 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
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<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _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<Int> = uiPrefs.theme
private val _errorFlow = MutableSharedFlow<String>()
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
val displayUnits =
radioConfigRepository.deviceProfileFlow
.mapNotNull { it.config?.display?.units }
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
viewModelScope.launch {
if (
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<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _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,
)

View file

@ -1,76 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<NodeClusterItem>,
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
)
}

View file

@ -1,216 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<MapLayerItem>,
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,
)
}

View file

@ -1,324 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<CustomTileProviderConfig?>(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<String?>(null) }
var urlError by remember { mutableStateOf<String?>(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<CustomTileProviderConfig>,
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
}

View file

@ -1,160 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.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 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) {
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() },
)
},
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
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,
)
}
}
}

View file

@ -1,114 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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) {
googleMapTypes.forEach { (name, type) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
mapViewModel.setSelectedGoogleMapType(type)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
if (customTileProviders.isNotEmpty()) {
HorizontalDivider()
customTileProviders.forEach { config ->
DropdownMenuItem(
text = { Text(config.name) },
onClick = {
mapViewModel.selectCustomTileProvider(config)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == config.urlTemplate) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
onClick = {
onManageCustomTileProvidersClicked()
onDismissRequest()
},
)
}
}

View file

@ -1,96 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.LocalLifecycleOwner
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<NodeClusterItem>,
mapFilterState: BaseMapViewModel.MapFilterState,
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
// Workaround for https://github.com/googlemaps/android-maps-compose/issues/858
// The maps clustering library creates an internal ComposeView to snapshot markers.
// If that view is not attached to the hierarchy (which it often isn't during rendering),
// it fails to find the Lifecycle and SavedState owners. We propagate them to the root view
// so the internal snapshot view can find them when walking up the tree.
LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) {
val root = view.rootView
if (root.findViewTreeLifecycleOwner() == null) {
root.setViewTreeLifecycleOwner(lifecycleOwner)
}
if (root.findViewTreeSavedStateRegistryOwner() == null) {
root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
}
}
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())
},
)
}

View file

@ -1,68 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -1,92 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.app.map.convertIntToEmoji
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
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
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) == 0) PUSHPIN else waypoint.icon!!
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
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) == 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

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.model
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@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
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.model
class CustomTileSource {
companion object {
fun getTileSource(index: Int) {
index
}
}
}

View file

@ -1,58 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 ?: 0]
}
}

View file

@ -1,54 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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),
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(
modifier = modifier,
mode =
GoogleMapMode.NodeTrack(
focusedNode = focusedNode,
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
),
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}

View file

@ -1,196 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<String?>
fun setSelectedGoogleMapType(value: String?)
val selectedCustomTileUrl: StateFlow<String?>
fun setSelectedCustomTileUrl(value: String?)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val cameraTargetLat: StateFlow<Double>
fun setCameraTargetLat(value: Double)
val cameraTargetLng: StateFlow<Double>
fun setCameraTargetLng(value: Double)
val cameraZoom: StateFlow<Float>
fun setCameraZoom(value: Float)
val cameraTilt: StateFlow<Float>
fun setCameraTilt(value: Float)
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
@Single
class GoogleMapsPrefsImpl(
@Named("GoogleMapsDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : GoogleMapsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val selectedGoogleMapType: StateFlow<String?> =
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<String?> =
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<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
}
override val cameraTargetLat: StateFlow<Double> =
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<Double> =
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<Float> =
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<Float> =
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<Float> =
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<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
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")
}
}

View file

@ -1,104 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<List<CustomTileProviderConfig>>
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<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
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<List<CustomTileProviderConfig>>(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error deserializing tile providers" }
customTileProvidersStateFlow.value = emptyList()
}
} else {
customTileProvidersStateFlow.value = emptyList()
}
}
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View file

@ -1,85 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@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)
}
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 ?: 0)
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,
)
}
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
)

View file

@ -52,9 +52,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.common.util.toMeshtasticUri
@ -70,12 +67,10 @@ import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
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
@ -84,7 +79,11 @@ 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.InlineMap
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.map.node.NodeTrackMap
import org.meshtastic.feature.map.traceroute.TracerouteMap
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
@ -172,22 +171,20 @@ class MainActivity : ComponentActivity() {
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,
NodeTrackMap(
destNum = destNum,
positions = positions,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
@ -198,7 +195,7 @@ class MainActivity : ComponentActivity() {
{ destNum, onNavigateUp ->
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->

View file

@ -28,8 +28,8 @@ import kotlinx.coroutines.CoroutineDispatcher
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.model.util.NodeIdLookup
import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import kotlin.test.Test

@ -1 +1 @@
Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b
Subproject commit a045501ea848f49d546cc10e4c162a32317d4c7e

View file

@ -21,8 +21,10 @@ 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].
* 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].
*
* On Android this is wired to [org.meshtastic.feature.map.MapScreen] via [MainActivity].
*/
@Suppress("Wrapping")
val LocalMapMainScreenProvider =

View file

@ -25,7 +25,7 @@ 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
* traceroute result. Unlike the main map screen, 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.
*

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MapViewProvider?> { null }

View file

@ -43,5 +43,10 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.di)
}
androidMain.dependencies {
implementation(libs.mapbox.maps.android)
implementation(libs.mapbox.maps.compose)
implementation(libs.accompanist.permissions)
}
}
}

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map
import com.mapbox.geojson.Feature
import com.mapbox.geojson.FeatureCollection
import com.mapbox.geojson.LineString
import com.mapbox.geojson.Point
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
/** Scale factor to convert protobuf integer coordinates to decimal degrees. */
internal const val COORDINATE_SCALE = 1e-7
/**
* Converts a protobuf [Position] integer coordinate pair to a Mapbox [Point], or returns null if the position is
* zero/missing.
*/
internal fun Position.toPointOrNull(): Point? {
val lat = (latitude_i ?: 0) * COORDINATE_SCALE
val lng = (longitude_i ?: 0) * COORDINATE_SCALE
return if (lat == 0.0 && lng == 0.0) null else Point.fromLngLat(lng, lat)
}
/** Converts an integer colour value to a hex colour string (#RRGGBB). */
internal fun Int.toHexColorString(): String = "#%06X".format(this and 0xFFFFFF)
/** Converts a Unicode code point to its emoji string, falling back to 📍 on error. */
internal fun convertIntToEmoji(codePoint: Int): String = try {
String(Character.toChars(codePoint))
} catch (_: IllegalArgumentException) {
"\uD83D\uDCCD"
}
/**
* Returns the precision radius in metres for a given precision-bits value. Formula mirrors the existing
* [precisionBitsToMeters] used in core:ui.
*/
@Suppress("MagicNumber")
internal fun precisionBitsToMeters(precisionBits: Int): Double = when {
precisionBits <= 0 -> 0.0
precisionBits >= 32 -> 0.0
else -> 111_320.0 / (1 shl precisionBits) * 180.0
}
// ---------------------------------------------------------------------------
// Node features
// ---------------------------------------------------------------------------
/**
* Builds a Mapbox [Feature] for a single [Node].
*
* Properties carried in the feature:
* - `nodeNum` Int, the node number (used as unique id)
* - `shortName` String, short display name
* - `longName` String, full display name
* - `color` String, hex colour for the marker fill
* - `isFavorite` Boolean
* - `lastHeard` Long, epoch seconds
* - `precisionMeters` Double, precision circle radius (0 = no circle)
*/
internal fun Node.toFeature(): Feature? {
val point = position?.toPointOrNull() ?: return null
return Feature.fromGeometry(point).also { f ->
f.addNumberProperty("nodeNum", num)
f.addStringProperty("shortName", user?.short_name ?: "?")
f.addStringProperty("longName", user?.long_name ?: "Unknown")
f.addStringProperty("color", colors.second.toHexColorString())
f.addBooleanProperty("isFavorite", isFavorite)
f.addNumberProperty("lastHeard", lastHeard.toLong())
f.addNumberProperty("precisionMeters", precisionBitsToMeters(position?.precision_bits ?: 0))
}
}
/** Converts a list of nodes to a [FeatureCollection] for use with a GeoJSON source. */
internal fun List<Node>.toFeatureCollection(): FeatureCollection =
FeatureCollection.fromFeatures(mapNotNull { it.toFeature() })
// ---------------------------------------------------------------------------
// Waypoint features
// ---------------------------------------------------------------------------
/**
* Builds a Mapbox [Feature] for a [Waypoint].
*
* Properties:
* - `id` Int, waypoint id
* - `name` String
* - `description` String
* - `icon` String, emoji representation
* - `lockedTo` Int, node num that locked this waypoint (0 = public)
*/
internal fun Waypoint.toFeature(): Feature? {
val lat = (latitude_i ?: 0) * COORDINATE_SCALE
val lng = (longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) return null
return Feature.fromGeometry(Point.fromLngLat(lng, lat)).also { f ->
f.addNumberProperty("id", id)
f.addStringProperty("name", name ?: "")
f.addStringProperty("description", description ?: "")
f.addStringProperty("icon", convertIntToEmoji(icon ?: 0x1F4CD))
f.addNumberProperty("lockedTo", locked_to ?: 0)
}
}
/** Converts a collection of [DataPacket]s (waypoints) to a [FeatureCollection]. */
internal fun Collection<DataPacket>.waypointsToFeatureCollection(): FeatureCollection =
FeatureCollection.fromFeatures(mapNotNull { it.waypoint?.toFeature() })
// ---------------------------------------------------------------------------
// Track / traceroute geometry
// ---------------------------------------------------------------------------
/** Converts a list of [Position]s to a Mapbox [LineString], returning null if fewer than 2 points. */
internal fun List<Position>.toLineStringOrNull(): LineString? {
val points = mapNotNull { it.toPointOrNull() }
return if (points.size >= 2) LineString.fromLngLats(points) else null
}

View file

@ -14,33 +14,170 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map
import android.Manifest
import androidx.compose.foundation.layout.Box
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.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 com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.mapbox.geojson.Point
import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
import com.mapbox.maps.extension.compose.rememberMapState
import com.mapbox.maps.extension.compose.style.MapStyle
import com.mapbox.maps.plugin.gestures.generated.GesturesSettings
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
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.map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Waypoint
/**
* Unified Mapbox-backed main map screen. Replaces the former [LocalMapViewProvider] / [LocalMapMainScreenProvider]
* indirection with a self-contained composable that lives entirely in `feature:map/androidMain`.
*
* Responsibilities:
* - Scaffold with [MainAppBar]
* - [MapboxMap] with persisted camera via [MapViewModel]
* - Location permissions + tracking via FusedLocationProviderClient (through [MapboxMap] built-in MyLocation layer)
* - Node cluster markers, precision circles, waypoint markers via [MapboxMapContent] helpers
* - Long-press to create/edit waypoints via [EditWaypointDialog]
* - [MapControlsOverlay] toolbar (filter, compass, location toggle)
* - Waypoint deep-link: animates camera to [waypointId] on first composition
*/
@OptIn(ExperimentalPermissionsApi::class, MapboxExperimental::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MapScreen(
onClickNodeChip: (Int) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: SharedMapViewModel,
viewModel: SharedMapViewModel = koinViewModel(),
mapViewModel: MapViewModel = koinViewModel(),
waypointId: Int? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
@Suppress("ViewModelForwarding")
// --- Location permissions ---
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
isLocationTrackingEnabled = true
triggerLocationToggleAfterPermission = false
}
}
// --- Camera state ---
val viewportState = rememberMapViewportState {
setCameraOptions {
val opts = mapViewModel.initialCameraOptions()
center(opts.center ?: Point.fromLngLat(0.0, 0.0))
zoom(opts.zoom ?: 5.0)
bearing(opts.bearing ?: 0.0)
pitch(opts.pitch ?: 0.0)
}
}
// Persist camera when viewport stops moving
LaunchedEffect(viewportState.mapViewportStatus) {
val cam = viewportState.cameraState ?: return@LaunchedEffect
val center = cam.center
mapViewModel.saveCameraPosition(
lat = center.latitude(),
lng = center.longitude(),
zoom = cam.zoom,
bearing = cam.bearing,
pitch = cam.pitch,
)
}
// --- Node / waypoint data ---
val allNodes by viewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val mapFilterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
val selectedMapStyle by mapViewModel.selectedMapStyle.collectAsStateWithLifecycle()
// Handle incoming waypointId deep-link: store it in the ViewModel and animate to it once
LaunchedEffect(waypointId) {
if (waypointId != null) {
mapViewModel.setWaypointId(waypointId)
}
}
// Animate camera to selected waypoint
LaunchedEffect(selectedWaypointId, waypoints) {
val id = selectedWaypointId ?: return@LaunchedEffect
val wpt = waypoints.values.mapNotNull { it.waypoint }.find { it.id == id } ?: return@LaunchedEffect
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) {
viewportState.flyTo(
com.mapbox.maps.CameraOptions.Builder().center(Point.fromLngLat(lng, lat)).zoom(14.0).build(),
)
}
}
val filteredNodes =
remember(allNodes, mapFilterState, ourNodeInfo) {
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
}
}
// --- Waypoint editing state ---
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// --- Filter menu expanded state ---
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
// --- Bearing for compass (read from viewport camera) ---
val bearing = (viewportState.cameraState?.bearing ?: 0.0).toFloat()
val coroutineScope = rememberCoroutineScope()
val mapState = rememberMapState {
gesturesSettings = GesturesSettings {
rotateEnabled = true
scrollEnabled = true
pitchEnabled = true
doubleTapToZoomInEnabled = true
}
}
Scaffold(
modifier = modifier,
topBar = {
@ -55,10 +192,86 @@ fun MapScreen(
)
},
) { paddingValues ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MapboxMap(
modifier = Modifier.fillMaxSize(),
mapViewportState = viewportState,
mapState = mapState,
style = { MapStyle(style = selectedMapStyle.styleUri) },
onMapLongClickListener = { point ->
if (isConnected) {
editingWaypoint =
Waypoint(
latitude_i = (point.latitude() / DEG_D).toInt(),
longitude_i = (point.longitude() / DEG_D).toInt(),
)
}
false
},
) {
NodeClusterMarkers(nodes = filteredNodes, onNodeClick = { node -> navigateToNodeDetails(node.num) })
PrecisionCircles(nodes = filteredNodes)
WaypointMarkers(
waypoints = waypoints.values,
filterState = mapFilterState,
selectedWaypointId = selectedWaypointId,
onWaypointClick = { wpt -> editingWaypoint = wpt },
)
}
// Controls overlay
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
bearing = bearing,
onToggleFilterMenu = { mapFilterMenuExpanded = true },
filterDropdownContent = {},
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
isLocationTrackingEnabled = !isLocationTrackingEnabled
if (!isLocationTrackingEnabled) followPhoneBearing = false
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
},
followPhoneBearing = followPhoneBearing,
onCompassClick = {
if (isLocationTrackingEnabled) {
followPhoneBearing = !followPhoneBearing
} else {
coroutineScope.launch {
viewportState.flyTo(com.mapbox.maps.CameraOptions.Builder().bearing(0.0).build())
}
}
},
)
// Waypoint edit dialog
editingWaypoint?.let { waypointToEdit ->
EditWaypointDialog(
waypoint = waypointToEdit,
onSendClicked = { updatedWp ->
mapViewModel.createAndSendWaypoint(
existing = if (updatedWp.id != 0) updatedWp else null,
name = updatedWp.name ?: "",
description = updatedWp.description ?: "",
icon = updatedWp.icon ?: 0,
lat = (updatedWp.latitude_i ?: 0) * COORDINATE_SCALE,
lng = (updatedWp.longitude_i ?: 0) * COORDINATE_SCALE,
)
editingWaypoint = null
},
onDeleteClicked = { wpToDelete ->
if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
viewModel.sendWaypoint(wpToDelete.copy(expire = 1))
}
viewModel.deleteWaypoint(wpToDelete.id)
editingWaypoint = null
},
onDismissRequest = { editingWaypoint = null },
)
}
}
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
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.preferencesDataStoreFile
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
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.KoinViewModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
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.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Waypoint
/**
* Android-specific extension of [BaseMapViewModel] that adds:
* - Mapbox camera state persistence (lat/lng/zoom/bearing/pitch) via DataStore
* - Map style selection
* - Waypoint creation helper
*
* The DataStore is created eagerly in the ViewModel's own scope to avoid requiring an activity-scoped store; it mirrors
* the pattern used by the former GoogleMapsPrefsImpl.
*/
@KoinViewModel
class MapViewModel(
context: Context,
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
private val dispatchers: CoroutineDispatchers,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val prefsScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val dataStore: DataStore<Preferences> =
PreferenceDataStoreFactory.create(
scope = prefsScope,
produceFile = { context.preferencesDataStoreFile("mapbox_map_prefs") },
)
// ---- Camera prefs ----
val cameraLat: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_LAT] ?: 0.0 }.stateIn(prefsScope, SharingStarted.Eagerly, 0.0)
val cameraLng: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_LNG] ?: 0.0 }.stateIn(prefsScope, SharingStarted.Eagerly, 0.0)
val cameraZoom: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_ZOOM] ?: 5.0 }.stateIn(prefsScope, SharingStarted.Eagerly, 5.0)
val cameraBearing: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_BEARING] ?: 0.0 }.stateIn(prefsScope, SharingStarted.Eagerly, 0.0)
val cameraPitch: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_PITCH] ?: 0.0 }.stateIn(prefsScope, SharingStarted.Eagerly, 0.0)
/** Builds the initial [CameraOptions] from persisted prefs. */
fun initialCameraOptions(): CameraOptions = CameraOptions.Builder()
.center(Point.fromLngLat(cameraLng.value, cameraLat.value))
.zoom(cameraZoom.value)
.bearing(cameraBearing.value)
.pitch(cameraPitch.value)
.build()
fun saveCameraPosition(lat: Double, lng: Double, zoom: Double, bearing: Double, pitch: Double) {
prefsScope.launch {
dataStore.edit { prefs ->
prefs[KEY_CAMERA_LAT] = lat
prefs[KEY_CAMERA_LNG] = lng
prefs[KEY_CAMERA_ZOOM] = zoom
prefs[KEY_CAMERA_BEARING] = bearing
prefs[KEY_CAMERA_PITCH] = pitch
}
}
}
// ---- Selected waypoint (for deep-link animation) ----
private val _selectedWaypointId = kotlinx.coroutines.flow.MutableStateFlow<Int?>(null)
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.stateInWhileSubscribed(initialValue = null)
fun setWaypointId(id: Int?) {
_selectedWaypointId.value = id
}
// ---- Map style ----
val selectedMapStyle: StateFlow<MapStyle> =
dataStore.data
.map { prefs ->
val name = prefs[KEY_MAP_STYLE]
MapStyle.entries.find { it.name == name } ?: MapStyle.Standard
}
.stateIn(prefsScope, SharingStarted.Eagerly, MapStyle.Standard)
fun setMapStyle(style: MapStyle) {
prefsScope.launch { dataStore.edit { it[KEY_MAP_STYLE] = style.name } }
}
// ---- Waypoint creation ----
/**
* Constructs a new or edited [Waypoint] and broadcasts it over the mesh.
* - New waypoints (id == 0) get a fresh packet-id assigned.
* - Locked waypoints are silently dropped.
* - Falls back to (0,0) if [lat]/[lng] are null.
*/
fun createAndSendWaypoint(
existing: Waypoint?,
name: String,
description: String,
icon: Int,
lat: Double?,
lng: Double?,
) {
if (existing != null && (existing.locked_to ?: 0) != 0) return
val latI = ((lat ?: 0.0) / COORDINATE_SCALE).toInt()
val lngI = ((lng ?: 0.0) / COORDINATE_SCALE).toInt()
val waypoint =
Waypoint(
id = existing?.id ?: (generatePacketId() ?: 0),
name = name,
description = description,
icon = if (icon == 0) 0x1F4CD else icon,
latitude_i = latI,
longitude_i = lngI,
locked_to = existing?.locked_to ?: 0,
expire = existing?.expire ?: 0,
)
sendWaypoint(waypoint, "0${DataPacket.ID_BROADCAST}")
}
companion object {
private val KEY_CAMERA_LAT = doublePreferencesKey("mapbox_camera_lat")
private val KEY_CAMERA_LNG = doublePreferencesKey("mapbox_camera_lng")
private val KEY_CAMERA_ZOOM = doublePreferencesKey("mapbox_camera_zoom")
private val KEY_CAMERA_BEARING = doublePreferencesKey("mapbox_camera_bearing")
private val KEY_CAMERA_PITCH = doublePreferencesKey("mapbox_camera_pitch")
private val KEY_MAP_STYLE = stringPreferencesKey("mapbox_map_style")
@Suppress("unused")
private val KEY_CAMERA_ZOOM_FLOAT = floatPreferencesKey("mapbox_camera_zoom_legacy")
}
}

View file

@ -0,0 +1,229 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map
import android.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.mapbox.geojson.Point
import com.mapbox.maps.extension.compose.annotation.generated.CircleAnnotationGroup
import com.mapbox.maps.extension.compose.annotation.generated.PointAnnotationGroup
import com.mapbox.maps.extension.compose.annotation.generated.PolylineAnnotationGroup
import com.mapbox.maps.plugin.annotation.AnnotationConfig
import com.mapbox.maps.plugin.annotation.AnnotationSourceOptions
import com.mapbox.maps.plugin.annotation.ClusterOptions
import com.mapbox.maps.plugin.annotation.generated.CircleAnnotationOptions
import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions
import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotationOptions
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
// ---------------------------------------------------------------------------
// Node cluster markers
// ---------------------------------------------------------------------------
/**
* Renders node markers with Mapbox clustering.
*
* Each node is a [PointAnnotationOptions] coloured by [Node.colors]. Clusters are rendered as [CircleAnnotationOptions]
* sized by point count.
*/
@Composable
internal fun NodeClusterMarkers(nodes: List<Node>, onNodeClick: (Node) -> Unit) {
if (nodes.isEmpty()) return
PointAnnotationGroup(
annotations =
nodes.map { node ->
val lat = (node.position?.latitude_i ?: 0) * DEG_D
val lng = (node.position?.longitude_i ?: 0) * DEG_D
PointAnnotationOptions()
.withPoint(Point.fromLngLat(lng, lat))
.withTextField(node.user?.short_name ?: "?")
.withTextColor(Color.WHITE)
.withTextSize(12.0)
.withIconSize(1.0)
},
annotationConfig =
AnnotationConfig(
annotationSourceOptions =
AnnotationSourceOptions(
clusterOptions =
ClusterOptions(
circleRadiusExpression =
com.mapbox.maps.extension.style.expressions.dsl.generated.literal(18.0),
colorLevels =
listOf(
Pair(10, Color.RED),
Pair(5, Color.parseColor("#FF8800")),
Pair(0, Color.parseColor("#2196F3")),
),
textColor = Color.WHITE,
textSize = 14.0,
),
),
),
) {
interactionsState.onClicked { annotation ->
val nodeNum =
annotation.point.let { pt ->
nodes.minByOrNull { n ->
val lat = (n.position?.latitude_i ?: 0) * DEG_D
val lng = (n.position?.longitude_i ?: 0) * DEG_D
val dLat = lat - pt.latitude()
val dLng = lng - pt.longitude()
dLat * dLat + dLng * dLng
}
}
nodeNum?.let { onNodeClick(it) }
true
}
}
}
// ---------------------------------------------------------------------------
// Precision circles
// ---------------------------------------------------------------------------
/**
* Renders a translucent precision circle for each node that has [precisionBitsToMeters] > 0. Zoom-level-accurate radius
* requires the native MapEffect workaround; for this POC we render fixed-radius circles and note that per-zoom
* interpolation is deferred.
*/
@Composable
internal fun PrecisionCircles(nodes: List<Node>) {
val circleNodes = nodes.filter { n -> precisionBitsToMeters(n.position?.precision_bits ?: 0) > 0 }
if (circleNodes.isEmpty()) return
CircleAnnotationGroup(
annotations =
circleNodes.map { node ->
val lat = (node.position?.latitude_i ?: 0) * DEG_D
val lng = (node.position?.longitude_i ?: 0) * DEG_D
val colorInt = node.colors.second
CircleAnnotationOptions()
.withPoint(Point.fromLngLat(lng, lat))
// Radius in pixels — approximate at zoom 15; proper meter conversion deferred
.withCircleRadius(20.0)
.withCircleColor(colorInt and 0xFFFFFF or (0x33 shl 24)) // 20% opacity
.withCircleStrokeColor(colorInt)
.withCircleStrokeWidth(1.5)
},
)
}
// ---------------------------------------------------------------------------
// Waypoint markers
// ---------------------------------------------------------------------------
@Composable
internal fun WaypointMarkers(
waypoints: Collection<DataPacket>,
filterState: MapFilterState,
selectedWaypointId: Int?,
onWaypointClick: (Waypoint) -> Unit,
) {
if (!filterState.showWaypoints) return
val displayable = waypoints.mapNotNull { it.waypoint }
if (displayable.isEmpty()) return
PointAnnotationGroup(
annotations =
displayable.map { wpt ->
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
PointAnnotationOptions()
.withPoint(Point.fromLngLat(lng, lat))
.withTextField(convertIntToEmoji(wpt.icon ?: 0x1F4CD))
.withTextSize(if (wpt.id == selectedWaypointId) 28.0 else 20.0)
},
) {
interactionsState.onClicked { annotation ->
val clicked =
displayable.minByOrNull { wpt ->
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
val dLat = lat - annotation.point.latitude()
val dLng = lng - annotation.point.longitude()
dLat * dLat + dLng * dLng
}
clicked?.let { onWaypointClick(it) }
true
}
}
}
// ---------------------------------------------------------------------------
// Track polyline
// ---------------------------------------------------------------------------
@Composable
internal fun TrackPolyline(positions: List<Position>, colorInt: Int) {
if (positions.size < 2) return
val points = positions.mapNotNull { it.toPointOrNull() }
if (points.size < 2) return
PolylineAnnotationGroup(
annotations =
listOf(
PolylineAnnotationOptions()
.withPoints(points)
.withLineColor(colorInt)
.withLineWidth(3.0)
.withLineOpacity(0.85),
),
)
}
// ---------------------------------------------------------------------------
// Traceroute polylines (offset forward + return routes)
// ---------------------------------------------------------------------------
@Composable
internal fun TraceroutePolylines(forwardPoints: List<Point>, returnPoints: List<Point>) {
if (forwardPoints.size >= 2) {
PolylineAnnotationGroup(
annotations =
listOf(
PolylineAnnotationOptions()
.withPoints(forwardPoints)
.withLineColor(Color.parseColor("#2196F3")) // blue — outgoing
.withLineWidth(4.0)
.withLineOpacity(0.9),
),
)
}
if (returnPoints.size >= 2) {
PolylineAnnotationGroup(
annotations =
listOf(
PolylineAnnotationOptions()
.withPoints(returnPoints)
.withLineColor(Color.parseColor("#FF5722")) // deep orange — return
.withLineWidth(3.0)
.withLineOpacity(0.9),
),
)
}
}

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
package org.meshtastic.feature.map.component
import android.app.DatePickerDialog
import android.app.TimePickerDialog

View file

@ -14,8 +14,20 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
package org.meshtastic.feature.map.model
import org.meshtastic.core.ui.util.MapViewProvider
import com.mapbox.maps.Style
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
/**
* Predefined map styles available in the Mapbox POC.
*
* Mapbox Standard and Standard Satellite are the flagship Mapbox styles and require a valid access token. The others
* are Mapbox-hosted equivalents.
*/
enum class MapStyle(val styleUri: String) {
Standard(Style.STANDARD),
Satellite(Style.SATELLITE_STREETS),
Outdoors(Style.OUTDOORS),
Light(Style.LIGHT),
Dark(Style.DARK),
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import com.mapbox.geojson.Point
import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
import com.mapbox.maps.extension.compose.annotation.generated.CircleAnnotationGroup
import com.mapbox.maps.extension.compose.annotation.generated.PointAnnotationGroup
import com.mapbox.maps.extension.compose.rememberMapState
import com.mapbox.maps.extension.compose.style.MapStyle
import com.mapbox.maps.plugin.annotation.generated.CircleAnnotationOptions
import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions
import com.mapbox.maps.plugin.gestures.generated.GesturesSettings
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.feature.map.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15.0
/**
* Read-only embedded Mapbox map for the node detail screen. Shows a node chip marker and optional precision circle. No
* controls or interaction.
*/
@OptIn(MapboxExperimental::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
key(node.num) {
val lat = (node.position?.latitude_i ?: 0) * DEG_D
val lng = (node.position?.longitude_i ?: 0) * DEG_D
val viewportState = rememberMapViewportState {
setCameraOptions {
center(Point.fromLngLat(lng, lat))
zoom(DEFAULT_ZOOM)
}
}
val mapState = rememberMapState {
gesturesSettings = GesturesSettings {
rotateEnabled = false
scrollEnabled = false
pitchEnabled = false
doubleTapToZoomInEnabled = false
quickZoomEnabled = false
pinchToZoomEnabled = false
}
}
MapboxMap(
modifier = modifier,
mapViewportState = viewportState,
mapState = mapState,
style = { MapStyle(style = com.mapbox.maps.Style.STANDARD) },
) {
val precisionMeters = precisionBitsToMeters(node.position?.precision_bits ?: 0)
val colorInt = node.colors.second
if (precisionMeters > 0) {
CircleAnnotationGroup(
annotations =
listOf(
CircleAnnotationOptions()
.withPoint(Point.fromLngLat(lng, lat))
.withCircleRadius(precisionMeters.coerceAtMost(5000.0) / 10.0)
.withCircleColor(colorInt and 0xFFFFFF or (0x33 shl 24))
.withCircleStrokeColor(colorInt)
.withCircleStrokeWidth(1.5),
),
)
}
PointAnnotationGroup(
annotations =
listOf(
PointAnnotationOptions()
.withPoint(Point.fromLngLat(lng, lat))
.withTextField(node.user?.short_name ?: "?")
.withTextColor(android.graphics.Color.WHITE)
.withTextSize(12.0),
),
)
}
}
}

View file

@ -14,7 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -23,14 +25,39 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mapbox.geojson.Point
import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
import com.mapbox.maps.extension.compose.style.MapStyle
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.map.TrackPolyline
/**
* Full-screen Mapbox node track map with [MainAppBar].
*
* Driven by [nodeMapViewModel] which provides the node and its position log.
*/
@OptIn(MapboxExperimental::class)
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val mostRecentPos = positions.maxByOrNull { it.time }
val initLat = (mostRecentPos?.latitude_i ?: 0) * DEG_D
val initLng = (mostRecentPos?.longitude_i ?: 0) * DEG_D
val viewportState = rememberMapViewportState {
setCameraOptions {
center(Point.fromLngLat(if (initLng != 0.0) initLng else 0.0, if (initLat != 0.0) initLat else 0.0))
zoom(if (initLat != 0.0 || initLng != 0.0) 12.0 else 2.0)
}
}
val colorInt = node?.colors?.second ?: android.graphics.Color.parseColor("#2196F3")
Scaffold(
topBar = {
MainAppBar(
@ -44,11 +71,12 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
NodeTrackOsmMap(
positions = positions,
applicationId = nodeMapViewModel.applicationId,
mapStyleId = nodeMapViewModel.mapStyleId,
MapboxMap(
modifier = Modifier.fillMaxSize().padding(paddingValues),
)
mapViewportState = viewportState,
style = { MapStyle(style = com.mapbox.maps.Style.STANDARD) },
) {
TrackPolyline(positions = positions, colorInt = colorInt)
}
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber", "UnusedParameter")
package org.meshtastic.feature.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mapbox.geojson.Point
import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
import com.mapbox.maps.extension.compose.style.MapStyle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.feature.map.TrackPolyline
import org.meshtastic.proto.Position
/**
* Embeddable Mapbox track map for a single node's position history.
*
* Renders a polyline of [positions] filtered by the ViewModel's last-heard track filter. Supports optional synchronized
* selection: when [selectedPositionTime] is non-null the map animates to the corresponding point; tapping a marker
* invokes [onPositionSelected].
*/
@OptIn(MapboxExperimental::class)
@Composable
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
// Derive initial camera center from most-recent position or fallback to (0,0)
val mostRecentPos = positions.maxByOrNull { it.time }
val initLat = (mostRecentPos?.latitude_i ?: 0) * DEG_D
val initLng = (mostRecentPos?.longitude_i ?: 0) * DEG_D
val viewportState = rememberMapViewportState {
setCameraOptions {
center(Point.fromLngLat(if (initLng != 0.0) initLng else 0.0, if (initLat != 0.0) initLat else 0.0))
zoom(if (initLat != 0.0 || initLng != 0.0) 12.0 else 2.0)
}
}
// Animate to selected position when driven from the list
androidx.compose.runtime.LaunchedEffect(selectedPositionTime) {
val selectedTime = selectedPositionTime ?: return@LaunchedEffect
val pos = positions.find { it.time == selectedTime } ?: return@LaunchedEffect
val lat = (pos.latitude_i ?: 0) * DEG_D
val lng = (pos.longitude_i ?: 0) * DEG_D
if (lat != 0.0 || lng != 0.0) {
viewportState.flyTo(com.mapbox.maps.CameraOptions.Builder().center(Point.fromLngLat(lng, lat)).build())
}
}
val colorInt = focusedNode?.colors?.second ?: android.graphics.Color.parseColor("#2196F3")
MapboxMap(
modifier = modifier,
mapViewportState = viewportState,
style = { MapStyle(style = com.mapbox.maps.Style.STANDARD) },
) {
TrackPolyline(positions = positions, colorInt = colorInt)
}
}

View file

@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map.traceroute
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 com.mapbox.geojson.Point
import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
import com.mapbox.maps.extension.compose.style.MapStyle
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.TraceroutePolylines
import org.meshtastic.feature.map.toPointOrNull
import org.meshtastic.proto.Position
/**
* Embeddable Mapbox traceroute map.
*
* Renders offset forward (blue) and return (orange) polylines for the given [tracerouteOverlay]. Invokes
* [onMappableCountChanged] with the number of nodes that have positions.
*/
@OptIn(MapboxExperimental::class)
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
// Build forward and return point lists from the overlay and node position map
val forwardPoints =
remember(tracerouteOverlay, tracerouteNodePositions) {
tracerouteOverlay?.forwardRoute?.mapNotNull { nodeNum -> tracerouteNodePositions[nodeNum]?.toPointOrNull() }
?: emptyList()
}
val returnPoints =
remember(tracerouteOverlay, tracerouteNodePositions) {
tracerouteOverlay?.returnRoute?.mapNotNull { nodeNum -> tracerouteNodePositions[nodeNum]?.toPointOrNull() }
?: emptyList()
}
// Report mappable node counts to the caller
LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
if (tracerouteOverlay != null) {
val allRouteNums = (tracerouteOverlay.forwardRoute + tracerouteOverlay.returnRoute).distinct()
val mappable = allRouteNums.count { tracerouteNodePositions.containsKey(it) }
onMappableCountChanged(mappable, tracerouteOverlay.relatedNodeNums.size)
}
}
// Initial camera: center on the midpoint of all route points
val allPoints = (forwardPoints + returnPoints).distinct()
val initCenter = allPoints.firstOrNull() ?: Point.fromLngLat(0.0, 0.0)
val viewportState = rememberMapViewportState {
setCameraOptions {
center(initCenter)
zoom(if (allPoints.isNotEmpty()) 10.0 else 2.0)
}
}
// Auto-fit camera to all points when they first become available
var hasCentered by remember { mutableStateOf(false) }
LaunchedEffect(allPoints) {
if (hasCentered || allPoints.isEmpty()) return@LaunchedEffect
val target =
if (allPoints.size == 1) {
com.mapbox.maps.CameraOptions.Builder().center(allPoints.first()).zoom(12.0).build()
} else {
// Compute a rough center by averaging coords
val avgLng = allPoints.map { it.longitude() }.average()
val avgLat = allPoints.map { it.latitude() }.average()
com.mapbox.maps.CameraOptions.Builder().center(Point.fromLngLat(avgLng, avgLat)).zoom(10.0).build()
}
viewportState.flyTo(target)
hasCentered = true
}
MapboxMap(
modifier = modifier,
mapViewportState = viewportState,
style = { MapStyle(style = com.mapbox.maps.Style.STANDARD) },
) {
TraceroutePolylines(forwardPoints = forwardPoints, returnPoints = returnPoints)
}
}

View file

@ -21,10 +21,13 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoute.Map> { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current
// LocalMapMainScreenProvider is wired to MapScreen (Mapbox) on Android and
// to a PlaceholderScreen on Desktop until a desktop map implementation is added.
val mapScreen = LocalMapMainScreenProvider.current
mapScreen(
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails

View file

@ -43,8 +43,8 @@ compose-multiplatform-material3 = "1.11.0-alpha06"
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-alpha06"
# Google
maps-compose = "8.3.0"
# Mapbox
mapbox-maps = "11.21.1"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
@ -68,7 +68,6 @@ firebase-crashlytics-gradle = "3.0.7"
google-services-gradle = "4.4.4"
markdownRenderer = "0.40.2"
okio = "3.17.0"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
wire = "6.2.0"
vico = "3.1.0"
@ -149,6 +148,9 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.12.0" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
location-services = { module = "com.google.android.gms:play-services-location", version = "21.3.0" }
# Mapbox Maps SDK for Android + Compose extension
mapbox-maps-android = { module = "com.mapbox.maps:android", version.ref = "mapbox-maps" }
mapbox-maps-compose = { module = "com.mapbox.extension:maps-compose", version.ref = "mapbox-maps" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" }
@ -156,11 +158,7 @@ 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" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
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" }
@ -228,9 +226,6 @@ kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref =
jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
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" }

View file

@ -21,6 +21,10 @@
# Replace these with actual keys when building the app to enable datadog reporting
datadogClientToken=faketoken1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
datadogApplicationId=fakeappid1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
MAPS_API_KEY=DEFAULT_API_KEY
# Mapbox tokens — replace with real values to enable map rendering
# MAPBOX_DOWNLOADS_TOKEN is used at build time (Maven authentication)
# MAPBOX_ACCESS_TOKEN is used at runtime (map tile requests)
MAPBOX_DOWNLOADS_TOKEN=pk.placeholder_downloads_token
MAPBOX_ACCESS_TOKEN=pk.placeholder_access_token

View file

@ -79,6 +79,19 @@ dependencyResolutionManagement {
includeGroupByRegex("com\\.github\\..*")
}
}
// Mapbox Maven repository — requires a Mapbox secret token with DOWNLOADS:READ scope.
// Set MAPBOX_DOWNLOADS_TOKEN in ~/.gradle/gradle.properties or as an env var.
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
authentication { create<BasicAuthentication>("basic") }
credentials {
username = "mapbox"
password = providers.gradleProperty("MAPBOX_DOWNLOADS_TOKEN")
.orElse(providers.environmentVariable("MAPBOX_DOWNLOADS_TOKEN"))
.orElse("")
.get()
}
}
}
}