feat(map): F-Droid map-layer parity — share layer UI + logic in common source (#6138) (#6148)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-07 22:07:40 -05:00
committed by GitHub
parent d30aa95a25
commit 2a8f923d10
17 changed files with 992 additions and 381 deletions

View File

@@ -28,4 +28,36 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application>
<!--
Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
Planner's "Send to App" share). The F-Droid map renders these as OSMdroid overlays (see #6138). Merged into
the MainActivity declared in the base manifest. application/json is broad, but the map importer validates the
file extension and ignores anything that isn't a supported layer.
-->
<activity
android:name="org.meshtastic.app.MainActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/geo+json" />
<data android:mimeType="application/vnd.geo+json" />
<data android:mimeType="application/json" />
<data android:mimeType="application/vnd.google-earth.kml+xml" />
<data android:mimeType="application/vnd.google-earth.kmz" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/geo+json" />
<data android:mimeType="application/vnd.geo+json" />
<data android:mimeType="application/json" />
<data android:mimeType="application/vnd.google-earth.kml+xml" />
<data android:mimeType="application/vnd.google-earth.kmz" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, Rendered>()
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<MapLayerItem>,
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 `<Style>`.
*/
private object SimpleStyleStyler : KmlFeature.Styler {
override fun onFeature(overlay: Overlay?, kmlFeature: KmlFeature?) = Unit
override fun onPoint(marker: Marker?, kmlPlacemark: KmlPlacemark?, kmlPoint: KmlPoint?) = Unit
override fun onLineString(polyline: Polyline?, kmlPlacemark: KmlPlacemark?, kmlLineString: KmlLineString?) {
polyline ?: return
val stroke = kmlPlacemark?.cssColor("stroke") ?: kmlPlacemark?.cssColor("color")
stroke?.let { polyline.color = it }
kmlPlacemark?.getExtendedData("stroke-width")?.toFloatOrNull()?.let { polyline.width = it }
}
override fun onPolygon(polygon: Polygon?, kmlPlacemark: KmlPlacemark?, kmlPolygon: KmlPolygon?) {
polygon ?: return
val fill = kmlPlacemark?.cssColor("fill") ?: kmlPlacemark?.cssColor("color")
val stroke = kmlPlacemark?.cssColor("stroke") ?: kmlPlacemark?.cssColor("color")
val fillOpacity = kmlPlacemark?.getExtendedData("fill-opacity")?.toFloatOrNull()
val strokeWidth = kmlPlacemark?.getExtendedData("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH
fill?.let { polygon.fillColor = it.resolveFillAlpha(fillOpacity) }
stroke?.let { polygon.strokeColor = it }
polygon.strokeWidth = strokeWidth
}
override fun onTrack(polyline: Polyline?, kmlPlacemark: KmlPlacemark?, kmlTrack: KmlTrack?) = Unit
}
private fun KmlPlacemark.cssColor(key: String): Int? = getExtendedData(key)?.let { parseCssColor(it) }
/**
* Resolve a polygon fill's alpha: `fill-opacity` wins when present; otherwise keep any alpha the color already carries
* (`rgba()`/`#AARRGGBB`), falling back to [DEFAULT_GEOJSON_FILL_OPACITY] for opaque fills.
*/
private fun Int.resolveFillAlpha(fillOpacity: Float?): Int = when {
fillOpacity != null -> withAlpha(fillOpacity)
Color.alpha(this) < OPAQUE -> this
else -> withAlpha(DEFAULT_GEOJSON_FILL_OPACITY)
}
/** Parse a hex (`#RRGGBB`/`#AARRGGBB`), `rgb()/rgba()`, or named color to an ARGB int; null if invalid. */
private fun parseCssColor(raw: String): Int? {
val value = raw.trim()
return try {
if (value.startsWith("rgb", ignoreCase = true)) {
val parts = value.substringAfter('(').substringBefore(')').split(',').map { it.trim() }
if (parts.size < 3) return null
val alpha = if (parts.size >= 4) (parts[3].toFloat() * OPAQUE).roundToInt() else OPAQUE
Color.argb(alpha, parts[0].toInt(), parts[1].toInt(), parts[2].toInt())
} else {
value.toColorInt() // #hex or named color
}
} catch (e: IllegalArgumentException) {
Logger.withTag(TAG).w(e) { "Unparseable GeoJSON color: $raw" }
null
}
}
private fun Int.withAlpha(opacity: Float): Int =
Color.argb((opacity.coerceIn(0f, 1f) * OPAQUE).roundToInt(), Color.red(this), Color.green(this), Color.blue(this))

View File

@@ -20,5 +20,5 @@ import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
/** Site Planner (coverage-estimate) is Google-flavor only; the F-Droid map has no overlay-layer support. */
fun sitePlannerAvailable(): Boolean = false
/** Site Planner (coverage-estimate) — the F-Droid map renders imported coverage as OSMdroid overlays (see #6138). */
fun sitePlannerAvailable(): Boolean = true

View File

@@ -16,6 +16,10 @@
*/
package org.meshtastic.app.map
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -45,12 +49,14 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -79,6 +85,7 @@ import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.CustomMapLayersSheet
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
@@ -104,6 +111,7 @@ import org.meshtastic.core.resources.geofence_box_author_hint_viewport
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
@@ -132,6 +140,7 @@ import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.util.PermissionStatus
@@ -143,6 +152,7 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
@@ -229,6 +239,7 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapView(
modifier: Modifier = Modifier,
@@ -301,6 +312,44 @@ fun MapView(
val nodeClusterer = remember { RadiusMarkerClusterer(context) }
// --- Imported map layers (GeoJSON/KML overlays) — shared model/logic, F-Droid OSMdroid render ---
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
val layerRenderer = remember { FdroidMapOverlayRenderer() }
var showLayersBottomSheet by remember { mutableStateOf(false) }
var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val channelSet by mapViewModel.channelSet.collectAsStateWithLifecycle()
val filePickerLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri -> mapViewModel.addMapLayer(uri, uri.getFileName(context)) }
}
}
val onAddLayerClicked = {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf(
"application/vnd.google-earth.kml+xml",
"application/vnd.google-earth.kmz",
"application/vnd.geo+json",
"application/geo+json",
"application/json",
),
)
}
filePickerLauncher.launch(intent)
}
// Draw imported layers on the OSMdroid map, reconciling whenever the list changes; strip them on dispose (the
// MapView is kept alive by setDestroyMode(false), so leftover overlays would otherwise persist).
LaunchedEffect(mapLayers) { layerRenderer.reconcile(map, mapLayers, mapViewModel::getInputStreamFromUri) }
DisposableEffect(Unit) { onDispose { layerRenderer.removeAll(map) } }
fun MapView.toggleMyLocation() {
if (context.gpsDisabled()) {
Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" }
@@ -718,11 +767,25 @@ fun MapView(
},
mapTypeContent = {
MapButton(
icon = MeshtasticIcons.Layers,
icon = MeshtasticIcons.Map,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { showMapStyleDialog = true },
)
},
layersContent = {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.manage_map_layers),
onClick = { showLayersBottomSheet = true },
)
},
// Hands node/channel-derived params to the hosted Site Planner and imports the returned coverage.
onSitePlannerClick =
if (sitePlannerAvailable()) {
{ sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
} else {
null
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
when {
@@ -754,6 +817,50 @@ fun MapView(
)
}
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
CustomMapLayersSheet(
mapLayers = mapLayers,
onToggleVisibility = mapViewModel::toggleLayerVisibility,
onRemoveLayer = mapViewModel::removeMapLayer,
onAddLayerClicked = onAddLayerClicked,
onRefreshLayer = mapViewModel::refreshMapLayer,
onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
)
}
}
// Site Planner deep link from a node's detail screen — open the estimate dialog prefilled with that node.
val sitePlannerRequest by mapViewModel.sitePlannerRequest.collectAsStateWithLifecycle()
LaunchedEffect(sitePlannerRequest) {
sitePlannerRequest?.let { node ->
sitePlannerInitial = node.toSitePlannerParams(channelSet)
mapViewModel.consumeSitePlannerRequest()
}
}
sitePlannerInitial?.let { initial ->
SitePlannerHost(
initialParams = initial,
onDismiss = { sitePlannerInitial = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
// Recenter on the estimate's transmitter so the freshly-imported coverage is on-screen.
map.controller.animateTo(GeoPoint(latitude, longitude))
},
// OSMdroid GPS fix (only when tracking is active + permission granted); no Play-services location on
// F-Droid.
onRequestCurrentLocation =
if (locationPermission.isGranted) {
{ myLocationOverlay?.myLocation?.let { it.latitude to it.longitude } }
} else {
null
},
onUseNodeLocation =
ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } },
onUseMapCenter = { map.mapCenter.let { it.latitude to it.longitude } },
)
}
if (showCacheManagerDialog) {
CacheManagerDialog(
onClickOption = { option ->

View File

@@ -16,19 +16,24 @@
*/
package org.meshtastic.app.map
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import java.io.InputStream
@Suppress("LongParameterList")
@KoinViewModel
@@ -40,6 +45,7 @@ class MapViewModel(
radioConfigRepository: RadioConfigRepository,
notificationPrefs: NotificationPrefs,
buildConfigProvider: BuildConfigProvider,
private val mapLayersManager: MapLayersManager,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(
mapPrefs,
@@ -66,4 +72,37 @@ class MapViewModel(
}
val applicationId = buildConfigProvider.applicationId
/** Imported overlay layers; owned by the flavor-neutral [MapLayersManager] and drawn on the OSMdroid map. */
val mapLayers: StateFlow<List<MapLayerItem>> = mapLayersManager.mapLayers
fun addMapLayer(uri: Uri, fileName: String?) = mapLayersManager.addMapLayer(uri, fileName)
fun addGeoJsonLayer(name: String, geoJson: String) = mapLayersManager.addGeoJsonLayer(name, geoJson)
fun addNetworkMapLayer(name: String, url: String) {
mapLayersManager.addNetworkMapLayer(name, url)
}
fun toggleLayerVisibility(layerId: String) = mapLayersManager.toggleLayerVisibility(layerId)
fun removeMapLayer(layerId: String) = mapLayersManager.removeMapLayer(layerId)
fun refreshMapLayer(layerId: String) = mapLayersManager.refreshMapLayer(layerId)
fun refreshAllVisibleNetworkLayers() = mapLayersManager.refreshAllVisibleNetworkLayers()
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? =
mapLayersManager.getInputStreamFromUri(layerItem)
// Site Planner deep link from node detail: MapRoute.Map(sitePlannerNodeNum) → resolve to the node so the map can
// open the estimate dialog pre-filled with its position. Cleared once consumed so it doesn't re-open.
private val pendingSitePlannerNodeNum = MutableStateFlow(savedStateHandle.get<Int>("sitePlannerNodeNum"))
val sitePlannerRequest: StateFlow<Node?> =
combine(pendingSitePlannerNodeNum, nodeRepository.nodeDBbyNum) { num, db -> num?.let { db[it] } }
.stateInWhileSubscribed(initialValue = null)
fun consumeSitePlannerRequest() {
pendingSitePlannerNodeNum.value = null
}
}

View File

@@ -22,7 +22,6 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.location.Location
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -113,7 +112,6 @@ import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
@@ -123,7 +121,6 @@ import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.primaryChannel
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
@@ -159,15 +156,12 @@ import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import android.graphics.Color as AndroidColor
@@ -1290,14 +1284,15 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
val context = LocalContext.current
var currentLayer by remember { mutableStateOf<Layer?>(null) }
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
MapEffect(layerItem.id, layerItem.refreshToken) { map ->
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
when (layerItem.layerType) {
LayerType.KML -> KmlLayer(map, inputStream, context)
// 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 {
@@ -1430,67 +1425,9 @@ internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
"\uD83D\uDCCD"
}
@Suppress("NestedBlockDepth")
fun Uri.getFileName(context: android.content.Context): String {
var name = this.lastPathSegment ?: "layer_$nowMillis"
if (this.scheme == "content") {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
name = cursor.getString(displayNameIndex)
}
}
}
}
return name
}
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
/**
* Seed Site Planner params from a node (name + position) and, when a radio is connected, from its actual config:
* transmit frequency (from the primary channel), transmit power (dBm→W), and a receiver sensitivity derived from the
* modem preset. Antenna gain/height aren't in the device config, so they keep the planner's stock defaults.
*/
private fun Node?.toSitePlannerParams(channelSet: ChannelSet?): SitePlannerParams {
val lora = channelSet?.lora_config
val freqMhz = channelSet?.primaryChannel?.radioFreq?.toDouble()?.takeIf { it > 0.0 }
return SitePlannerParams(
name = this?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Coverage",
latitude = this?.latitude ?: 0.0,
longitude = this?.longitude ?: 0.0,
txFreqMhz = freqMhz ?: SitePlannerParams.DEFAULT_TX_FREQ_MHZ,
txPowerWatts = dbmToWatts(lora?.tx_power ?: 0) ?: SitePlannerParams.DEFAULT_TX_POWER_WATTS,
rxSensitivityDbm = sensitivityDbmFor(lora?.modem_preset),
)
}
/**
* Meshtastic tx_power is dBm; the planner wants watts. 0 (or less) means "use region max" in firmware — unknown here.
*/
@Suppress("MagicNumber")
private fun dbmToWatts(dbm: Int): Double? = if (dbm <= 0) null else 10.0.pow((dbm - 30).toDouble() / 10.0)
/**
* Approximate receiver sensitivity (dBm) per Meshtastic modem preset, from the Site Planner's own parameters.md table.
* Unmapped presets fall back to the planner's default so the estimate still runs.
*/
@Suppress("MagicNumber")
private fun sensitivityDbmFor(preset: ModemPreset?): Double = when (preset) {
ModemPreset.SHORT_TURBO -> -126.0
ModemPreset.SHORT_FAST -> -129.0
ModemPreset.SHORT_SLOW -> -131.5
ModemPreset.MEDIUM_FAST -> -134.0
ModemPreset.MEDIUM_SLOW -> -136.5
ModemPreset.LONG_FAST -> -139.0
ModemPreset.LONG_MODERATE -> -142.0
ModemPreset.LONG_SLOW -> -144.5
ModemPreset.VERY_LONG_SLOW -> -147.5
else -> SitePlannerParams.DEFAULT_RX_SENSITIVITY_DBM
}
/** One-shot last known location as a suspend call. Guarded by a permission check at the call site. */
@SuppressLint("MissingPermission")
private suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? = suspendCancellableCoroutine { cont ->

View File

@@ -28,11 +28,6 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -41,12 +36,10 @@ import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.MapFileImportBus
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
@@ -86,7 +79,7 @@ data class MapCameraPosition(
class MapViewModel(
private val application: Application,
private val dispatchers: CoroutineDispatchers,
private val httpClient: HttpClient,
private val mapLayersManager: MapLayersManager,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@@ -366,24 +359,14 @@ class MapViewModel(
urlTemplate.contains("{x}", ignoreCase = true) &&
urlTemplate.contains("{y}", ignoreCase = true)
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
/** Imported overlay layers; owned by the flavor-neutral [MapLayersManager] and rendered by [MapLayerOverlay]. */
val mapLayers: StateFlow<List<MapLayerItem>> = mapLayersManager.mapLayers
init {
viewModelScope.launch {
customTileProviderRepository.getCustomTileProviders().first()
loadPersistedMapType()
}
loadPersistedLayers()
// Import a map file handed to us via an "Open in / Send to Meshtastic" intent (see MapFileImportBus).
viewModelScope.launch {
MapFileImportBus.pending.collect { uri ->
uri ?: return@collect
MapFileImportBus.pending.value = null
addMapLayer(uri, uri.getFileName(application))
}
}
selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
@@ -436,153 +419,11 @@ class MapViewModel(
}
}
private fun loadPersistedLayers() {
viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
}
} else {
null
}
}
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
val id = parts[0]
val name = parts[1]
val uri = Uri.parse(parts[2])
MapLayerItem(
id = id,
name = name,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = LayerType.KML,
isNetwork = true,
)
} else {
null
}
} catch (e: Exception) {
null
}
}
_mapLayers.value = loadedItems + networkItems
if (_mapLayers.value.isNotEmpty()) {
Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
}
}
} else {
Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" }
_mapLayers.value = emptyList()
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) {
viewModelScope.launch {
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
val extension =
fileName?.substringAfterLast('.', "")?.lowercase()
?: application.contentResolver.getType(uri)?.split('/')?.last()
val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
val geoJsonExtensions = listOf("geojson", "json")
val layerType =
when (extension) {
in kmlExtensions -> LayerType.KML
in geoJsonExtensions -> LayerType.GEOJSON
else -> null
}
if (layerType == null) {
Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension")
return@launch
}
val finalFileName =
if (fileName != null) {
"$layerName.$extension"
} else {
"layer_${Uuid.random()}.$extension"
}
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
if (localFileUri != null) {
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
_mapLayers.value = _mapLayers.value + newItem
} else {
Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.")
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) = mapLayersManager.addMapLayer(uri, fileName)
fun addNetworkMapLayer(name: String, url: String) {
viewModelScope.launch {
if (name.isBlank() || url.isBlank()) {
_errorFlow.emit("Invalid name or URL for network layer.")
return@launch
}
try {
val uri = Uri.parse(url)
if (uri.scheme != "http" && uri.scheme != "https") {
_errorFlow.emit("URL must be http or https.")
return@launch
}
val path = uri.path?.lowercase() ?: ""
val layerType =
when {
path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
else -> LayerType.KML // Default to KML
}
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
mapLayersManager.addNetworkMapLayer(name, url)?.let { error ->
viewModelScope.launch { _errorFlow.emit(error) }
}
}
@@ -604,88 +445,15 @@ class MapViewModel(
}
}
/**
* Import a GeoJSON string (e.g. handed back by the Site Planner headless bridge) as a visible local overlay,
* reusing the same internal-storage-backed layer plumbing as file imports.
*/
fun addGeoJsonLayer(name: String, geoJson: String) {
viewModelScope.launch {
val displayName = name.ifBlank { "Coverage" }
val safeFileName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_")
val uri = writeStringToInternalStorage(geoJson, "${safeFileName}_${Uuid.random()}.geojson")
if (uri != null) {
_mapLayers.value =
_mapLayers.value + MapLayerItem(name = displayName, uri = uri, layerType = LayerType.GEOJSON)
} else {
Logger.withTag("MapViewModel").e("Failed to write GeoJSON layer to internal storage.")
}
}
}
fun addGeoJsonLayer(name: String, geoJson: String) = mapLayersManager.addGeoJsonLayer(name, geoJson)
private suspend fun writeStringToInternalStorage(content: String, fileName: String): Uri? =
withContext(dispatchers.io) {
try {
val directory = File(application.filesDir, "map_layers").apply { if (!exists()) mkdirs() }
val outputFile = File(directory, fileName)
outputFile.writeText(content)
Uri.fromFile(outputFile)
} catch (e: IOException) {
Logger.withTag("MapViewModel").e(e) { "Error writing GeoJSON to internal storage" }
null
}
}
fun toggleLayerVisibility(layerId: String) = mapLayersManager.toggleLayerVisibility(layerId)
fun toggleLayerVisibility(layerId: String) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =
_mapLayers.value.map {
if (it.id == layerId) {
toggledLayer = it.copy(isVisible = !it.isVisible)
toggledLayer
} else {
it
}
}
_mapLayers.value = updatedLayers
fun removeMapLayer(layerId: String) = mapLayersManager.removeMapLayer(layerId)
toggledLayer?.let {
if (it.isVisible) {
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
fun refreshMapLayer(layerId: String) = mapLayersManager.refreshMapLayer(layerId)
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
googleMapsPrefs.setNetworkMapLayers(
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
)
} else {
deleteFileToInternalStorage(uri)
}
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
fun refreshMapLayer(layerId: String) {
viewModelScope.launch {
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
// By resetting the layer data in the UI (implied by just refreshing),
// we trigger a reload in the Composable.
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
}
}
fun refreshAllVisibleNetworkLayers() {
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
}
fun refreshAllVisibleNetworkLayers() = mapLayersManager.refreshAllVisibleNetworkLayers()
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(dispatchers.io) {
@@ -700,27 +468,8 @@ class MapViewModel(
}
}
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val response = httpClient.get(uriToLoad.toString())
if (!response.status.isSuccess()) {
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
return@withContext null
}
response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
null
}
}
}
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? =
mapLayersManager.getInputStreamFromUri(layerItem)
override fun onCleared() {
super.onCleared()
@@ -729,18 +478,3 @@ class MapViewModel(
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST)
}
enum class LayerType {
KML,
GEOJSON,
}
data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uri: Uri? = null,
val isVisible: Boolean = true,
val layerType: LayerType,
val isNetwork: Boolean = false,
val isRefreshing: Boolean = false,
)

