feat(map): Site Planner coverage integration — import + estimate (#6136)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-07 18:24:14 -05:00
committed by GitHub
parent e12c4a579d
commit 17501048b4
18 changed files with 1267 additions and 10 deletions

View File

@@ -19,3 +19,6 @@ package org.meshtastic.app.map
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

View File

@@ -29,6 +29,35 @@
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
<!--
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). Google flavor only — it's the flavor that renders map overlays. 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"
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>
<!--
AppFunctions 1.0.0-alpha10 discontinued the appfunctions-service artifact whose
manifest used to contribute these two delegate services; apps must now declare

View File

@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
/** Site Planner (coverage-estimate) availability — Google flavor only (the flavor that renders map overlays). */
fun sitePlannerAvailable(): Boolean = true

View File

@@ -18,8 +18,10 @@
package org.meshtastic.app.map
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
@@ -61,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -97,6 +100,7 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle
import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.koin.compose.viewmodel.koinViewModel
@@ -119,6 +123,7 @@ 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
@@ -150,14 +155,19 @@ 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.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
@@ -250,6 +260,7 @@ fun MapView(
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val channelSet by mapViewModel.channelSet.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var geofenceInfoWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
@@ -509,6 +520,8 @@ fun MapView(
// --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
// Non-null while the Site Planner estimate dialog/runner is open, holding the initial (prefilled) params.
var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
val onAddLayerClicked = {
val intent =
@@ -839,6 +852,13 @@ fun MapView(
onClick = { showLayersBottomSheet = true },
)
},
// Google flavor only: hands params to the hosted Site Planner and imports the returned coverage.
onSitePlannerClick =
if (sitePlannerAvailable()) {
{ sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
} else {
null
},
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
when {
@@ -896,6 +916,40 @@ fun MapView(
)
}
}
// 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 ->
// Phone GPS: only when permission is already granted; otherwise the field stays manual.
val onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? =
if (locationPermission.isGranted) {
{ fusedLocationClient.awaitLastLocation()?.let { it.latitude to it.longitude } }
} else {
null
}
// Our connected node's reported position: only when it has a valid fix.
val onUseNodeLocation: (() -> Pair<Double, Double>)? =
ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }
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.
coroutineScope.launch {
cameraPositionState.animate(CameraUpdateFactory.newLatLng(LatLng(latitude, longitude)))
}
},
onRequestCurrentLocation = onRequestCurrentLocation,
onUseNodeLocation = onUseNodeLocation,
onUseMapCenter = { cameraPositionState.position.target.let { it.latitude to it.longitude } },
)
}
showClusterItemsDialog?.let {
ClusterItemsListDialog(
items = it,
@@ -1395,6 +1449,60 @@ fun Uri.getFileName(context: android.content.Context): String {
/** 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 ->
// lastLocation can throw SecurityException synchronously if permission is revoked between the compose-time
// isGranted check and this call; treat that as "no location" rather than crashing the estimate flow.
try {
lastLocation.addOnSuccessListener { cont.resume(it) }.addOnFailureListener { cont.resume(null) }
} catch (_: SecurityException) {
cont.resume(null)
}
}
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),

View File

@@ -39,16 +39,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -106,6 +109,17 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
// 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
}
fun setWaypointId(id: Int?) {
if (_selectedWaypointId.value != id) {
_selectedWaypointId.value = id
@@ -362,6 +376,15 @@ class MapViewModel(
}
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 {
val wpMap = waypoints.first { it.containsKey(wpId) }
@@ -581,6 +604,37 @@ 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.")
}
}
}
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) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =

View File

@@ -0,0 +1,231 @@
/*
* 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

@@ -54,6 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.map.sitePlannerAvailable
import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
@@ -81,6 +82,7 @@ import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
@@ -199,6 +201,7 @@ class MainActivity : AppCompatActivity() {
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalSitePlannerAvailable provides sitePlannerAvailable(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
@@ -262,9 +265,7 @@ class MainActivity : AppCompatActivity() {
val appLinkData: Uri? = intent.data
when (appLinkAction) {
Intent.ACTION_VIEW -> {
appLinkData?.let { handleMeshtasticUri(it) }
}
Intent.ACTION_VIEW -> handleViewIntent(appLinkData)
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages =
@@ -295,12 +296,7 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_MAIN -> {}
Intent.ACTION_SEND -> {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null) {
createShareIntent(text).send()
}
}
Intent.ACTION_SEND -> handleSendIntent(intent)
else -> {
Logger.w { "Unexpected action $appLinkAction" }
@@ -308,12 +304,46 @@ class MainActivity : AppCompatActivity() {
}
}
private fun handleViewIntent(uri: Uri?) {
when {
uri == null -> {}
// A file handed to us via "Open in Meshtastic" (Files, Share Sheet, drag-and-drop) rather than a
// meshtastic:// / https deep link — import it as a map overlay.
uri.scheme == "content" || uri.scheme == "file" -> importMapFile(uri)
else -> handleMeshtasticUri(uri)
}
}
private fun handleSendIntent(intent: Intent) {
val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
when {
stream != null -> importMapFile(stream)
// shared .geojson/.kml file (e.g. Site Planner)
text != null -> createShareIntent(text).send()
}
}
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
/**
* Hand a map file received via an OS "Open in / Send to Meshtastic" intent to the map, then bring the Map tab
* forward so the imported overlay is visible. Only the Google-flavor map consumes this (see [MapFileImportBus]);
* the read grant on [uri] lives as long as this activity, which is long enough for the map to copy the file in.
*/
private fun importMapFile(uri: Uri) {
Logger.d { "Importing shared map file: $uri" }
MapFileImportBus.pending.value = uri
handleMeshtasticUri("$DEEP_LINK_BASE_URI/map".toUri())
}
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent =

View File

@@ -0,0 +1,33 @@
/*
* 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
import android.net.Uri
import kotlinx.coroutines.flow.MutableStateFlow
/**
* One-slot handoff for a map file (GeoJSON/KML) received via an "Open in / Send to Meshtastic" intent (e.g. the
* Meshtastic Site Planner's "Send to App" share). The shared [MainActivity] receives the intent; only the Google-flavor
* map renders overlays, so its `MapViewModel` drains this and imports the layer while the activity still holds the URI
* read grant.
*
* ponytail: process-global single slot — fine for one shared file at a time; promote to a Koin single if this ever
* needs a second consumer or unit testing.
*/
object MapFileImportBus {
val pending = MutableStateFlow<Uri?>(null)
}