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

@@ -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

@@ -1,231 +0,0 @@
/*
* 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.annotation.SuppressLint
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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 co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.site_planner_estimating
import org.meshtastic.core.resources.site_planner_failed
import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.SitePlannerSheet
// The official hosted Site Planner (static PWA on GitHub Pages). The estimate flow loads it headless with
// run=1&bridge=1; the native bridge + full flat query contract it relies on shipped in site-planner #74.
// ponytail: make it a setting if a self-hosted planner is ever needed.
private const val SITE_PLANNER_BASE_URL = "https://site.meshtastic.org"
private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
/**
* Site Planner coverage-estimate flow: an editable [SitePlannerSheet] pre-filled with [initialParams], then a hidden
* headless WebView that loads the planner with `run=1&bridge=1`, waits for it to hand back the styled GeoJSON, and
* imports it via [onImport]. Location shortcuts re-seed the coordinates from the device GPS
* ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center
* ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SitePlannerHost(
initialParams: SitePlannerParams,
onDismiss: () -> Unit,
onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit,
onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? = null,
onUseNodeLocation: (() -> Pair<Double, Double>)? = null,
onUseMapCenter: (() -> Pair<Double, Double>)? = null,
) {
var params by remember(initialParams) { mutableStateOf(initialParams) }
var running by remember { mutableStateOf<SitePlannerParams?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val failedText = stringResource(Res.string.site_planner_failed)
val estimatingText = stringResource(Res.string.site_planner_estimating)
val current = running
if (current == null) {
SitePlannerSheet(
initial = params,
onSubmit = { running = it },
onDismiss = onDismiss,
onUseCurrentLocation =
onRequestCurrentLocation?.let { fetch ->
{
scope.launch {
fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) }
}
}
},
onUseNodeLocation =
onUseNodeLocation?.let { node ->
{
val (lat, lon) = node()
params = params.copy(latitude = lat, longitude = lon)
}
},
onUseMapCenter =
onUseMapCenter?.let { center ->
{
val (lat, lon) = center()
params = params.copy(latitude = lat, longitude = lon)
}
},
)
} else {
Dialog(onDismissRequest = { onDismiss() }) {
// The WebView fills the card and runs the sim; the opaque spinner Surface on top hides it, so the flow
// reads as "Estimating coverage…" rather than a browser. It must be attached and non-trivially sized —
// a detached or 0-size WebView can't get a WebGL context, and the planner's autorun waits on map load.
Box(modifier = Modifier.size(280.dp), contentAlignment = Alignment.Center) {
SitePlannerRunner(
url = current.toQueryUrl(SITE_PLANNER_BASE_URL),
onResult = { geoJson ->
onImport(current.name, geoJson, current.latitude, current.longitude)
onDismiss()
},
onError = { detail ->
Logger.withTag("SitePlanner").e { "Coverage estimate failed: $detail" }
Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
onDismiss()
},
// Headless: fully transparent so the WebGL first-paint frame never flashes through, but still
// attached + sized (280dp) so the sim gets a WebGL context. alpha(0) is a compositor property —
// the view stays VISIBLE, so the page's requestAnimationFrame/autorun keep running.
modifier = Modifier.matchParentSize().alpha(0f),
)
Surface(
modifier = Modifier.matchParentSize(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
CircularWavyProgressIndicator()
Text(estimatingText)
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
}
}
}
}
LaunchedEffect(current) {
delay(SITE_PLANNER_TIMEOUT_MS)
Logger.withTag("SitePlanner").w { "Coverage estimate timed out after ${SITE_PLANNER_TIMEOUT_MS}ms" }
Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
onDismiss()
}
}
}
/**
* Headless WebView that loads [url] (the planner with `run=1&bridge=1`), exposes a `window.__meshtasticNative` bridge,
* and reports the coverage GeoJSON via [onResult] once the planner posts it. Main-frame load failures go to [onError].
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun SitePlannerRunner(
url: String,
onResult: (String) -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val onResultState by rememberUpdatedState(onResult)
val onErrorState by rememberUpdatedState(onError)
AndroidView(
modifier = modifier,
factory = { context ->
val main = Handler(Looper.getMainLooper())
WebView(context).apply {
setBackgroundColor(android.graphics.Color.TRANSPARENT) // no opaque black backing before first paint
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(
object {
// Called from the planner's JS thread — hop to main before touching Compose state.
@JavascriptInterface fun onCoverage(geoJson: String) = main.post { onResultState(geoJson) }
},
"__meshtasticNative",
)
webViewClient =
object : WebViewClient() {
// Keep the __meshtasticNative bridge exclusive to the planner's own origin: block any
// navigation
// elsewhere (redirect/compromise/open-redirect) so foreign content can never reach
// onCoverage().
// 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)
return target.scheme != trusted.scheme || target.host != trusted.host
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
// Only a failed main-frame load is fatal; a stray tile/asset 404 is not.
if (request?.isForMainFrame == true) {
main.post { onErrorState("${error?.errorCode} ${error?.description}") }
}
}
}
loadUrl(url)
}
},
onRelease = WebView::destroy,
)
}

View File

@@ -1,216 +0,0 @@
/*
* 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.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapLayerItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_layer
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.network_layer_url_hint
import org.meshtastic.core.resources.no_map_layers_loaded
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.remove_layer
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomMapLayersSheet(
mapLayers: List<MapLayerItem>,
onToggleVisibility: (String) -> Unit,
onRemoveLayer: (String) -> Unit,
onAddLayerClicked: () -> Unit,
onRefreshLayer: (String) -> Unit,
onAddNetworkLayer: (String, String) -> Unit,
) {
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
modifier = Modifier.padding(16.dp),
text = stringResource(Res.string.manage_map_layers),
style = MaterialTheme.typography.headlineSmall,
)
HorizontalDivider()
}
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
text = stringResource(Res.string.map_layer_formats),
style = MaterialTheme.typography.bodySmall,
)
}
if (mapLayers.isEmpty()) {
item {
Text(
modifier = Modifier.padding(16.dp),
text = stringResource(Res.string.no_map_layers_loaded),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(mapLayers, key = { it.id }) { layer ->
ListItem(
headlineContent = { Text(layer.name) },
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (layer.isNetwork) {
if (layer.isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(4.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { onRefreshLayer(layer.id) }) {
Icon(
imageVector = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
)
}
}
}
IconToggleButton(
checked = layer.isVisible,
onCheckedChange = { onToggleVisibility(layer.id) },
) {
Icon(
imageVector =
if (layer.isVisible) {
MeshtasticIcons.Visibility
} else {
MeshtasticIcons.VisibilityOff
},
contentDescription =
stringResource(
if (layer.isVisible) {
Res.string.hide_layer
} else {
Res.string.show_layer
},
),
)
}
IconButton(onClick = { onRemoveLayer(layer.id) }) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.remove_layer),
)
}
}
},
)
HorizontalDivider()
}
}
item {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
Text(stringResource(Res.string.add_layer))
}
Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
Text(stringResource(Res.string.add_network_layer))
}
}
}
}
if (showAddNetworkLayerDialog) {
AddNetworkLayerDialog(
onDismiss = { showAddNetworkLayerDialog = false },
onConfirm = { name, url ->
onAddNetworkLayer(name, url)
showAddNetworkLayerDialog = false
},
)
}
}
@Composable
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.add_network_layer),
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(Res.string.url)) },
placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
onConfirm = { onConfirm(name, url) },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}

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")
}
}