View File

@@ -22,7 +22,6 @@ import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -45,10 +44,6 @@ interface GoogleMapsPrefs {
fun setSelectedCustomTileUrl(value: String?)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val cameraTargetLat: StateFlow<Double>
fun setCameraTargetLat(value: Double)
@@ -68,10 +63,6 @@ interface GoogleMapsPrefs {
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
@Single
@@ -113,15 +104,6 @@ class GoogleMapsPrefsImpl(
}
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
}
override val cameraTargetLat: StateFlow<Double> =
dataStore.data
.map {
@@ -173,24 +155,13 @@ class GoogleMapsPrefsImpl(
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
}
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
}
companion object {
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
}
}

View File

@@ -0,0 +1,94 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.nowMillis
import kotlin.uuid.Uuid
/**
* Flavor-neutral map-layer model shared by the Google (Google Maps data layer) and F-Droid (OSMdroid overlay) flavors.
* Only the final overlay draw is flavor-specific; the layer list, storage, and import logic live in [MapLayersManager].
*/
enum class LayerType {
KML,
GEOJSON,
}
data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uri: Uri? = null,
val isVisible: Boolean = true,
val layerType: LayerType,
val isNetwork: Boolean = false,
/** UI indicator: whether a refresh is in flight (drives the sheet/toolbar spinner). */
val isRefreshing: Boolean = false,
/**
* Monotonic counter bumped on refresh so the flavor renderers reliably re-read the layer. A [StateFlow] conflates
* transient values, so a bounced boolean flag can be missed — an ever-increasing token cannot.
*/
val refreshToken: Int = 0,
)
private val KML_EXTENSIONS = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
private val GEOJSON_EXTENSIONS = listOf("geojson", "json")
/**
* Resolve a file extension or MIME subtype (e.g. `geojson`, `vnd.geo+json`) to a [LayerType], or null if unsupported.
*/
fun resolveLayerType(extensionOrMime: String?): LayerType? = when (extensionOrMime?.lowercase()) {
in KML_EXTENSIONS -> LayerType.KML
in GEOJSON_EXTENSIONS -> LayerType.GEOJSON
// MIME subtypes the content resolver may report for GeoJSON that aren't a bare "geojson"/"json".
"geo+json",
"vnd.geo+json",
-> LayerType.GEOJSON
else -> null
}
/**
* Resolve a display file name for [this] URI, querying the content resolver for `content://` URIs. Untrusted providers
* (share/open-with from other apps) can throw or return a null display name, so guard both and fall back to the URI's
* last path segment.
*/
@Suppress("NestedBlockDepth")
fun Uri.getFileName(context: Context): String {
var name = lastPathSegment ?: "layer_$nowMillis"
if (scheme == "content") {
try {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
cursor.getString(displayNameIndex)?.let { name = it }
}
}
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// Keep the lastPathSegment fallback assigned above rather than crashing the import.
Logger.withTag("MapLayer").w(e) { "Failed to resolve display name for content URI; using fallback" }
}
}
return name
}

