Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 127 additions & 66 deletions androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.location.Location
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
Expand Down Expand Up @@ -91,17 +92,23 @@ import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
import com.google.maps.android.data.Layer
import com.google.maps.android.data.geojson.GeoJsonFeature
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.geojson.GeoJsonLineStringStyle
import com.google.maps.android.data.geojson.GeoJsonPolygonStyle
import com.google.maps.android.data.kml.KmlLayer
import com.google.maps.android.data.parser.geojson.GeoJsonParser
import com.google.maps.android.data.parser.kml.KmlParser
import com.google.maps.android.data.parser.kml.KmzParser
import com.google.maps.android.data.renderer.UrlIconProvider
import com.google.maps.android.data.renderer.mapper.toLayer
import com.google.maps.android.data.renderer.mapview.MapViewRenderer
import com.google.maps.android.data.renderer.model.DataLayer
import com.google.maps.android.data.renderer.model.Feature
import com.google.maps.android.data.renderer.model.Geometry
import com.google.maps.android.data.renderer.model.LineString
import com.google.maps.android.data.renderer.model.LineStyle
import com.google.maps.android.data.renderer.model.MultiGeometry
import com.google.maps.android.data.renderer.model.PolygonStyle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.component.ClusterItemsListDialog
import org.meshtastic.app.map.component.CustomMapLayersSheet
Expand Down Expand Up @@ -159,11 +166,15 @@ import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import java.io.BufferedInputStream
import java.io.InputStream
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import android.graphics.Color as AndroidColor
import com.google.android.gms.maps.GoogleMap as GmsGoogleMap
import com.google.maps.android.data.renderer.model.Polygon as ModelPolygon

// region --- Map Mode ---

Expand Down Expand Up @@ -1297,110 +1308,160 @@ private fun offsetPolyline(
@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
val context = LocalContext.current
var currentLayer by remember { mutableStateOf<Layer?>(null) }
var currentLayer by remember { mutableStateOf<RenderedMapLayer?>(null) }

MapEffect(layerItem.id, layerItem.refreshToken) { map ->
currentLayer?.safeRemoveLayerFromMap()
currentLayer?.safeHide()
currentLayer = null
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
when (layerItem.layerType) {
// KmlLayer parses the stream in its constructor but doesn't close it — close it ourselves.
LayerType.KML -> inputStream.use { KmlLayer(map, it, context) }

LayerType.GEOJSON ->
GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })).also {
it.applySimpleStyleSpec()
}
val dataLayer = inputStream.use { stream -> parseMapLayer(layerItem.layerType, stream) }
if (dataLayer == null) {
Logger.withTag("MapView").e { "Error loading map layer: ${layerItem.name} (unrecognized format)" }
null
} else {
RenderedMapLayer(map, dataLayer)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
null
}
layer?.let {
if (layerItem.isVisible) it.safeAddLayerToMap()
if (layerItem.isVisible) it.safeShow()
currentLayer = it
}
}

DisposableEffect(layerItem.id) {
onDispose {
currentLayer?.safeRemoveLayerFromMap()
currentLayer?.safeHide()
currentLayer = null
}
}

LaunchedEffect(layerItem.isVisible) {
val layer = currentLayer ?: return@LaunchedEffect
if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap()
if (layerItem.isVisible) layer.safeShow() else layer.safeHide()
}
}

/** Zip magic bytes; a [LayerType.KML] source starting with these is a KMZ archive rather than bare KML. */
private val KMZ_MAGIC = byteArrayOf('P'.code.toByte(), 'K'.code.toByte())

/**
* Parse a custom overlay into the maps-utils platform-agnostic [DataLayer] model; null if the format is unrecognized.
*/
private fun parseMapLayer(layerType: LayerType, stream: InputStream): DataLayer? = when (layerType) {
LayerType.KML -> {
val buffered = BufferedInputStream(stream)
buffered.mark(KMZ_MAGIC.size)
val magic = ByteArray(KMZ_MAGIC.size)
val read = buffered.read(magic)
buffered.reset()
val kml =
if (read == KMZ_MAGIC.size && magic.contentEquals(KMZ_MAGIC)) {
KmzParser().parse(buffered)
} else {
KmlParser().parse(buffered)
}
kml.toLayer()
}

LayerType.GEOJSON -> GeoJsonParser().parse(stream)?.toLayer()?.applySimpleStyleSpec()
}

/**
* A parsed custom overlay (KML/KMZ/GeoJSON) plus the machinery to draw it on the map.
*
* Each [show] creates a fresh single-use [MapViewRenderer] and [hide] tears it down with [MapViewRenderer.clear].
* Renderers are deliberately not reused: clear() cancels their icon-loading scope for good, and per-feature removal
* ([MapViewRenderer.removeFeature]) silently no-ops for MultiGeometry features (the renderer keys rendered map objects
* by internal per-geometry copies), so clear() is the only teardown that reliably takes everything off the map.
*/
private class RenderedMapLayer(private val map: GmsGoogleMap, private val dataLayer: DataLayer) {
private var renderer: MapViewRenderer? = null

/** Images extracted from a KMZ archive, keyed by archive path; the KML mapper stashes them in layer properties. */
private val localImages: Map<String, Bitmap>
@Suppress("UNCHECKED_CAST")
get() = dataLayer.properties["images"] as? Map<String, Bitmap> ?: emptyMap()

fun show() {
if (renderer != null) return
renderer =
MapViewRenderer(map, UrlIconProvider()).also { r ->
localImages.forEach { (path, bitmap) -> r.cacheImageData(path, bitmap) }
r.addLayer(dataLayer)
}
}

fun hide() {
renderer?.clear()
renderer = null
}
}

