Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ 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
import org.meshtastic.app.map.offline.BurningManOsmDroidTileProvider
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
Expand Down Expand Up @@ -154,6 +155,7 @@ 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.offline.BurningManPackRuntime
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
Expand Down Expand Up @@ -309,6 +311,47 @@ fun MapView(
box = initialCameraView,
tileSource = loadOnlineTileSourceBase(),
)
val onlineTileProvider = remember(map) { map.tileProvider }
val burningManRuntime = remember(context.applicationContext) { BurningManPackRuntime.forContext(context) }
val burningManPack by burningManRuntime.coordinator.selectedPack.collectAsStateWithLifecycle()
val burningManTileProvider =
remember(burningManPack?.file) {
burningManPack?.file?.let { file -> runCatching { BurningManOsmDroidTileProvider(file) }.getOrNull() }
}

// Use the opaque app-private pack only inside its validated bounds. Restoring the original provider outside
// coverage keeps the user's selected online style available, while the local provider has no downloader.
DisposableEffect(map, onlineTileProvider, burningManTileProvider) {
fun reconcileTileProvider() {
val localProvider =
burningManTileProvider?.takeIf { provider ->
map.mapCenter.let { center -> provider.covers(center.latitude, center.longitude) }
}
if (localProvider != null) {
showDownloadButton = false
if (map.tileProvider !== localProvider) map.setTileProvider(localProvider)
} else {
if (map.tileProvider !== onlineTileProvider) map.setTileProvider(onlineTileProvider)
loadOnlineTileSourceBase()
}
map.invalidate()
}
val tileProviderListener =
object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
reconcileTileProvider()
return false
}

override fun onZoom(event: ZoomEvent): Boolean = false
}
reconcileTileProvider()
map.addMapListener(tileProviderListener)
onDispose {
map.removeMapListener(tileProviderListener)
if (map.tileProvider === burningManTileProvider) map.setTileProvider(onlineTileProvider)
}
}

val nodeClusterer = remember { RadiusMarkerClusterer(context) }

