refactor: maps (#2097)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-13 12:51:19 -05:00 committed by GitHub
parent c05f434ff2
commit 87e50e03ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 4188 additions and 1830 deletions

View file

@ -23,47 +23,36 @@ import com.geeksville.mesh.android.Logging
class DataPair(val name: String, valueIn: Any?) {
val value = valueIn ?: "null"
/// An accumulating firebase event - only one allowed per event
// / An accumulating firebase event - only one allowed per event
constructor(d: Double) : this("BOGUS", d)
constructor(d: Int) : this("BOGUS", d)
}
/**
* Implement our analytics API using Firebase Analytics
*/
@Suppress("UNUSED_PARAMETER")
class NopAnalytics(context: Context) : AnalyticsProvider, Logging {
/** Implement our analytics API using Firebase Analytics */
@Suppress("UNUSED_PARAMETER", "EmptyFunctionBlock", "EmptyInitBlock")
class NopAnalytics(context: Context) :
AnalyticsProvider,
Logging {
init {
}
init {}
override fun setEnabled(on: Boolean) {
}
override fun setEnabled(on: Boolean) {}
override fun endSession() {
}
override fun endSession() {}
override fun trackLowValue(event: String, vararg properties: DataPair) {
}
override fun trackLowValue(event: String, vararg properties: DataPair) {}
override fun track(event: String, vararg properties: DataPair) {
}
override fun track(event: String, vararg properties: DataPair) {}
override fun startSession() {
}
override fun startSession() {}
override fun setUserInfo(vararg p: DataPair) {
}
override fun setUserInfo(vararg p: DataPair) {}
override fun increment(name: String, amount: Double) {
}
override fun increment(name: String, amount: Double) {}
/**
* Send a google analytics screen view event
*/
override fun sendScreenView(name: String) {
}
/** Send a google analytics screen view event */
override fun sendScreenView(name: String) {}
override fun endScreenView() {
}
override fun endScreenView() {}
}

View file

@ -41,11 +41,18 @@ open class GeeksvilleApplication :
lateinit var analytics: AnalyticsProvider
}
val isGooglePlayAvailable: Boolean
get() {
return false
}
// / Are we running inside the testlab?
val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab") ?: null
if (testLabSetting != null) info("Testlab is $testLabSetting")
if (testLabSetting != null) {
info("Testlab is $testLabSetting")
}
return "true" == testLabSetting
}

View file

