diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index c5a8d751ed..b1f22cfe6d 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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 @@ -91,17 +92,25 @@ 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.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext 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 @@ -159,11 +168,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 --- @@ -1297,110 +1310,167 @@ private fun offsetPolyline( @OptIn(MapsComposeExperimentalApi::class) @Composable private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { - val context = LocalContext.current - var currentLayer by remember { mutableStateOf(null) } + var currentLayer by remember { mutableStateOf(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 = + withContext(Dispatchers.IO) { + 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) } } 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 + @Suppress("UNCHECKED_CAST") + get() = dataLayer.properties["images"] as? Map ?: 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`/`stroke-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 fillOpacity = stringProperty("fill-opacity")?.toFloatOrNull() + val strokeOpacity = stringProperty("stroke-opacity")?.toFloatOrNull() + val stroke = + (cssColor("stroke") ?: cssColor("color"))?.let { + if (strokeOpacity != null) it.withAlpha(strokeOpacity) else it + } + 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)) - 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