View File

@@ -0,0 +1,311 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import android.app.Application
import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.app.MapFileImportBus
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapPrefs
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import kotlin.uuid.Uuid
/**
* Flavor-neutral owner of the imported map-layer list, its internal-storage persistence, and the GeoJSON/KML import
* plumbing. Both the Google (Google Maps data layer) and F-Droid (OSMdroid overlay) flavors observe [mapLayers] and
* render it their own way; this class is the single home for everything that isn't the final overlay draw.
*
* A [Single] rather than per-flavor ViewModel state so the layer set survives ViewModel recreation and both flavors
* share one implementation. Layers persist as files under `filesDir/map_layers`; hidden/network state lives in
* [MapPrefs].
*/
@Single
@Suppress("TooManyFunctions")
class MapLayersManager(
private val application: Application,
private val dispatchers: CoroutineDispatchers,
private val httpClient: HttpClient,
private val mapPrefs: MapPrefs,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
init {
loadPersistedLayers()
// Import a map file handed to us via an "Open in / Send to Meshtastic" intent (see MapFileImportBus).
scope.launch {
MapFileImportBus.pending.collect { uri ->
uri ?: return@collect
MapFileImportBus.pending.value = null
addMapLayer(uri, uri.getFileName(application))
}
}
}
private fun loadPersistedLayers() {
scope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, LAYERS_DIR)
// await* (not .value) so a cold-start load doesn't see the StateFlow's initial empty default.
val hiddenLayerUrls = mapPrefs.awaitHiddenLayerUrls()
val loadedItems =
if (layersDir.exists() && layersDir.isDirectory) {
layersDir.listFiles().orEmpty().mapNotNull { file ->
if (!file.isFile) return@mapNotNull null
resolveLayerType(file.extension)?.let { layerType ->
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = layerType,
)
}
}
} else {
emptyList()
}
val networkItems =
mapPrefs.awaitNetworkMapLayers().mapNotNull { networkString ->
val parts = networkString.split(NETWORK_LAYER_DELIMITER)
if (parts.size == NETWORK_LAYER_FIELDS) {
val uri = parts[2].toUri()
MapLayerItem(
id = parts[0],
name = parts[1],
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = LayerType.KML,
isNetwork = true,
)
} else {
null
}
}
_mapLayers.value = loadedItems + networkItems
if (_mapLayers.value.isNotEmpty()) {
Logger.withTag(TAG).i("Loaded ${_mapLayers.value.size} persisted map layers.")
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.withTag(TAG).e(e) { "Error loading persisted map layers" }
_mapLayers.value = emptyList()
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) {
scope.launch {
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
val extension =
fileName?.substringAfterLast('.', "")?.lowercase()
?: application.contentResolver.getType(uri)?.split('/')?.last()
val layerType = resolveLayerType(extension)
if (layerType == null) {
Logger.withTag(TAG).e("Unsupported map layer file type: $extension")
return@launch
}
// Sanitize the on-disk name: fileName comes from an untrusted DISPLAY_NAME/lastPathSegment (share/open-with
// from other apps), so strip anything that could let it escape map_layers/ (mirrors addGeoJsonLayer).
val safeBase = layerName.replace(FILE_NAME_UNSAFE, "_")
val finalFileName = if (fileName != null) "$safeBase.$extension" else "layer_${Uuid.random()}.$extension"
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
if (localFileUri != null) {
_mapLayers.update { it + MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) }
} else {
Logger.withTag(TAG).e("Failed to copy file to internal storage.")
}
}
}
/**
* Import a GeoJSON string (e.g. handed back by the Site Planner headless bridge) as a visible local overlay,
* reusing the same internal-storage-backed layer plumbing as file imports.
*/
fun addGeoJsonLayer(name: String, geoJson: String) {
scope.launch {
val displayName = name.ifBlank { "Coverage" }
val safeFileName = displayName.replace(FILE_NAME_UNSAFE, "_")
val uri = writeStringToInternalStorage(geoJson, "${safeFileName}_${Uuid.random()}.geojson")
if (uri != null) {
_mapLayers.update { it + MapLayerItem(name = displayName, uri = uri, layerType = LayerType.GEOJSON) }
} else {
Logger.withTag(TAG).e("Failed to write GeoJSON layer to internal storage.")
}
}
}
/** Returns an error message if [name]/[url] are invalid, or null on success. Adds a persisted network layer. */
@Suppress("ReturnCount") // guard clauses read clearer than nesting for this validation
fun addNetworkMapLayer(name: String, url: String): String? {
if (name.isBlank() || url.isBlank()) return "Invalid name or URL for network layer."
val uri =
try {
url.toUri()
} catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) {
return "Invalid URL."
}
if (uri.scheme != "http" && uri.scheme != "https") return "URL must be http or https."
val path = uri.path?.lowercase() ?: ""
val layerType = if (path.endsWith(".geojson") || path.endsWith(".json")) LayerType.GEOJSON else LayerType.KML
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
_mapLayers.update { it + newItem }
val encoded = listOf(newItem.id, newItem.name, newItem.uri).joinToString(NETWORK_LAYER_DELIMITER)
mapPrefs.updateNetworkMapLayers { it + encoded }
return null
}
fun toggleLayerVisibility(layerId: String) {
val target = _mapLayers.value.find { it.id == layerId } ?: return
val nowVisible = !target.isVisible
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isVisible = nowVisible) else it } }
val uri = target.uri?.toString() ?: return
mapPrefs.updateHiddenLayerUrls { if (nowVisible) it - uri else it + uri }
}
fun removeMapLayer(layerId: String) {
scope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
mapPrefs.updateNetworkMapLayers { entries ->
entries.filterNot { it.startsWith("$layerId$NETWORK_LAYER_DELIMITER") }.toSet()
}
} else {
deleteFileFromInternalStorage(uri)
}
mapPrefs.updateHiddenLayerUrls { it - uri.toString() }
}
_mapLayers.update { layers -> layers.filterNot { it.id == layerId } }
}
}
/** Bump a layer's [MapLayerItem.refreshToken] so renderers re-read it (used for network-layer refresh). */
fun refreshMapLayer(layerId: String) {
_mapLayers.update { layers ->
layers.map { if (it.id == layerId) it.copy(refreshToken = it.refreshToken + 1) else it }
}
}
fun refreshAllVisibleNetworkLayers() {
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
}
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val response = httpClient.get(uriToLoad.toString())
if (!response.status.isSuccess()) {
// Log only host, not the full URL (paths can carry user-identifying info).
Logger.withTag(TAG).e {
"HTTP ${response.status} fetching network layer from ${uriToLoad.host}"
}
return@withContext null
}
response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// Redact the URI: a content:///file:// path can include a user-chosen file name.
Logger.withTag(TAG).e(e) { "Error opening InputStream for layer (scheme=${uriToLoad.scheme})" }
null
}
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
// openInputStream can return null (documented) — fail rather than return a Uri for a file never
// written.
val inputStream = application.contentResolver.openInputStream(uri) ?: return@withContext null
val directory = File(application.filesDir, LAYERS_DIR).apply { if (!exists()) mkdirs() }
val outputFile = File(directory, fileName)
inputStream.use { input -> FileOutputStream(outputFile).use { output -> input.copyTo(output) } }
Uri.fromFile(outputFile)
} catch (e: IOException) {
Logger.withTag(TAG).e(e) { "Error copying file to internal storage" }
null
}
}
private suspend fun writeStringToInternalStorage(content: String, fileName: String): Uri? =
withContext(dispatchers.io) {
try {
val directory = File(application.filesDir, LAYERS_DIR).apply { if (!exists()) mkdirs() }
val outputFile = File(directory, fileName)
outputFile.writeText(content)
Uri.fromFile(outputFile)
} catch (e: IOException) {
Logger.withTag(TAG).e(e) { "Error writing GeoJSON to internal storage" }
null
}
}
private suspend fun deleteFileFromInternalStorage(uri: Uri) {
withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) file.delete()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.withTag(TAG).e(e) { "Error deleting file from internal storage" }
}
}
}
private companion object {
const val TAG = "MapLayersManager"
const val LAYERS_DIR = "map_layers"
const val NETWORK_LAYER_DELIMITER = "|:|"
const val NETWORK_LAYER_FIELDS = 3 // id|:|name|:|uri
// Characters not allowed in an on-disk layer file name; strips path separators so imports can't traverse.
val FILE_NAME_UNSAFE = Regex("[^A-Za-z0-9._-]")
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.primaryChannel
import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import kotlin.math.pow
/**
* Seed Site Planner params from a node (name + position) and, when a radio is connected, from its actual config:
* transmit frequency (from the primary channel), transmit power (dBm→W), and a receiver sensitivity derived from the
* modem preset. Antenna gain/height aren't in the device config, so they keep the planner's stock defaults.
*
* Flavor-neutral: both the Google and F-Droid maps feed the same hosted Site Planner.
*/
fun Node?.toSitePlannerParams(channelSet: ChannelSet?): SitePlannerParams {
val lora = channelSet?.lora_config
val freqMhz = channelSet?.primaryChannel?.radioFreq?.toDouble()?.takeIf { it > 0.0 }
return SitePlannerParams(
name = this?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Coverage",
latitude = this?.latitude ?: 0.0,
longitude = this?.longitude ?: 0.0,
txFreqMhz = freqMhz ?: SitePlannerParams.DEFAULT_TX_FREQ_MHZ,
txPowerWatts = dbmToWatts(lora?.tx_power ?: 0) ?: SitePlannerParams.DEFAULT_TX_POWER_WATTS,
rxSensitivityDbm = sensitivityDbmFor(lora?.modem_preset),
)
}
/**
* Meshtastic tx_power is dBm; the planner wants watts. 0 (or less) means "use region max" in firmware — unknown here.
*/
@Suppress("MagicNumber")
private fun dbmToWatts(dbm: Int): Double? = if (dbm <= 0) null else 10.0.pow((dbm - 30).toDouble() / 10.0)
/**
* Approximate receiver sensitivity (dBm) per Meshtastic modem preset, from the Site Planner's own parameters.md table.
* Unmapped presets fall back to the planner's default so the estimate still runs.
*/
@Suppress("MagicNumber")
private fun sensitivityDbmFor(preset: ModemPreset?): Double = when (preset) {
ModemPreset.SHORT_TURBO -> -126.0
ModemPreset.SHORT_FAST -> -129.0
ModemPreset.SHORT_SLOW -> -131.5
ModemPreset.MEDIUM_FAST -> -134.0
ModemPreset.MEDIUM_SLOW -> -136.5
ModemPreset.LONG_FAST -> -139.0
ModemPreset.LONG_MODERATE -> -142.0
ModemPreset.LONG_SLOW -> -144.5
ModemPreset.VERY_LONG_SLOW -> -147.5
else -> SitePlannerParams.DEFAULT_RX_SENSITIVITY_DBM
}

View File

@@ -17,7 +17,6 @@
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
@@ -52,6 +51,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -77,6 +77,7 @@ private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
* ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "LambdaParameterInRestartableEffect")
@Composable
fun SitePlannerHost(
initialParams: SitePlannerParams,
@@ -208,7 +209,7 @@ private fun SitePlannerRunner(
// Sub-resource fetches (tiles, XHR) aren't navigations, so this doesn't affect the sim itself.
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val target = request?.url ?: return false
val trusted = Uri.parse(SITE_PLANNER_BASE_URL)
val trusted = SITE_PLANNER_BASE_URL.toUri()
return target.scheme != trusted.scheme || target.host != trusted.host
}

View File

@@ -68,7 +68,7 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
@Suppress("LongMethod")
@Suppress("LongMethod", "ParameterNaming") // onAddLayerClicked is the established callback name used by both flavors
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomMapLayersSheet(
@@ -78,9 +78,10 @@ fun CustomMapLayersSheet(
onAddLayerClicked: () -> Unit,
onRefreshLayer: (String) -> Unit,
onAddNetworkLayer: (String, String) -> Unit,
modifier: Modifier = Modifier,
) {
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
LazyColumn(modifier = modifier, contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
modifier = Modifier.padding(16.dp),
@@ -182,6 +183,7 @@ fun CustomMapLayersSheet(
}
}
@Suppress("ModifierMissing") // wraps MeshtasticDialog, which owns its own layout; no meaningful modifier slot
@Composable
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }

View File

@@ -0,0 +1,47 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MapLayerResolutionTest {
@Test
fun resolvesGeoJsonExtensionsAndMimes() {
assertEquals(LayerType.GEOJSON, resolveLayerType("geojson"))
assertEquals(LayerType.GEOJSON, resolveLayerType("json"))
assertEquals(LayerType.GEOJSON, resolveLayerType("GeoJSON")) // case-insensitive
assertEquals(LayerType.GEOJSON, resolveLayerType("geo+json")) // content-resolver MIME subtype
assertEquals(LayerType.GEOJSON, resolveLayerType("vnd.geo+json"))
}
@Test
fun resolvesKmlExtensionsAndMimes() {
assertEquals(LayerType.KML, resolveLayerType("kml"))
assertEquals(LayerType.KML, resolveLayerType("kmz"))
assertEquals(LayerType.KML, resolveLayerType("vnd.google-earth.kml+xml"))
assertEquals(LayerType.KML, resolveLayerType("vnd.google-earth.kmz"))
}
@Test
fun rejectsUnsupportedAndNull() {
assertNull(resolveLayerType("txt"))
assertNull(resolveLayerType(""))
assertNull(resolveLayerType(null))
}
}

View File

@@ -22,10 +22,12 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -83,6 +85,38 @@ class MapPrefsImpl(
scope.launch { dataStore.edit { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] = seconds } }
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun updateHiddenLayerUrls(transform: (Set<String>) -> Set<String>) {
// Compute the new set inside the edit transaction (DataStore serializes edits) to avoid lost updates.
scope.launch {
dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = transform(it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet()) }
}
}
// dataStore.data's first emission is the persisted value (unlike the eager StateFlow, which starts at emptySet()).
override suspend fun awaitHiddenLayerUrls(): Set<String> =
dataStore.data.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }.first()
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun updateNetworkMapLayers(transform: (Set<String>) -> Set<String>) {
scope.launch {
dataStore.edit {
it[KEY_NETWORK_MAP_LAYERS_PREF] = transform(it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet())
}
}
}
override suspend fun awaitNetworkMapLayers(): Set<String> =
dataStore.data.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }.first()
companion object {
val KEY_MAP_STYLE_PREF = intPreferencesKey("map_style_id")
val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPreferencesKey("show_only_favorites")
@@ -90,5 +124,7 @@ class MapPrefsImpl(
val KEY_SHOW_PRECISION_CIRCLE_PREF = booleanPreferencesKey("show_precision_circle")
val KEY_LAST_HEARD_FILTER_PREF = longPreferencesKey("last_heard_filter")
val KEY_LAST_HEARD_TRACK_FILTER_PREF = longPreferencesKey("last_heard_track_filter")
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
}
}