private fun Layer.safeRemoveLayerFromMap() {
private fun RenderedMapLayer.safeShow() {
try {
removeLayerFromMap()
show()
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error removing map layer" }
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
}

private fun Layer.safeAddLayerToMap() {
private fun RenderedMapLayer.safeHide() {
try {
// maps-utils 5.0.0 dropped isLayerOnMap() from the Layer base class; both concrete layers still expose it.
val isOnMap =
when (this) {
is GeoJsonLayer -> isLayerOnMap()
is KmlLayer -> isLayerOnMap()
else -> false
}
if (!isOnMap) addLayerToMap()
hide()
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
Logger.withTag("MapView").e(e) { "Error removing map layer" }
}
}

/**
* Apply simplestyle-spec (https://github.com/mapbox/simplestyle-spec) properties to a GeoJSON layer.
* Apply simplestyle-spec (https://github.com/mapbox/simplestyle-spec) properties to a parsed GeoJSON layer.
*
* Google's [GeoJsonLayer] otherwise applies one default style to every feature, so exports that carry per-feature
* colors render unstyled. In particular, Meshtastic Site Planner coverage contours set `fill`/`stroke` (plus a legacy
* `color`) and `fill-opacity`; read those and style each polygon/line so the coverage draws in its dBm colors instead
* of the default black outline.
* The maps-utils GeoJSON mapper reads `fill`/`stroke`/`stroke-width`/`fill-opacity` itself, but misses several things
* this app relies on for Meshtastic Site Planner coverage exports: the legacy `color` fallback, `rgb()`/`rgba()`
* colors, a default fill opacity so stacked contour bands read as a gradient, and polygon styling for MultiPolygon
* features (the mapper styles any multi-geometry as a line). Rebuild each feature's style from its properties so the
* coverage draws in its dBm colors instead of the default black outline.
*/
private fun GeoJsonLayer.applySimpleStyleSpec() {
for (feature in features) {
val fill = feature.cssColor("fill") ?: feature.cssColor("color")
val stroke = feature.cssColor("stroke") ?: feature.cssColor("color")
val fillOpacity = feature.getProperty("fill-opacity")?.toFloatOrNull()
val strokeWidth = feature.getProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH
when (feature.getGeometry()?.getGeometryType()) {
"Polygon",
"MultiPolygon",
->
feature.polygonStyle =
GeoJsonPolygonStyle().apply {
fill?.let { fillColor = it.resolveFillAlpha(fillOpacity) }
stroke?.let { setStrokeColor(it) }
setStrokeWidth(strokeWidth)
}
private fun DataLayer.applySimpleStyleSpec(): DataLayer = copy(features = features.map { it.applySimpleStyleSpec() })

private fun Feature.applySimpleStyleSpec(): Feature {
val fill = cssColor("fill") ?: cssColor("color")
val stroke = cssColor("stroke") ?: cssColor("color")
val fillOpacity = stringProperty("fill-opacity")?.toFloatOrNull()
val strokeWidth = stringProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH
return when {
geometry.isPolygonal() ->
copy(
style =
PolygonStyle(
fillColor = fill?.resolveFillAlpha(fillOpacity) ?: AndroidColor.TRANSPARENT,
strokeColor = stroke ?: AndroidColor.BLACK,
strokeWidth = strokeWidth,
),
)

"LineString",
"MultiLineString",
->
feature.lineStringStyle =
GeoJsonLineStringStyle().apply {
stroke?.let { color = it }
setWidth(strokeWidth)
}
geometry.isLinear() -> copy(style = LineStyle(color = stroke ?: AndroidColor.BLACK, width = strokeWidth))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

else -> Unit // Points keep the default marker.
}
else -> this // Points keep the default marker; mixed geometry collections keep the mapper's style.
}
}

private fun GeoJsonFeature.cssColor(key: String): Int? = getProperty(key)?.let { parseCssColor(it) }
/** Polygon or MultiPolygon (a multi-geometry whose members are all polygons). */
private fun Geometry.isPolygonal(): Boolean =
this is ModelPolygon || (this is MultiGeometry && geometries.isNotEmpty() && geometries.all { it is ModelPolygon })

/** LineString or MultiLineString (a multi-geometry whose members are all line strings). */
private fun Geometry.isLinear(): Boolean =
this is LineString || (this is MultiGeometry && geometries.isNotEmpty() && geometries.all { it is LineString })

private fun Feature.stringProperty(key: String): String? = properties[key] as? String

private fun Feature.cssColor(key: String): Int? = stringProperty(key)?.let { parseCssColor(it) }

/**
* Resolve a polygon fill's alpha: `fill-opacity` wins when present; otherwise keep any alpha the color already carries
Expand Down
Loading