@ -0,0 +1,209 @@
/*
* 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 com.geeksville.mesh.model.map
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

@ -0,0 +1,139 @@
/*
* 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 com.geeksville.mesh.model.map
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.MotionEvent
import com.geeksville.mesh.android.dpToPx
import com.geeksville.mesh.android.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

@ -0,0 +1,177 @@
/*
* 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 com.geeksville.mesh.model.map
import android.content.res.Resources
import android.util.Log
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 {
Log.i(IMapView.LOGTAG, "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
}
// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
// var srs: String? = "EPSG:900913"
// if (layer.srs.isNotEmpty()) {
// srs = layer.srs[0]
// }
// return if (layer.styles.isEmpty()) {
// WMSTileSource(
// layer.name, arrayOf(endpoint.baseurl), layer.name,
// endpoint.wmsVersion, srs, null, layer.pixelSize
// )
// } else WMSTileSource(
// layer.name, arrayOf(endpoint.baseurl), layer.name,
// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
// )
// }
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])
Log.i(IMapView.LOGTAG, 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

@ -0,0 +1,66 @@
/*
* 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 com.geeksville.mesh.model.map
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

@ -0,0 +1,216 @@
/*
* 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 com.geeksville.mesh.model.map.clustering;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
import com.geeksville.mesh.model.map.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

@ -0,0 +1,213 @@
/*
* 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 com.geeksville.mesh.model.map.clustering;
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 com.geeksville.mesh.model.map.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 neighbour = it.next();
double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition());
if (distance <= mRadiusInMeters) {
cluster.add(neighbour);
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

@ -0,0 +1,85 @@
/*
* 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 com.geeksville.mesh.model.map.clustering;
import com.geeksville.mesh.model.map.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

@ -0,0 +1,791 @@
/*
* 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 com.geeksville.mesh.ui.map
import android.Manifest // Added for Accompanist
import android.content.Context
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lens
import androidx.compose.material.icons.filled.LocationDisabled
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.android.hasGps
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.model.map.MarkerWithLabel
import com.geeksville.mesh.model.map.clustering.RadiusMarkerClusterer
import com.geeksville.mesh.ui.map.components.CacheLayout
import com.geeksville.mesh.ui.map.components.DownloadButton
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
import com.geeksville.mesh.ui.map.components.MapButton
import com.geeksville.mesh.util.SqlTileWriterExt
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addScaleBarOverlay
import com.geeksville.mesh.util.createLatLongGrid
import com.geeksville.mesh.util.formatAgo
import com.geeksville.mesh.util.zoomIn
import com.geeksville.mesh.waypoint
import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist
import com.google.accompanist.permissions.rememberMultiplePermissionsState // Added for Accompanist
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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 java.text.DateFormat
@Composable
private fun MapView.UpdateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
nodeClusterer: RadiusMarkerClusterer,
) {
debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints")
overlays.removeAll { it is MarkerWithLabel }
// overlays.addAll(nodeMarkers + waypointMarkers)
overlays.addAll(waypointMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
nodeClusterer.invalidate()
}
// private fun addWeatherLayer() {
// if (map.tileProvider.tileSource.name()
// .equals(CustomTileSource.getTileSource("ESRI World TOPO").name())
// ) {
// val layer = TilesOverlay(
// MapTileProviderBasic(
// activity,
// CustomTileSource.OPENWEATHER_RADAR
// ), context
// )
// layer.loadingBackgroundColor = Color.TRANSPARENT
// layer.loadingLineColor = Color.TRANSPARENT
// map.overlayManager.add(layer)
// }
// }
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
}
}
private fun Context.purgeTileSource(onResult: (String) -> Unit) {
val cache = SqlTileWriterExt()
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.map_tile_source)
val sources = cache.sources
val sourceList = mutableListOf<String>()
for (i in sources.indices) {
sourceList.add(sources[i].source as String)
}
val selected: BooleanArray? = null
val selectedList = mutableListOf<Int>()
builder.setMultiChoiceItems(sourceList.toTypedArray(), selected) { _, i, b ->
if (b) {
selectedList.add(i)
} else {
selectedList.remove(i)
}
}
builder.setPositiveButton(R.string.clear) { _, _ ->
for (x in selectedList) {
val item = sources[x]
val b = cache.purgeCache(item.source)
onResult(
if (b) {
getString(R.string.map_purge_success, item.source)
} else {
getString(R.string.map_purge_fail)
},
)
}
}
builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
builder.show()
}
/**
* 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 model The [UIViewModel] 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(
uiViewModel: UIViewModel = viewModel(),
mapViewModel: MapViewModel = viewModel(),
navigateToNodeDetails: (Int) -> Unit,
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val isConnected by uiViewModel.isConnectedStateFlow.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 showCurrentCacheInfo by remember { mutableStateOf(false) }
val context = LocalContext.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val hasGps = remember { context.hasGps() }
// 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
debug("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(initialCameraView, loadOnlineTileSourceBase())
val nodeClusterer = remember { RadiusMarkerClusterer(context) }
fun MapView.toggleMyLocation() {
if (context.gpsDisabled()) {
debug("Telling user we need location turned on for MyLocationNewOverlay")
uiViewModel.showSnackBar(R.string.location_disabled)
return
}
debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}")
if (myLocationOverlay == null) {
myLocationOverlay =
MyLocationNewOverlay(this).apply {
enableMyLocation()
enableFollowLocation()
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let {
setPersonIcon(it)
setPersonAnchor(0.5f, 0.5f)
}
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.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
}
}
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = uiViewModel.ourNodeInfo.value
val displayUnits = uiViewModel.config.display.units
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
val (p, u) = node.position to node.user
val nodePosition = GeoPoint(node.latitude, node.longitude)
MarkerWithLabel(mapView = this, label = "${u.shortName} ${formatAgo(p.time)}").apply {
id = u.id
title = u.longName
snippet =
context.getString(
R.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 ->
subDescription = context.getString(R.string.map_subDescription, ourNode.bearing(node), dist)
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
icon = markerIcon
setNodeColors(node.colors)
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
setPrecisionBits(p.precisionBits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
true
}
}
}
}
fun showDeleteMarkerDialog(waypoint: Waypoint) {
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(R.string.waypoint_delete)
builder.setNeutralButton(R.string.cancel) { _, _ -> debug("User canceled marker delete dialog") }
builder.setNegativeButton(R.string.delete_for_me) { _, _ ->
debug("User deleted waypoint ${waypoint.id} for me")
uiViewModel.deleteWaypoint(waypoint.id)
}
if (waypoint.lockedTo in setOf(0, uiViewModel.myNodeNum ?: 0) && isConnected) {
builder.setPositiveButton(R.string.delete_for_everyone) { _, _ ->
debug("User deleted waypoint ${waypoint.id} for everyone")
uiViewModel.sendWaypoint(waypoint.copy { expire = 1 })
uiViewModel.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()
debug("marker long pressed id=$id")
val waypoint = waypoints[id]?.data?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
if (waypoint.lockedTo in setOf(0, uiViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) {
context.getString(R.string.you)
} else {
uiViewModel.getUser(id).longName
}
@Composable
@Suppress("MagicNumber")
fun MapView.onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.data.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else ""
val time = dateFormat.format(waypoint.received_time)
val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt())
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val timeLeft = pt.expire * 1000L - System.currentTimeMillis()
val expireTimeStr =
when {
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
timeLeft <= 0 -> "Expired"
timeLeft < 60_000 -> "${timeLeft / 1000} seconds"
timeLeft < 3_600_000 -> "${timeLeft / 60_000} minute${if (timeLeft / 60_000 != 1L) "s" else ""}"
timeLeft < 86_400_000 -> {
val hours = (timeLeft / 3_600_000).toInt()
val minutes = ((timeLeft % 3_600_000) / 60_000).toInt()
if (minutes >= 30) {
"${hours + 1} hour${if (hours + 1 != 1) "s" else ""}"
} else if (minutes > 0) {
"$hours hour${if (hours != 1) "s" else ""}, $minutes minute${if (minutes != 1) "s" else ""}"
} else {
"$hours hour${if (hours != 1) "s" else ""}"
}
}
else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}"
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
snippet = "[$time] ${pt.description} " + stringResource(R.string.expires) + ": $expireTimeStr"
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
setVisible(false) // This seems to be always false, was this intended?
setOnLongClickListener {
showMarkerLongPressDialog(pt.id)
true
}
}
}
}
LaunchedEffect(showCurrentCacheInfo) {
if (!showCurrentCacheInfo) return@LaunchedEffect
uiViewModel.showSnackBar(R.string.calculating)
val cacheManager = CacheManager(map)
val cacheCapacity = cacheManager.cacheCapacity()
val currentCacheUsage = cacheManager.currentCacheUsage()
val mapCacheInfoText =
context.getString(
R.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.map_cache_manager)
.setMessage(mapCacheInfoText)
.setPositiveButton(R.string.close) { dialog, _ ->
showCurrentCacheInfo = false
dialog.dismiss()
}
.show()
}
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 {
latitudeI = (p.latitude * 1e7).toInt()
longitudeI = (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()
}
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
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 = context.getString(R.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 = {
uiViewModel.showSnackBar(R.string.map_download_complete)
writer.onDetach()
},
onTaskFailed = { errors ->
uiViewModel.showSnackBar(context.getString(R.string.map_download_errors, errors))
writer.onDetach()
},
),
)
} catch (ex: TileSourcePolicyException) {
debug("Tile source does not allow archiving: ${ex.message}")
} catch (ex: Exception) {
debug("Tile source exception: ${ex.message}")
}
}
fun showMapStyleDialog() {
val builder = MaterialAlertDialogBuilder(context)
val mapStyles: Array<CharSequence> = CustomTileSource.mTileSources.values.toTypedArray()
val mapStyleInt = mapViewModel.mapStyleId
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
debug("Set mapStyleId pref to $which")
mapViewModel.mapStyleId = which
dialog.dismiss()
map.setTileSource(loadOnlineTileSourceBase())
}
val dialog = builder.create()
dialog.show()
}
fun Context.showCacheManagerDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.map_offline_manager)
.setItems(
arrayOf<CharSequence>(
getString(R.string.map_cache_size),
getString(R.string.map_download_region),
getString(R.string.map_clear_tiles),
getString(R.string.cancel),
),
) { dialog, which ->
when (which) {
0 -> showCurrentCacheInfo = true
1 -> {
map.generateBoxOverlay()
dialog.dismiss()
}
2 -> purgeTileSource { uiViewModel.showSnackBar(it) }
else -> dialog.dismiss()
}
}
.show()
}
Scaffold(
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { context.showCacheManagerDialog() }
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AndroidView(
factory = {
map.apply {
setDestroyMode(false)
addMapListener(boxOverlayListener)
}
},
modifier = Modifier.fillMaxSize(),
update = { mapView -> 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 {
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MapButton(
onClick = ::showMapStyleDialog,
icon = Icons.Outlined.Layers,
contentDescription = R.string.map_style_selection,
)
Box(modifier = Modifier) {
MapButton(
onClick = { mapFilterExpanded = true },
icon = Icons.Outlined.Tune,
contentDescription = R.string.map_filter,
)
DropdownMenu(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.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 = Icons.Default.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.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 = Icons.Default.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
}
}
if (hasGps) {
MapButton(
icon =
if (myLocationOverlay == null) {
Icons.Outlined.MyLocation
} else {
Icons.Default.LocationDisabled
},
contentDescription = stringResource(R.string.toggle_my_position),
) {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
}
}
}
}
}
}
if (showEditWaypointDialog != null) {
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
onSendClicked = { waypoint ->
debug("User clicked send waypoint ${waypoint.id}")
showEditWaypointDialog = null
uiViewModel.sendWaypoint(
waypoint.copy {
if (id == 0) id = uiViewModel.generatePacketId() ?: return@EditWaypointDialog
if (name == "") name = "Dropped Pin"
if (expire == 0) expire = Int.MAX_VALUE
lockedTo = if (waypoint.lockedTo != 0) uiViewModel.myNodeNum ?: 0 else 0
if (waypoint.icon == 0) icon = 128205
},
)
},
onDeleteClicked = { waypoint ->
debug("User clicked delete waypoint ${waypoint.id}")
showEditWaypointDialog = null
showDeleteMarkerDialog(waypoint)
},
onDismissRequest = {
debug("User clicked cancel marker edit dialog")
showEditWaypointDialog = null
},
)
}
}

View file

@ -0,0 +1,39 @@
/*
* 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 com.geeksville.mesh.ui.map
import android.content.SharedPreferences
import androidx.core.content.edit
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class MapViewModel
@Inject
constructor(
preferences: SharedPreferences,
packetRepository: PacketRepository,
nodeRepository: NodeRepository,
) : BaseMapViewModel(preferences, nodeRepository, packetRepository) {
var mapStyleId: Int
get() = preferences.getInt(MAP_STYLE_ID, 0)
set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
}

View file

@ -0,0 +1,171 @@
/*
* 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 com.geeksville.mesh.ui.map
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
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 com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.android.BuildUtils.errormsg
import com.geeksville.mesh.util.requiredZoomLevel
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
@SuppressLint("WakelockTimeout")
private fun PowerManager.WakeLock.safeAcquire() {
if (!isHeld) {
try {
acquire()
} catch (e: SecurityException) {
errormsg("WakeLock permission exception: ${e.message}")
} catch (e: IllegalStateException) {
errormsg("WakeLock acquire() exception: ${e.message}")
}
}
}
private fun PowerManager.WakeLock.safeRelease() {
if (isHeld) {
try {
release()
} catch (e: IllegalStateException) {
errormsg("WakeLock release() exception: ${e.message}")
}
}
}
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
internal fun rememberMapViewWithLifecycle(
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(zoom, center, tileSource)
}
@Suppress("LongMethod")
@Composable
internal fun rememberMapViewWithLifecycle(
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 = BuildConfig.APPLICATION_ID
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 powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
wakeLock.safeAcquire()
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
wakeLock.safeAcquire()
mapView.onResume()
}
Lifecycle.Event.ON_STOP -> {
savedCenter = mapView.projection.currentCenter
savedZoom = mapView.zoomLevelDouble
}
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
wakeLock.safeRelease()
}
}
return mapView
}

View file

@ -0,0 +1,94 @@
/*
* 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 com.geeksville.mesh.ui.map.components
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal 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(id = R.string.map_select_download_region),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.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(id = R.string.cancel), color = MaterialTheme.colorScheme.onPrimary)
}
Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) {
Text(
text = stringResource(id = R.string.map_start_download),
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun CacheLayoutPreview() {
CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {})
}

View file

@ -0,0 +1,65 @@
/*
* 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 com.geeksville.mesh.ui.map.components
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.material.icons.Icons
import androidx.compose.material.icons.filled.Download
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 androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
@Composable
internal 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 = Icons.Default.Download,
contentDescription = stringResource(R.string.map_download_region),
modifier = Modifier.scale(1.25f),
)
}
}
}
// @Preview(showBackground = true)
// @Composable
// private fun DownloadButtonPreview() {
// DownloadButton(true, onClick = {})
// }

View file

@ -0,0 +1,339 @@
/*
* 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 com.geeksville.mesh.ui.map.components
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.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock
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.res.stringResource
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 com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.common.components.EditTextPreference
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.waypoint
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal 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) R.string.waypoint_new else R.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 calendar = Calendar.getInstance()
val currentTime = System.currentTimeMillis()
calendar.timeInMillis = currentTime
@Suppress("MagicNumber")
calendar.add(Calendar.HOUR_OF_DAY, 8)
// Current time for initializing pickers
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH)
val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
// Determine locale-specific date format
val locale = Locale.getDefault()
val dateFormat =
if (locale.country == "US") {
SimpleDateFormat("MM/dd/yyyy", locale)
} else {
SimpleDateFormat("dd/MM/yyyy", locale)
}
// Check if 24-hour format is preferred
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
val timeFormat =
if (is24Hour) {
SimpleDateFormat("HH:mm", locale)
} else {
SimpleDateFormat("hh:mm a", locale)
}
// State to hold selected date and time
var selectedDate by remember { mutableStateOf(dateFormat.format(calendar.time)) }
var selectedTime by remember { mutableStateOf(timeFormat.format(calendar.time)) }
var epochTime by remember { mutableStateOf<Long?>(null) }
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(R.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(R.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 = Icons.Default.Lock, contentDescription = stringResource(R.string.locked))
Text(stringResource(R.string.locked))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.lockedTo != 0,
onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
)
}
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
selectedDate = "$selectedDay/${selectedMonth + 1}/$selectedYear"
calendar.set(selectedYear, selectedMonth, selectedDay)
epochTime = calendar.timeInMillis
if (epochTime != null) {
selectedDate = dateFormat.format(calendar.time)
}
},
year,
month,
day,
)
val timePickerDialog =
android.app.TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
selectedTime =
String.format(Locale.getDefault(), "%02d:%02d", selectedHour, selectedMinute)
calendar.set(Calendar.HOUR_OF_DAY, selectedHour)
calendar.set(Calendar.MINUTE, selectedMinute)
epochTime = calendar.timeInMillis
selectedTime = timeFormat.format(calendar.time)
@Suppress("MagicNumber")
waypointInput = waypointInput.copy { expire = (epochTime!! / 1000).toInt() }
},
hour,
minute,
is24Hour,
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = Icons.Default.CalendarMonth,
contentDescription = stringResource(R.string.expires),
)
Text(stringResource(R.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
waypointInput =
waypointInput.copy {
expire =
if (isChecked) {
@Suppress("MagicNumber")
calendar.timeInMillis / 1000
} else {
Int.MAX_VALUE
}
.toInt()
}
if (isChecked) {
selectedDate = dateFormat.format(calendar.time)
selectedTime = timeFormat.format(calendar.time)
} else {
selectedDate = ""
selectedTime = ""
}
},
)
}
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(R.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(R.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(R.string.cancel))
}
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = waypointInput.name.isNotEmpty(),
) {
Text(stringResource(R.string.delete))
}
}
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
Text(stringResource(R.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 = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt()
},
onSendClicked = {},
onDeleteClicked = {},
onDismissRequest = {},
)
}
}

View file

@ -0,0 +1,61 @@
/*
* 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 com.geeksville.mesh.ui.map.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun MapButton(
icon: ImageVector,
@StringRes contentDescription: Int,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MapButton(
icon = icon,
contentDescription = stringResource(contentDescription),
modifier = modifier,
onClick = onClick,
)
}
@Composable
fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
FloatingActionButton(onClick = onClick, modifier = modifier) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = R.string.map_style_selection) }
}

View file

@ -0,0 +1,64 @@
/*
* 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 com.geeksville.mesh.ui.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addPolyline
import com.geeksville.mesh.util.addPositionMarkers
import com.geeksville.mesh.util.addScaleBarOverlay
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
@Suppress("UNUSED_PARAMETER") uiViewModel: UIViewModel = hiltViewModel(),
viewModel: MetricsViewModel = hiltViewModel(),
) {
val density = LocalDensity.current
val state by viewModel.state.collectAsStateWithLifecycle()
val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(state.positionLogs) {}
},
)
}

View file

@ -0,0 +1,71 @@
/*
* 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 com.geeksville.mesh.util
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,
)
}

View file

@ -0,0 +1,146 @@
/*
* 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 com.geeksville.mesh.util
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 com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
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<MeshProtos.Position>, onClick: () -> Unit): List<Marker> {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24)
val markers =
positions.map {
Marker(this).apply {
icon = navIcon
rotation = (it.groundTrack * 1e-5).toFloat()
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7)
setOnMarkerClickListener { _, _ ->
onClick()
true
}
}
}
overlays.addAll(markers)
return markers
}

View file

@ -0,0 +1,111 @@
/*
* 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 com.geeksville.mesh.util
import android.database.Cursor
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(System.currentTimeMillis().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
}
}