diff --git a/androidApp/src/fdroid/AndroidManifest.xml b/androidApp/src/fdroid/AndroidManifest.xml index efd581020e..adb8c226e3 100644 --- a/androidApp/src/fdroid/AndroidManifest.xml +++ b/androidApp/src/fdroid/AndroidManifest.xml @@ -28,4 +28,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt new file mode 100644 index 0000000000..503e42bf35 --- /dev/null +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map + +import android.graphics.Color +import androidx.core.graphics.toColorInt +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.meshtastic.app.map.cluster.RadiusMarkerClusterer +import org.osmdroid.bonuspack.kml.KmlDocument +import org.osmdroid.bonuspack.kml.KmlFeature +import org.osmdroid.bonuspack.kml.KmlLineString +import org.osmdroid.bonuspack.kml.KmlPlacemark +import org.osmdroid.bonuspack.kml.KmlPoint +import org.osmdroid.bonuspack.kml.KmlPolygon +import org.osmdroid.bonuspack.kml.KmlTrack +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Overlay +import org.osmdroid.views.overlay.Polygon +import org.osmdroid.views.overlay.Polyline +import java.io.InputStream +import kotlin.math.roundToInt + +private const val TAG = "MapOverlayRenderer" + +// simplestyle-spec fallbacks, mirroring the Google flavor's applySimpleStyleSpec(); tune here. +private const val DEFAULT_GEOJSON_FILL_OPACITY = 0.35f +private const val DEFAULT_GEOJSON_STROKE_WIDTH = 2f +private const val OPAQUE = 255 + +/** + * F-Droid flavor's map-overlay renderer: turns the shared [MapLayerItem] list into OSMdroid overlays via osmbonuspack's + * [KmlDocument], honoring per-feature mapbox **simplestyle** (`fill`/`stroke`/`fill-opacity`/`stroke-width`) so + * imported coverage draws in its dBm colors — the OSMdroid mirror of the Google flavor's + * `GeoJsonLayer.applySimpleStyleSpec()`. + * + * A single instance is kept for the lifetime of the map composable. [reconcile] is called whenever the layer list + * changes; it adds newly-visible layers, removes gone/hidden ones, and rebuilds any whose URI or refresh flag changed. + */ +class FdroidMapOverlayRenderer { + + private data class Rendered(val signature: String, val overlay: Overlay) + + // id -> currently-drawn overlay. Touched only from reconcile()'s (single-flight) coroutine. + private val rendered = mutableMapOf() + + private fun signatureOf(item: MapLayerItem) = "${item.uri}|${item.refreshToken}" + + /** Reconcile the map's overlays with [layers]. [openStream] resolves a layer to its data (file or network). */ + suspend fun reconcile( + map: MapView, + layers: List, + openStream: suspend (MapLayerItem) -> InputStream?, + ) { + val visible = layers.filter { it.isVisible && it.uri != null } + val wanted = visible.associateBy { it.id } + var dirty = false + + // Drop overlays that are gone, hidden, or whose signature changed (rebuild). + val stale = rendered.filter { (id, r) -> wanted[id]?.let { signatureOf(it) == r.signature } != true } + if (stale.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { stale.values.forEach { map.overlays.remove(it.overlay) } } + stale.keys.forEach { rendered.remove(it) } + dirty = true + } + + // Build overlays for visible layers not already drawn. + for (layer in visible) { + if (rendered.containsKey(layer.id)) continue + val doc = parse(layer, openStream) ?: continue + val overlay = + withContext(Dispatchers.Main.immediate) { + // Build on the main thread: overlay markers reference the MapView (info windows, defaults). + doc.mKmlRoot.buildOverlay(map, null, SimpleStyleStyler, doc).also { insertBelowMarkers(map, it) } + } + rendered[layer.id] = Rendered(signatureOf(layer), overlay) + dirty = true + } + + if (dirty) withContext(Dispatchers.Main.immediate) { map.invalidate() } + } + + /** + * Remove every layer overlay. Call on the main thread (e.g. from onDispose); the OSMdroid map outlives composition. + */ + fun removeAll(map: MapView) { + if (rendered.isEmpty()) return + rendered.values.forEach { map.overlays.remove(it.overlay) } + rendered.clear() + map.invalidate() + } + + private suspend fun parse(layer: MapLayerItem, openStream: suspend (MapLayerItem) -> InputStream?): KmlDocument? { + val stream = openStream(layer) ?: return null + return withContext(Dispatchers.IO) { + try { + val doc = KmlDocument() + val ok = + stream.use { input -> + when (layer.layerType) { + LayerType.GEOJSON -> doc.parseGeoJSON(input.bufferedReader().readText()) + LayerType.KML -> doc.parseKMLStream(input, null) + } + } + if (ok) doc else null + } catch (e: Exception) { + Logger.withTag(TAG).e(e) { "Error parsing map layer: ${layer.name}" } + null + } + } + } + + // Keep coverage under the node markers/clusterer so nodes stay visible + tappable (matching the Google flavor). + private fun insertBelowMarkers(map: MapView, overlay: Overlay) { + val idx = map.overlays.indexOfFirst { it is RadiusMarkerClusterer || it is Marker } + if (idx >= 0) map.overlays.add(idx, overlay) else map.overlays.add(overlay) + } +} + +/** + * osmbonuspack styler that maps mapbox simplestyle properties (read from a GeoJSON feature's `properties`, which + * osmbonuspack stores as KML ExtendedData) onto the built osmdroid geometry. Only overrides when a property is present, + * so KML files keep their own `