Expand Down Expand Up @@ -812,7 +855,8 @@ fun MapView(
onDismiss = { showMapStyleDialog = false },
onSelectMapStyle = {
mapViewModel.mapStyleId = it
map.setTileSource(loadOnlineTileSourceBase())
onlineTileProvider.setTileSource(loadOnlineTileSourceBase())
map.invalidate()
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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.offline

import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import org.osmdroid.tileprovider.MapTileProviderBase
import org.osmdroid.tileprovider.modules.IFilesystemCache
import org.osmdroid.tileprovider.tilesource.BitmapTileSourceBase
import org.osmdroid.util.MapTileIndex
import java.io.File

/** Renders the validated, app-private Burning Man PMTiles vector pack without any network fallback. */
class BurningManOsmDroidTileProvider(file: File) : MapTileProviderBase(LocalTileSource) {
private val renderer = BurningManPmtilesRenderer(file)

init {
setUseDataConnection(false)
}

override fun getMapTile(pMapTileIndex: Long): Drawable? = renderer
.tile(
MapTileIndex.getX(pMapTileIndex),
MapTileIndex.getY(pMapTileIndex),
MapTileIndex.getZoom(pMapTileIndex),
)
?.let { BitmapDrawable(null, it) }

override fun getMinimumZoomLevel(): Int = renderer.minZoom

override fun getMaximumZoomLevel(): Int = renderer.maxZoom

override fun getTileWriter(): IFilesystemCache? = null

override fun getQueueSize(): Long = 0

fun covers(latitude: Double, longitude: Double): Boolean = renderer.covers(latitude, longitude)

private object LocalTileSource : BitmapTileSourceBase("Burning Man local", 0, 15, TILE_SIZE, ".png")

private companion object {
const val TILE_SIZE = 256
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 com.google.maps.android.compose.MapType

/** Chooses one opaque map base so a local Burning Man pack never mixes with remote custom tiles. */
internal data class GoogleMapTileLayerSelection(val mapType: MapType, val attachCustomTileSource: Boolean)

internal fun googleMapTileLayerSelection(
selectedMapType: MapType,
hasCustomTileSource: Boolean,
burningManPackCoversCamera: Boolean,
): GoogleMapTileLayerSelection = GoogleMapTileLayerSelection(
mapType = if (hasCustomTileSource || burningManPackCoversCamera) MapType.NONE else selectedMapType,
attachCustomTileSource = hasCustomTileSource && !burningManPackCoversCamera,
)
47 changes: 37 additions & 10 deletions androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
Expand Down Expand Up @@ -121,6 +120,7 @@ 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.app.map.offline.BurningManGoogleTileProvider
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
Expand Down Expand Up @@ -280,6 +280,7 @@ fun MapView(

val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
val selectedBurningManPack by mapViewModel.selectedBurningManPack.collectAsStateWithLifecycle()

var mapTypeMenuExpanded by remember { mutableStateOf(false) }
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
Expand All @@ -288,6 +289,10 @@ fun MapView(
// Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
val cameraPositionState =
if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
val burningManTileProvider =
remember(selectedBurningManPack?.file) {
selectedBurningManPack?.file?.let { file -> runCatching { BurningManGoogleTileProvider(file) }.getOrNull() }
}

if (mode is GoogleMapMode.Main) {
LaunchedEffect(cameraPositionState.isMoving) {
Expand Down Expand Up @@ -550,7 +555,20 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }

val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
val burningManTileProviderForCamera =
burningManTileProvider?.takeIf { tileProvider ->
tileProvider.covers(
latitude = cameraPositionState.position.target.latitude,
longitude = cameraPositionState.position.target.longitude,
)
}
val burningManPackCoversCamera = burningManTileProviderForCamera != null
val tileLayerSelection =
googleMapTileLayerSelection(
selectedMapType = selectedGoogleMapType,
hasCustomTileSource = currentCustomTileProviderUrl != null,
burningManPackCoversCamera = burningManPackCoversCamera,
)

var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }

Expand Down Expand Up @@ -586,7 +604,7 @@ fun MapView(
),
properties =
MapProperties(
mapType = effectiveGoogleMapType,
mapType = tileLayerSelection.mapType,
isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
),
onMapClick = { latLng ->
Expand Down Expand Up @@ -619,15 +637,24 @@ fun MapView(
}
},
) {
// The validated local pack is active only while the camera remains inside its coverage boundary.
key(selectedBurningManPack?.file, burningManPackCoversCamera) {
burningManTileProviderForCamera?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = false, transparency = 0f, zIndex = -2f)
}
}

// Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
val config =
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
it.urlTemplate == url || it.localUri == url
key(currentCustomTileProviderUrl, tileLayerSelection.attachCustomTileSource) {
if (tileLayerSelection.attachCustomTileSource) {
currentCustomTileProviderUrl?.let { url ->
val config =
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
it.urlTemplate == url || it.localUri == url
}
mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.offline.BurningManPackRuntime
import org.meshtastic.feature.map.offline.SelectedBurningManPack
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
Expand Down Expand Up @@ -98,6 +100,9 @@ class MapViewModel(
radioConfigRepository,
notificationPrefs,
) {
private val burningManPackRuntime = BurningManPackRuntime.forContext(application)

val selectedBurningManPack: StateFlow<SelectedBurningManPack?> = burningManPackRuntime.coordinator.selectedPack

private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
Expand Down Expand Up @@ -368,6 +373,8 @@ class MapViewModel(
loadPersistedMapType()
}

viewModelScope.launch { burningManPackRuntime.restoreSelection() }

selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.offline

import com.google.android.gms.maps.model.Tile
import com.google.android.gms.maps.model.TileProvider
import java.io.ByteArrayOutputStream
import java.io.File

/** Renders the validated, app-private Burning Man PMTiles vector pack without any network fallback. */
class BurningManGoogleTileProvider(file: File) : TileProvider {
private val renderer = BurningManPmtilesRenderer(file)

override fun getTile(x: Int, y: Int, zoom: Int): Tile? = renderer.tile(x, y, zoom)?.let { bitmap ->
ByteArrayOutputStream().use { output ->
check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, PNG_QUALITY, output))
Tile(TILE_SIZE, TILE_SIZE, output.toByteArray())
}
} ?: TileProvider.NO_TILE

fun covers(latitude: Double, longitude: Double): Boolean = renderer.covers(latitude, longitude)

private companion object {
const val TILE_SIZE = 256
const val PNG_QUALITY = 100
}
}
30 changes: 30 additions & 0 deletions androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ package org.meshtastic.app

import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.hardware.usb.UsbManager
import android.location.Location
import android.location.LocationManager
import android.net.Uri
import android.nfc.NdefMessage
import android.nfc.NfcAdapter
Expand Down Expand Up @@ -102,8 +106,13 @@ import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.map.offline.BurningManPackRuntime
import org.meshtastic.feature.map.offline.PackLocation
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant

class MainActivity : AppCompatActivity() {
private val model: UIViewModel by viewModel()
Expand Down Expand Up @@ -181,6 +190,11 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
MeshService.startService(this)
lifecycleScope.launch {
BurningManPackRuntime.forContext(applicationContext)
.coordinator
.reconcile(Clock.System.now(), applicationContext.existingAuthorizedLocation())
}
}

override fun onResume() {
Expand Down Expand Up @@ -390,3 +404,19 @@ class MainActivity : AppCompatActivity() {
const val EXTRA_SKIP_ONBOARDING = "skip_onboarding"
}
}

/** Returns only an already-authorized, fresh system location; this never requests permission or updates. */
@Suppress("ReturnCount")
private fun Context.existingAuthorizedLocation(now: Instant = Clock.System.now()): PackLocation? {
val hasLocationPermission =
checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!hasLocationPermission) return null
val locationManager = getSystemService(LocationManager::class.java) ?: return null
val location =
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER)
.mapNotNull { provider -> runCatching { locationManager.getLastKnownLocation(provider) }.getOrNull() }
.maxByOrNull(Location::getTime) ?: return null
val timestamp = Instant.fromEpochMilliseconds(location.time)
return PackLocation(location.latitude, location.longitude, timestamp).takeIf { it.timestamp in now - 24.hours..now }
}
Loading
Loading