View File

@@ -239,6 +239,24 @@ interface MapPrefs {
val lastHeardTrackFilter: StateFlow<Long>
fun setLastHeardTrackFilter(seconds: Long)
/** URIs of imported map layers the user has toggled off; a layer is visible unless its URI is in this set. */
val hiddenLayerUrls: StateFlow<Set<String>>
/** Atomically mutate [hiddenLayerUrls]; [transform] runs against the persisted value, avoiding lost updates. */
fun updateHiddenLayerUrls(transform: (Set<String>) -> Set<String>)
/** Persisted [hiddenLayerUrls]; suspends for the first disk load to avoid a cold-start empty default. */
suspend fun awaitHiddenLayerUrls(): Set<String>
/** Persisted network (URL-backed) map layers, each encoded as `id|:|name|:|uri`. */
val networkMapLayers: StateFlow<Set<String>>
/** Atomically mutate [networkMapLayers]; [transform] runs against the persisted value, avoiding lost updates. */
fun updateNetworkMapLayers(transform: (Set<String>) -> Set<String>)
/** Persisted [networkMapLayers]; suspends for the first disk load to avoid a cold-start empty default. */
suspend fun awaitNetworkMapLayers(): Set<String>
}
/** Reactive interface for map consent. */

View File

@@ -275,6 +275,22 @@ class FakeMapPrefs : MapPrefs {
override fun setLastHeardTrackFilter(seconds: Long) {
lastHeardTrackFilter.value = seconds
}
override val hiddenLayerUrls = MutableStateFlow<Set<String>>(emptySet())
override fun updateHiddenLayerUrls(transform: (Set<String>) -> Set<String>) {
hiddenLayerUrls.value = transform(hiddenLayerUrls.value)
}
override suspend fun awaitHiddenLayerUrls(): Set<String> = hiddenLayerUrls.value
override val networkMapLayers = MutableStateFlow<Set<String>>(emptySet())
override fun updateNetworkMapLayers(transform: (Set<String>) -> Set<String>) {
networkMapLayers.value = transform(networkMapLayers.value)
}
override suspend fun awaitNetworkMapLayers(): Set<String> = networkMapLayers.value
}
class FakeMapConsentPrefs : MapConsentPrefs {