From 2a8f923d107db2a369c6418db0fb3f2fa9ee652c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:07:40 -0500 Subject: [PATCH] =?UTF-8?q?feat(map):=20F-Droid=20map-layer=20parity=20?= =?UTF-8?q?=E2=80=94=20share=20layer=20UI=20+=20logic=20in=20common=20sour?= =?UTF-8?q?ce=20(#6138)=20(#6148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- androidApp/src/fdroid/AndroidManifest.xml | 32 ++ .../app/map/FdroidMapOverlayRenderer.kt | 198 +++++++++++ .../meshtastic/app/map/GetMapViewProvider.kt | 4 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 109 +++++- .../org/meshtastic/app/map/MapViewModel.kt | 39 +++ .../kotlin/org/meshtastic/app/map/MapView.kt | 69 +--- .../org/meshtastic/app/map/MapViewModel.kt | 292 +--------------- .../app/map/prefs/map/GoogleMapsPrefs.kt | 29 -- .../kotlin/org/meshtastic/app/map/MapLayer.kt | 94 ++++++ .../meshtastic/app/map/MapLayersManager.kt | 311 ++++++++++++++++++ .../app/map/SitePlannerParamsFactory.kt | 68 ++++ .../meshtastic/app/map/SitePlannerRunner.kt | 5 +- .../app/map/component/CustomMapLayersSheet.kt | 6 +- .../app/map/MapLayerResolutionTest.kt | 47 +++ .../meshtastic/core/prefs/map/MapPrefsImpl.kt | 36 ++ .../core/repository/AppPreferences.kt | 18 + .../core/testing/FakeAppPreferences.kt | 16 + 17 files changed, 992 insertions(+), 381 deletions(-) create mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt create mode 100644 androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayer.kt create mode 100644 androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayersManager.kt create mode 100644 androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerParamsFactory.kt rename androidApp/src/{google => main}/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt (98%) rename androidApp/src/{google => main}/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt (96%) create mode 100644 androidApp/src/test/kotlin/org/meshtastic/app/map/MapLayerResolutionTest.kt diff --git a/androidApp/src/fdroid/AndroidManifest.xml b/androidApp/src/fdroid/AndroidManifest.xml index efd581020..adb8c226e 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 000000000..503e42bf3 --- /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 `