mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 04:15:37 -04:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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._-]")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("") }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user