diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 155f645727..80935f719b 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -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 @@ -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 @@ -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) } @@ -812,7 +855,8 @@ fun MapView( onDismiss = { showMapStyleDialog = false }, onSelectMapStyle = { mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) + onlineTileProvider.setTileSource(loadOnlineTileSourceBase()) + map.invalidate() }, ) } diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt new file mode 100644 index 0000000000..bffa279fc6 --- /dev/null +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt @@ -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 . + */ +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 + } +} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt new file mode 100644 index 0000000000..147ff3bd30 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt @@ -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 . + */ +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, +) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index b1f22cfe6d..afd7f19232 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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 @@ -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 @@ -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) } @@ -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) { @@ -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?>(null) } @@ -586,7 +604,7 @@ fun MapView( ), properties = MapProperties( - mapType = effectiveGoogleMapType, + mapType = tileLayerSelection.mapType, isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted, ), onMapClick = { latLng -> @@ -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) } } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 5015ef00c0..bd6fb39b4a 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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 @@ -98,6 +100,9 @@ class MapViewModel( radioConfigRepository, notificationPrefs, ) { + private val burningManPackRuntime = BurningManPackRuntime.forContext(application) + + val selectedBurningManPack: StateFlow = burningManPackRuntime.coordinator.selectedPack private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() @@ -368,6 +373,8 @@ class MapViewModel( loadPersistedMapType() } + viewModelScope.launch { burningManPackRuntime.restoreSelection() } + selectedWaypointId.value?.let { wpId -> viewModelScope.launch { val wpMap = waypoints.first { it.containsKey(wpId) } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt new file mode 100644 index 0000000000..ec872634c7 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt @@ -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 . + */ +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 + } +} diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index c2d9864763..8490ab6938 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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 @@ -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() @@ -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() { @@ -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 } +} diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/offline/BurningManPmtilesRenderer.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/offline/BurningManPmtilesRenderer.kt new file mode 100644 index 0000000000..e125ee9434 --- /dev/null +++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/offline/BurningManPmtilesRenderer.kt @@ -0,0 +1,508 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber", "TooManyFunctions") + +package org.meshtastic.app.map.offline + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import java.io.ByteArrayInputStream +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.GZIPInputStream + +/** Renders the validated, app-private Burning Man PMTiles vector pack without any network fallback. */ +internal class BurningManPmtilesRenderer(file: File) { + private val archive = LocalPmtiles(file) + + fun tile(x: Int, y: Int, zoom: Int): Bitmap? = archive.tile(x, y, zoom)?.let(::rasterize) + + val minZoom: Int + get() = archive.minZoom + + val maxZoom: Int + get() = archive.maxZoom + + fun covers(latitude: Double, longitude: Double): Boolean = archive.covers(latitude, longitude) + + private fun rasterize(vectorTile: ByteArray): Bitmap { + val bitmap = Bitmap.createBitmap(TILE_SIZE, TILE_SIZE, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(PLAYA_COLOR) + val canvas = Canvas(bitmap) + val features = decodeVectorTile(vectorTile) + drawAreaLayer(canvas, features, "landcover", LANDCOVER_COLOR) + drawAreaLayer(canvas, features, "landuse", LANDUSE_COLOR) + drawAreaLayer(canvas, features, "water", WATER_COLOR) + features.filter { it.layer == "roads" }.forEach { drawRoad(bitmap, it) } + return bitmap + } + + private fun drawAreaLayer(canvas: Canvas, features: List, layer: String, color: Int) { + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + style = Paint.Style.FILL + } + features + .filter { it.layer == layer && it.type == GeometryType.Polygon } + .forEach { feature -> feature.paths.forEach { path -> canvas.drawPath(path.path, paint) } } + } + + private fun drawRoad(bitmap: Bitmap, feature: VectorFeature) { + if (feature.type != GeometryType.LineString) return + val kind = feature.tags["kind"] + val kindDetail = feature.tags["kind_detail"] + if (kind in EXCLUDED_PATH_KINDS || (kind == PATH_KIND && kindDetail != PEDESTRIAN_KIND_DETAIL)) return + val color = if (kind == PATH_KIND) MINOR_ROAD_COLOR else ROAD_COLOR + val width = if (kind == PATH_KIND) PEDESTRIAN_ROAD_WIDTH else ROAD_WIDTH + feature.paths.forEach { path -> + path.points.zipWithNext().forEach { (start, end) -> drawLine(bitmap, start, end, color, width) } + if (path.closed && path.points.size > 2) { + drawLine(bitmap, path.points.last(), path.points.first(), color, width) + } + } + } + + private fun drawLine(bitmap: Bitmap, start: VectorPoint, end: VectorPoint, color: Int, width: Float) { + val dx = end.x - start.x + val dy = end.y - start.y + val steps = maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)).toInt().coerceAtLeast(1) + val radius = (width / 2f).toInt().coerceAtLeast(1) + repeat(steps + 1) { index -> + val progress = index.toFloat() / steps + fillCircle(bitmap, start.x + dx * progress, start.y + dy * progress, radius, color) + } + } + + private fun fillCircle(bitmap: Bitmap, centerX: Float, centerY: Float, radius: Int, color: Int) { + val x = centerX.toInt() + val y = centerY.toInt() + for (offsetY in -radius..radius) { + for (offsetX in -radius..radius) { + if (offsetX * offsetX + offsetY * offsetY <= radius * radius) { + val pixelX = x + offsetX + val pixelY = y + offsetY + if (pixelX in 0 until TILE_SIZE && pixelY in 0 until TILE_SIZE) { + bitmap.setPixel(pixelX, pixelY, color) + } + } + } + } + } +} + +private class LocalPmtiles(private val file: File) { + private val header: PmtilesHeader + private val rootDirectory: List + + val minZoom + get() = header.minZoom + + val maxZoom + get() = header.maxZoom + + init { + require(file.isFile) { "Burning Man PMTiles file is missing" } + header = readHeader(file) + require(header.tileType == VECTOR_TILE_TYPE) { "Burning Man pack is not vector MVT" } + rootDirectory = parseDirectory(readRange(file, header.rootDirectoryOffset, header.rootDirectoryLength.toInt())) + } + + fun covers(latitude: Double, longitude: Double): Boolean = + longitude in header.minLongitude..header.maxLongitude && latitude in header.minLatitude..header.maxLatitude + + @Suppress("ReturnCount") + fun tile(x: Int, y: Int, zoom: Int): ByteArray? { + if (zoom !in header.minZoom..header.maxZoom || x !in 0 until (1 shl zoom) || y !in 0 until (1 shl zoom)) { + return null + } + val entry = findEntry(rootDirectory, zxyToTileId(zoom, x, y)) ?: return null + if (entry.runLength == 0) return null + val compressed = readRange(file, header.tileDataOffset + entry.offset, entry.length) + return when (header.tileCompression) { + NO_COMPRESSION -> compressed + GZIP_COMPRESSION -> GZIPInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + else -> null + } + } +} + +private data class PmtilesHeader( + val rootDirectoryOffset: Long, + val rootDirectoryLength: Long, + val tileDataOffset: Long, + val tileCompression: Int, + val tileType: Int, + val minZoom: Int, + val maxZoom: Int, + val minLongitude: Double, + val minLatitude: Double, + val maxLongitude: Double, + val maxLatitude: Double, +) + +private data class PmtilesDirectoryEntry(val tileId: Long, val offset: Long, val length: Int, val runLength: Int) + +private fun readHeader(file: File): PmtilesHeader { + val bytes = readRange(file, 0, PMTILES_HEADER_SIZE) + require(bytes.copyOfRange(0, 7).decodeToString() == "PMTiles" && bytes[7] == PMTILES_VERSION.toByte()) { + "Invalid PMTiles v3 file" + } + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + return PmtilesHeader( + rootDirectoryOffset = buffer.getLong(ROOT_DIRECTORY_OFFSET), + rootDirectoryLength = buffer.getLong(ROOT_DIRECTORY_LENGTH), + tileDataOffset = buffer.getLong(TILE_DATA_OFFSET), + tileCompression = bytes[TILE_COMPRESSION_OFFSET].toInt() and 0xff, + tileType = bytes[TILE_TYPE_OFFSET].toInt() and 0xff, + minZoom = bytes[MIN_ZOOM_OFFSET].toInt() and 0xff, + maxZoom = bytes[MAX_ZOOM_OFFSET].toInt() and 0xff, + minLongitude = buffer.getInt(MIN_LONGITUDE_OFFSET) / COORDINATE_SCALE, + minLatitude = buffer.getInt(MIN_LATITUDE_OFFSET) / COORDINATE_SCALE, + maxLongitude = buffer.getInt(MAX_LONGITUDE_OFFSET) / COORDINATE_SCALE, + maxLatitude = buffer.getInt(MAX_LATITUDE_OFFSET) / COORDINATE_SCALE, + ) +} + +private fun readRange(file: File, offset: Long, length: Int): ByteArray { + require(offset >= 0 && length >= 0 && offset + length <= file.length()) { "Invalid PMTiles range" } + return RandomAccessFile(file, "r").use { input -> + input.seek(offset) + ByteArray(length).also(input::readFully) + } +} + +private fun parseDirectory(bytes: ByteArray): List { + val reader = VarintReader(bytes) + val count = reader.read().toInt() + require(count in 1..MAX_DIRECTORY_ENTRIES) { "Invalid PMTiles directory" } + val tileIds = LongArray(count) + var previousId = 0L + repeat(count) { index -> + previousId += reader.read() + tileIds[index] = previousId + } + val runLengths = IntArray(count) { reader.read().toInt() } + val lengths = IntArray(count) { reader.read().toInt() } + val offsets = LongArray(count) + repeat(count) { index -> + val encoded = reader.read() + offsets[index] = if (encoded == 0L && index > 0) offsets[index - 1] + lengths[index - 1] else encoded - 1 + } + return List(count) { index -> + PmtilesDirectoryEntry(tileIds[index], offsets[index], lengths[index], runLengths[index]) + } +} + +private fun findEntry(entries: List, tileId: Long): PmtilesDirectoryEntry? { + var low = 0 + var high = entries.lastIndex + while (low <= high) { + val middle = (low + high) ushr 1 + when { + tileId > entries[middle].tileId -> low = middle + 1 + tileId < entries[middle].tileId -> high = middle - 1 + else -> return entries[middle] + } + } + return entries.getOrNull(high)?.takeIf { entry -> entry.runLength == 0 || tileId - entry.tileId < entry.runLength } +} + +private fun zxyToTileId(zoom: Int, x: Int, y: Int): Long { + var accumulated = 0L + repeat(zoom) { index -> accumulated += 1L shl (2 * index) } + var xx = x + var yy = y + var distance = 0L + var size = if (zoom == 0) 0 else 1 shl (zoom - 1) + while (size > 0) { + val rx = if ((xx and size) != 0) 1 else 0 + val ry = if ((yy and size) != 0) 1 else 0 + distance += size.toLong() * size * ((3 * rx) xor ry) + if (ry == 0) { + if (rx == 1) { + xx = size - 1 - xx + yy = size - 1 - yy + } + val temporary = xx + xx = yy + yy = temporary + } + size /= 2 + } + return accumulated + distance +} + +private data class VectorFeature( + val layer: String, + val tags: Map, + val type: GeometryType, + val paths: List, +) + +private data class VectorPoint(val x: Float, val y: Float) + +private data class VectorPath(val path: Path, val points: MutableList, var closed: Boolean = false) + +private enum class GeometryType { + Unknown, + Point, + LineString, + Polygon, +} + +private fun decodeVectorTile(bytes: ByteArray): List { + val reader = WireReader(bytes) + val features = mutableListOf() + while (!reader.isAtEnd) { + val tag = reader.readVarint().toInt() + if (tag ushr 3 == TILE_LAYER_FIELD && tag and WIRE_TYPE_MASK == LENGTH_DELIMITED) { + features += decodeLayer(reader.readBytes()) + } else { + reader.skip(tag and WIRE_TYPE_MASK) + } + } + return features +} + +private fun decodeLayer(bytes: ByteArray): List { + val reader = WireReader(bytes) + var name = "" + var extent = DEFAULT_EXTENT + val encodedFeatures = mutableListOf() + val keys = mutableListOf() + val values = mutableListOf() + while (!reader.isAtEnd) { + val tag = reader.readVarint().toInt() + when (tag ushr 3) { + LAYER_NAME_FIELD -> name = reader.readBytes().decodeToString() + LAYER_FEATURE_FIELD -> encodedFeatures += reader.readBytes() + LAYER_KEY_FIELD -> keys += reader.readBytes().decodeToString() + LAYER_VALUE_FIELD -> values += decodeValue(reader.readBytes()) + LAYER_EXTENT_FIELD -> extent = reader.readVarint().toInt() + else -> reader.skip(tag and WIRE_TYPE_MASK) + } + } + return encodedFeatures.mapNotNull { decodeFeature(name, it, keys, values, extent) } +} + +private fun decodeValue(bytes: ByteArray): String { + val reader = WireReader(bytes) + while (!reader.isAtEnd) { + val tag = reader.readVarint().toInt() + if (tag ushr 3 == VALUE_STRING_FIELD && tag and WIRE_TYPE_MASK == LENGTH_DELIMITED) { + return reader.readBytes().decodeToString() + } + reader.skip(tag and WIRE_TYPE_MASK) + } + return "" +} + +private fun decodeFeature( + layer: String, + bytes: ByteArray, + keys: List, + values: List, + extent: Int, +): VectorFeature? { + val reader = WireReader(bytes) + var tags = emptyList() + var type = GeometryType.Unknown + var geometry = byteArrayOf() + while (!reader.isAtEnd) { + val tag = reader.readVarint().toInt() + when (tag ushr 3) { + FEATURE_TAGS_FIELD -> tags = WireReader(reader.readBytes()).readAllVarints() + + FEATURE_TYPE_FIELD -> + type = GeometryType.entries.getOrElse(reader.readVarint().toInt()) { GeometryType.Unknown } + + FEATURE_GEOMETRY_FIELD -> geometry = reader.readBytes() + + else -> reader.skip(tag and WIRE_TYPE_MASK) + } + } + if (type == GeometryType.Unknown || geometry.isEmpty()) return null + return VectorFeature(layer, decodeTags(tags, keys, values), type, decodePaths(geometry, extent)) +} + +private fun decodeTags(tags: List, keys: List, values: List): Map = tags + .chunked(2) + .mapNotNull { pair -> + if (pair.size == 2) { + keys.getOrNull(pair[0].toInt())?.let { key -> values.getOrNull(pair[1].toInt())?.let { key to it } } + } else { + null + } + } + .toMap() + +private fun decodePaths(bytes: ByteArray, extent: Int): List { + val reader = WireReader(bytes) + val paths = mutableListOf() + var currentPath: VectorPath? = null + var x = 0 + var y = 0 + while (!reader.isAtEnd) { + val commandInteger = reader.readVarint().toInt() + val command = commandInteger and COMMAND_MASK + repeat(commandInteger ushr COMMAND_SHIFT) { + when (command) { + MOVE_TO -> { + x += zigZagDecode(reader.readVarint()) + y += zigZagDecode(reader.readVarint()) + val point = VectorPoint(scale(x, extent), scale(y, extent)) + currentPath = VectorPath(Path().apply { moveTo(point.x, point.y) }, mutableListOf(point)) + paths += checkNotNull(currentPath) + } + + LINE_TO -> { + x += zigZagDecode(reader.readVarint()) + y += zigZagDecode(reader.readVarint()) + val point = VectorPoint(scale(x, extent), scale(y, extent)) + currentPath?.apply { + path.lineTo(point.x, point.y) + points += point + } + } + + CLOSE_PATH -> + currentPath?.apply { + path.close() + closed = true + } + + else -> return paths + } + } + } + return paths +} + +private fun scale(value: Int, extent: Int): Float = value.toFloat() * TILE_SIZE / extent + +private fun zigZagDecode(value: Long): Int = ((value ushr 1) xor -(value and 1)).toInt() + +private class WireReader(private val bytes: ByteArray) { + private var index = 0 + val isAtEnd + get() = index >= bytes.size + + fun readVarint(): Long { + var value = 0L + var shift = 0 + while (index < bytes.size && shift < Long.SIZE_BITS) { + val next = bytes[index++].toInt() and 0xff + value = value or ((next and 0x7f).toLong() shl shift) + if (next and 0x80 == 0) return value + shift += 7 + } + throw IllegalArgumentException("Invalid protobuf varint") + } + + fun readBytes(): ByteArray { + val length = readVarint().toInt() + require(length >= 0 && index + length <= bytes.size) { "Invalid protobuf bytes" } + return bytes.copyOfRange(index, index + length).also { index += length } + } + + fun readAllVarints(): List = buildList { while (!isAtEnd) add(readVarint()) } + + fun skip(wireType: Int) { + when (wireType) { + VARINT -> readVarint() + LENGTH_DELIMITED -> readBytes() + FIXED_64 -> index += Long.SIZE_BYTES + FIXED_32 -> index += Int.SIZE_BYTES + else -> throw IllegalArgumentException("Unsupported protobuf wire type") + } + require(index <= bytes.size) { "Invalid protobuf field" } + } +} + +private class VarintReader(private val bytes: ByteArray) { + private var index = 0 + + fun read(): Long { + var value = 0L + var shift = 0 + while (index < bytes.size && shift < Long.SIZE_BITS) { + val next = bytes[index++].toInt() and 0xff + value = value or ((next and 0x7f).toLong() shl shift) + if (next and 0x80 == 0) return value + shift += 7 + } + throw IllegalArgumentException("Invalid PMTiles varint") + } +} + +private const val PMTILES_HEADER_SIZE = 127 +private const val PMTILES_VERSION = 3 +private const val ROOT_DIRECTORY_OFFSET = 8 +private const val ROOT_DIRECTORY_LENGTH = 16 +private const val TILE_DATA_OFFSET = 56 +private const val TILE_COMPRESSION_OFFSET = 98 +private const val TILE_TYPE_OFFSET = 99 +private const val MIN_ZOOM_OFFSET = 100 +private const val MAX_ZOOM_OFFSET = 101 +private const val MIN_LONGITUDE_OFFSET = 102 +private const val MIN_LATITUDE_OFFSET = 106 +private const val MAX_LONGITUDE_OFFSET = 110 +private const val MAX_LATITUDE_OFFSET = 114 +private const val VECTOR_TILE_TYPE = 1 +private const val NO_COMPRESSION = 1 +private const val GZIP_COMPRESSION = 2 +private const val MAX_DIRECTORY_ENTRIES = 10_000_000 +private const val COORDINATE_SCALE = 10_000_000.0 +private const val TILE_LAYER_FIELD = 3 +private const val LAYER_NAME_FIELD = 1 +private const val LAYER_FEATURE_FIELD = 2 +private const val LAYER_KEY_FIELD = 3 +private const val LAYER_VALUE_FIELD = 4 +private const val LAYER_EXTENT_FIELD = 5 +private const val VALUE_STRING_FIELD = 1 +private const val FEATURE_TAGS_FIELD = 2 +private const val FEATURE_TYPE_FIELD = 3 +private const val FEATURE_GEOMETRY_FIELD = 4 +private const val DEFAULT_EXTENT = 4096 +private const val WIRE_TYPE_MASK = 7 +private const val VARINT = 0 +private const val FIXED_64 = 1 +private const val LENGTH_DELIMITED = 2 +private const val FIXED_32 = 5 +private const val COMMAND_MASK = 7 +private const val COMMAND_SHIFT = 3 +private const val MOVE_TO = 1 +private const val LINE_TO = 2 +private const val CLOSE_PATH = 7 +private const val TILE_SIZE = 256 +private const val PATH_KIND = "path" +private const val PEDESTRIAN_KIND_DETAIL = "pedestrian" +private val EXCLUDED_PATH_KINDS = setOf("footway", "cycleway", "track") +private val PLAYA_COLOR = Color.rgb(234, 234, 234) +private val LANDCOVER_COLOR = Color.rgb(228, 221, 202) +private val LANDUSE_COLOR = Color.rgb(238, 228, 196) +private val WATER_COLOR = Color.rgb(144, 198, 231) +private val ROAD_COLOR = Color.rgb(255, 255, 255) +private val MINOR_ROAD_COLOR = Color.rgb(254, 181, 196) +private const val ROAD_WIDTH = 4f +private const val PEDESTRIAN_ROAD_WIDTH = 3f diff --git a/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProviderTest.kt b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProviderTest.kt new file mode 100644 index 0000000000..8305c9cc5a --- /dev/null +++ b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProviderTest.kt @@ -0,0 +1,142 @@ +/* + * 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 . + */ +package org.meshtastic.app.map.offline + +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.osmdroid.util.MapTileIndex +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class BurningManOsmDroidTileProviderTest { + + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test + fun `renders a local Protomaps pedestrian street without a network fallback`() { + val provider = BurningManOsmDroidTileProvider(writePack(vectorTile())) + + val drawable = checkNotNull(provider.getMapTile(MapTileIndex.getTileIndex(0, 0, 0))) as BitmapDrawable + val bitmap = checkNotNull(drawable.bitmap) + + assertEquals(256, bitmap.width) + assertEquals(256, bitmap.height) + assertEquals(255, Color.alpha(bitmap.getPixel(128, 128))) + assertEquals(Color.rgb(254, 181, 196), bitmap.getPixel(128, 128)) + assertEquals(Color.rgb(234, 234, 234), bitmap.getPixel(32, 64)) + assertFalse(provider.useDataConnection()) + } + + @Test + fun `returns no drawable outside the local pack`() { + val provider = BurningManOsmDroidTileProvider(writePack(vectorTile())) + + assertNull(provider.getMapTile(MapTileIndex.getTileIndex(1, 1, 1))) + } + + @Test + fun `reports coverage from the local pack bounds`() { + val provider = BurningManOsmDroidTileProvider(writePack(vectorTile())) + + assertTrue(provider.covers(0.0, 0.0)) + assertFalse(provider.covers(0.0, 1.0)) + } + + private fun writePack(tile: ByteArray): File { + val directory = byteArrayOf(1, 0, 1) + varint(tile.size) + byteArrayOf(1) + val metadata = "{}".encodeToByteArray() + val header = ByteBuffer.allocate(PMTILES_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN) + header.put("PMTiles".encodeToByteArray()) + header.put(3) + header.putLong(PMTILES_HEADER_SIZE.toLong()) + header.putLong(directory.size.toLong()) + header.putLong((PMTILES_HEADER_SIZE + directory.size).toLong()) + header.putLong(metadata.size.toLong()) + header.putLong((PMTILES_HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(0) + header.putLong((PMTILES_HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(tile.size.toLong()) + header.putLong(1) + header.putLong(1) + header.putLong(1) + header.put(1) + header.put(1) + header.put(1) + header.put(1) + header.put(0) + header.put(0) + + return temporaryFolder.newFile("burning-man.pmtiles").apply { + writeBytes(header.array() + directory + metadata + tile) + } + } + + private fun vectorTile(): ByteArray = field(3, roadsLayer()) + + private fun roadsLayer(): ByteArray = scalar(15, 2) + + field(1, "roads".encodeToByteArray()) + + field(2, roadFeature(kindDetail = 1, geometry = verticalLine())) + + field(2, roadFeature(kindDetail = 2, geometry = footwayLine())) + + field(3, "kind".encodeToByteArray()) + + field(3, "kind_detail".encodeToByteArray()) + + field(4, field(1, "path".encodeToByteArray())) + + field(4, field(1, "pedestrian".encodeToByteArray())) + + field(4, field(1, "footway".encodeToByteArray())) + + scalar(5, 4096) + + private fun roadFeature(kindDetail: Int, geometry: ByteArray): ByteArray = + field(2, byteArrayOf(0, 0, 1, kindDetail.toByte())) + scalar(3, 2) + field(4, geometry) + + private fun verticalLine(): ByteArray = packed(9, 4096, 0, 10, 0, 8192) + + private fun footwayLine(): ByteArray = packed(9, 0, 2048, 10, 2048, 0) + + private fun packed(vararg values: Int): ByteArray = + values.fold(byteArrayOf()) { bytes, value -> bytes + varint(value) } + + private fun field(number: Int, bytes: ByteArray): ByteArray = + varint((number shl 3) or 2) + varint(bytes.size) + bytes + + private fun scalar(number: Int, value: Int): ByteArray = varint(number shl 3) + varint(value) + + private fun varint(value: Int): ByteArray { + var remaining = value + val bytes = mutableListOf() + do { + var next = remaining and 0x7f + remaining = remaining ushr 7 + if (remaining > 0) next = next or 0x80 + bytes += next.toByte() + } while (remaining > 0) + return bytes.toByteArray() + } + + private companion object { + const val PMTILES_HEADER_SIZE = 127 + } +} diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelectionTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelectionTest.kt new file mode 100644 index 0000000000..0afc021e1c --- /dev/null +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelectionTest.kt @@ -0,0 +1,52 @@ +/* + * 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 . + */ +package org.meshtastic.app.map + +import com.google.maps.android.compose.MapType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GoogleMapTileLayerSelectionTest { + + @Test + fun `local Burning Man pack takes precedence over a custom tile source inside coverage`() { + val selection = + googleMapTileLayerSelection( + selectedMapType = MapType.HYBRID, + hasCustomTileSource = true, + burningManPackCoversCamera = true, + ) + + assertEquals(MapType.NONE, selection.mapType) + assertFalse(selection.attachCustomTileSource) + } + + @Test + fun `custom tile source remains available outside Burning Man coverage`() { + val selection = + googleMapTileLayerSelection( + selectedMapType = MapType.HYBRID, + hasCustomTileSource = true, + burningManPackCoversCamera = false, + ) + + assertEquals(MapType.NONE, selection.mapType) + assertTrue(selection.attachCustomTileSource) + } +} diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt new file mode 100644 index 0000000000..c6bf4c19ed --- /dev/null +++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt @@ -0,0 +1,132 @@ +/* + * 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 . + */ +package org.meshtastic.app.map.offline + +import android.graphics.BitmapFactory +import android.graphics.Color +import com.google.android.gms.maps.model.TileProvider +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.assertEquals +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +class BurningManGoogleTileProviderTest { + + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test + fun `renders a Protomaps pedestrian street while excluding footways`() { + val provider = BurningManGoogleTileProvider(writePack(vectorTile())) + + val tile = checkNotNull(provider.getTile(0, 0, 0)) + val data = checkNotNull(tile.data) + val bitmap = checkNotNull(BitmapFactory.decodeByteArray(data, 0, data.size)) + + assertEquals(256, tile.width) + assertEquals(256, tile.height) + assertEquals(255, Color.alpha(bitmap.getPixel(128, 128))) + assertEquals(Color.rgb(254, 181, 196), bitmap.getPixel(128, 128)) + assertEquals(Color.rgb(234, 234, 234), bitmap.getPixel(32, 64)) + } + + @Test + fun `returns no tile outside the local pack`() { + val provider = BurningManGoogleTileProvider(writePack(vectorTile())) + + assertSame(TileProvider.NO_TILE, provider.getTile(1, 0, 1)) + } + + private fun writePack(tile: ByteArray): File { + val directory = byteArrayOf(1, 0, 1) + varint(tile.size) + byteArrayOf(1) + val metadata = "{}".encodeToByteArray() + val header = ByteBuffer.allocate(PMTILES_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN) + header.put("PMTiles".encodeToByteArray()) + header.put(3) + header.putLong(PMTILES_HEADER_SIZE.toLong()) + header.putLong(directory.size.toLong()) + header.putLong((PMTILES_HEADER_SIZE + directory.size).toLong()) + header.putLong(metadata.size.toLong()) + header.putLong((PMTILES_HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(0) + header.putLong((PMTILES_HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(tile.size.toLong()) + header.putLong(1) + header.putLong(1) + header.putLong(1) + header.put(1) + header.put(1) + header.put(1) + header.put(1) + header.put(0) + header.put(0) + + return temporaryFolder.newFile("burning-man.pmtiles").apply { + writeBytes(header.array() + directory + metadata + tile) + } + } + + private fun vectorTile(): ByteArray = field(3, roadsLayer()) + + private fun roadsLayer(): ByteArray = scalar(15, 2) + + field(1, "roads".encodeToByteArray()) + + field(2, roadFeature(kindDetail = 1, geometry = verticalLine())) + + field(2, roadFeature(kindDetail = 2, geometry = footwayLine())) + + field(3, "kind".encodeToByteArray()) + + field(3, "kind_detail".encodeToByteArray()) + + field(4, field(1, "path".encodeToByteArray())) + + field(4, field(1, "pedestrian".encodeToByteArray())) + + field(4, field(1, "footway".encodeToByteArray())) + + scalar(5, 4096) + + private fun roadFeature(kindDetail: Int, geometry: ByteArray): ByteArray = + field(2, byteArrayOf(0, 0, 1, kindDetail.toByte())) + scalar(3, 2) + field(4, geometry) + + private fun verticalLine(): ByteArray = packed(9, 4096, 0, 10, 0, 8192) + + private fun footwayLine(): ByteArray = packed(9, 0, 2048, 10, 2048, 0) + + private fun packed(vararg values: Int): ByteArray = + values.fold(byteArrayOf()) { bytes, value -> bytes + varint(value) } + + private fun field(number: Int, bytes: ByteArray): ByteArray = + varint((number shl 3) or 2) + varint(bytes.size) + bytes + + private fun scalar(number: Int, value: Int): ByteArray = varint(number shl 3) + varint(value) + + private fun varint(value: Int): ByteArray { + var remaining = value + val bytes = mutableListOf() + do { + var next = remaining and 0x7f + remaining = remaining ushr 7 + if (remaining > 0) next = next or 0x80 + bytes += next.toByte() + } while (remaining > 0) + return bytes.toByteArray() + } + + private companion object { + const val PMTILES_HEADER_SIZE = 127 + } +} diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinatorTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinatorTest.kt new file mode 100644 index 0000000000..9e55b90330 --- /dev/null +++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinatorTest.kt @@ -0,0 +1,335 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class BurningManPackCoordinatorTest { + + @Test + fun `first reconciliation installs once to the app private pack path`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore() + val downloader = FakeDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + val selected = coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) + + val destination = File(filesDirectory, "offline/burning-man-2026.pmtiles") + assertEquals(destination, selected?.file) + assertEquals(listOf(destination), downloader.destinations) + assertEquals("burning-man-2026", store.record?.packId) + assertEquals("20260902", store.record?.sourceBuild) + assertEquals(REPLICATION_TIME, store.record?.replicationTimestamp) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `an installed validated manifest returns the selected pack without downloading`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val file = File(filesDirectory, "offline/burning-man-2026.pmtiles") + writeValidatedPack(file) + val store = FakeStore(record = installedRecord()) + val downloader = FakeDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + assertEquals(file, coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION)?.file) + assertTrue(downloader.destinations.isEmpty()) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `lower-detail pack is rejected and deleted on install`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore() + val downloader = FakeDownloader(maxZoom = 14) + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + assertNull(coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION)) + assertFalse(File(filesDirectory, "offline/burning-man-2026.pmtiles").exists()) + assertNull(store.record) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `lower-detail persisted pack is rejected and deleted on restore`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val file = File(filesDirectory, "offline/burning-man-2026.pmtiles") + writeValidatedPack(file, maxZoom = 14) + val store = FakeStore(record = installedRecord()) + val coordinator = BurningManPackCoordinator(filesDirectory, store, FakeDownloader()) + + try { + assertNull(coordinator.restoreValidatedSelection()) + assertFalse(file.exists()) + assertNull(store.record) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `user removal suppresses a later install`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore() + val downloader = FakeDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) + coordinator.removeByUser() + + assertNull(coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION)) + assertEquals(1, downloader.destinations.size) + assertTrue(store.record?.userSuppressed == true) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `missing selected file clears selection and a later reconciliation retries`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore(record = installedRecord()) + val downloader = FakeDownloader(failFirstDownload = true) + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + assertNull(coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION)) + assertNull(store.record) + + assertTrue(coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION)?.file?.isFile == true) + assertEquals(2, downloader.destinations.size) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `automatic cleanup removes a pack without user suppression`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val file = File(filesDirectory, "offline/burning-man-2026.pmtiles") + writeValidatedPack(file) + val store = FakeStore(record = installedRecord()) + val coordinator = BurningManPackCoordinator(filesDirectory, store, FakeDownloader()) + + try { + assertNull(coordinator.reconcile(AUTOMATIC_CLEANUP_TIME, null)) + assertFalse(file.exists()) + assertNull(store.record) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `user removal during installation leaves the pack suppressed and absent`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore() + val downloader = BlockingDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + val installation = async { coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) } + downloader.started.await() + val removal = async { coordinator.removeByUser() } + runCurrent() + downloader.release.complete(Unit) + installation.await() + removal.await() + + assertNull(coordinator.selectedPack.value) + assertTrue(store.record?.userSuppressed == true) + assertFalse(File(filesDirectory, "offline/burning-man-2026.pmtiles").exists()) + } finally { + downloader.release.complete(Unit) + filesDirectory.deleteRecursively() + } + } + + @Test + fun `automatic cleanup queued during installation leaves no selected pack`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val store = FakeStore() + val downloader = BlockingDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, store, downloader) + + try { + val installation = async { coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) } + downloader.started.await() + val cleanup = async { coordinator.reconcile(AUTOMATIC_CLEANUP_TIME, null) } + runCurrent() + downloader.release.complete(Unit) + installation.await() + cleanup.await() + + assertNull(coordinator.selectedPack.value) + assertNull(store.record) + assertFalse(File(filesDirectory, "offline/burning-man-2026.pmtiles").exists()) + } finally { + downloader.release.complete(Unit) + filesDirectory.deleteRecursively() + } + } + + @Test + fun `concurrent reconciliations perform one installation`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val downloader = BlockingDownloader() + val coordinator = BurningManPackCoordinator(filesDirectory, FakeStore(), downloader) + + try { + val first = async { coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) } + downloader.started.await() + val second = async { coordinator.reconcile(INSTALL_TIME, INSIDE_LOCATION) } + + try { + runCurrent() + assertEquals(1, downloader.destinations.size) + } finally { + downloader.release.complete(Unit) + } + + assertEquals(first.await()?.file, second.await()?.file) + assertEquals(1, downloader.destinations.size) + } finally { + downloader.release.complete(Unit) + filesDirectory.deleteRecursively() + } + } + + @Test + fun `mismatched persisted pack identity clears the manifest and file`() = runTest { + val filesDirectory = createTempDirectory("burning-man-pack").toFile() + val file = File(filesDirectory, "offline/burning-man-2026.pmtiles") + writeValidatedPack(file) + val store = FakeStore(record = installedRecord().copy(packId = "other-pack")) + val coordinator = BurningManPackCoordinator(filesDirectory, store, FakeDownloader()) + + try { + assertNull(coordinator.reconcile(INSTALL_TIME, null)) + assertNull(store.record) + assertFalse(file.exists()) + } finally { + filesDirectory.deleteRecursively() + } + } + + private class FakeStore(record: BurningManPackRecord? = null) : BurningManPackStore { + var record: BurningManPackRecord? = record + + override fun load(): BurningManPackRecord? = record + + override fun save(record: BurningManPackRecord?) { + this.record = record + } + } + + private class FakeDownloader(private var failFirstDownload: Boolean = false, private val maxZoom: Int = 15) : + BurningManPackDownloader { + val destinations = mutableListOf() + + override suspend fun download(bounds: GeoBounds, destination: File): DownloadedPack { + destinations += destination + if (failFirstDownload) { + failFirstDownload = false + throw IllegalStateException("download failed") + } + writeValidatedPack(destination, maxZoom) + return DownloadedPack("20260902", destination) + } + } + + private class BlockingDownloader : BurningManPackDownloader { + val destinations = mutableListOf() + val started = CompletableDeferred() + val release = CompletableDeferred() + + override suspend fun download(bounds: GeoBounds, destination: File): DownloadedPack { + destinations += destination + started.complete(Unit) + release.await() + writeValidatedPack(destination) + return DownloadedPack("20260902", destination) + } + } + + private fun installedRecord() = BurningManPackRecord( + packId = "burning-man-2026", + sourceBuild = "20260901", + replicationTimestamp = REPLICATION_TIME, + installedAt = INSTALL_TIME, + userSuppressed = false, + ) + + private companion object { + val INSTALL_TIME: Instant = Instant.parse("2026-09-07T20:00:00Z") + val AUTOMATIC_CLEANUP_TIME: Instant = Instant.parse("2026-09-12T07:00:00Z") + val INSIDE_LOCATION = + PackLocation(latitude = 40.7864, longitude = -119.2065, timestamp = Instant.parse("2026-09-07T19:00:00Z")) + const val REPLICATION_TIME = "20260901" + } +} + +private fun writeValidatedPack(destination: File, maxZoom: Int = 15) { + destination.parentFile?.mkdirs() + val metadata = "{\"$REPLICATION_TIME_KEY\":\"20260901\"}".encodeToByteArray() + val root = byteArrayOf(1, 0, 1, 1, 1) + val header = ByteBuffer.allocate(PmtilesV3Reader.HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN) + header.put("PMTiles".encodeToByteArray()) + header.put(3) + header.putLong(PmtilesV3Reader.HEADER_SIZE.toLong()) + header.putLong(root.size.toLong()) + header.putLong((PmtilesV3Reader.HEADER_SIZE + root.size).toLong()) + header.putLong(metadata.size.toLong()) + header.putLong((PmtilesV3Reader.HEADER_SIZE + root.size + metadata.size).toLong()) + header.putLong(0) + header.putLong((PmtilesV3Reader.HEADER_SIZE + root.size + metadata.size).toLong()) + header.putLong(1) + header.putLong(1) + header.putLong(1) + header.putLong(1) + header.put(0) + header.put(PmtilesCompression.None.value) + header.put(PmtilesCompression.None.value) + header.put(PmtilesTileType.Mvt.value) + header.put(0) + header.put(maxZoom.toByte()) + destination.writeBytes(header.array() + root + metadata + byteArrayOf(0)) +} diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntimeTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntimeTest.kt new file mode 100644 index 0000000000..8a1ba30ab1 --- /dev/null +++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntimeTest.kt @@ -0,0 +1,133 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import android.content.Context +import android.content.ContextWrapper +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.path.createTempDirectory +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.time.Instant + +class BurningManPackRuntimeTest { + + @After + fun resetRuntime() { + BurningManPackRuntime.resetForTest() + } + + @Test + fun `application consumers share one coordinator and restore one validated selection`() = runTest { + val context = ApplicationContext() + val filesDirectory = createTempDirectory("burning-man-runtime").toFile() + val store = CountingStore(installedRecord()) + writeValidatedPack(File(filesDirectory, "offline/burning-man-2026.pmtiles")) + BurningManPackRuntime.installFactoryForTest { BurningManPackCoordinator(filesDirectory, store) } + + try { + val firstConsumer = BurningManPackRuntime.forContext(context) + val secondConsumer = BurningManPackRuntime.forContext(context.applicationContext) + firstConsumer.restoreSelection() + secondConsumer.restoreSelection() + + assertSame(firstConsumer.coordinator, secondConsumer.coordinator) + assertEquals(firstConsumer.coordinator.selectedPack.value, secondConsumer.coordinator.selectedPack.value) + assertEquals(1, store.loadCalls) + } finally { + filesDirectory.deleteRecursively() + } + } + + @Test + fun `runtime keeps the factory captured during singleton creation`() { + val context = ApplicationContext() + val filesDirectory = createTempDirectory("burning-man-first").toFile() + try { + val firstCoordinator = BurningManPackCoordinator(filesDirectory, EmptyStore) + BurningManPackRuntime.installFactoryForTest { firstCoordinator } + val runtime = BurningManPackRuntime.forContext(context) + + BurningManPackRuntime.resetForTest() + BurningManPackRuntime.installFactoryForTest { error("A reset must not alter an existing runtime") } + + assertSame(firstCoordinator, runtime.coordinator) + } finally { + filesDirectory.deleteRecursively() + } + } + + private fun installedRecord() = BurningManPackRecord( + packId = "burning-man-2026", + sourceBuild = "20260720", + replicationTimestamp = "20260720", + installedAt = Instant.parse("2026-08-25T00:00:00Z"), + userSuppressed = false, + ) + + private fun writeValidatedPack(file: File) { + file.parentFile?.mkdirs() + val metadata = "{\"$REPLICATION_TIME_KEY\":\"20260720\"}".encodeToByteArray() + val header = ByteBuffer.allocate(PmtilesV3Reader.HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN) + header.put("PMTiles".encodeToByteArray()) + header.put(3) + header.putLong(PmtilesV3Reader.HEADER_SIZE.toLong()) + header.putLong(0) + header.putLong(PmtilesV3Reader.HEADER_SIZE.toLong()) + header.putLong(metadata.size.toLong()) + header.putLong((PmtilesV3Reader.HEADER_SIZE + metadata.size).toLong()) + header.putLong(0) + header.putLong((PmtilesV3Reader.HEADER_SIZE + metadata.size).toLong()) + header.putLong(0) + header.putLong(0) + header.putLong(0) + header.putLong(0) + header.put(PmtilesCompression.None.value) + header.put(PmtilesCompression.None.value) + header.put(PmtilesCompression.None.value) + header.put(PmtilesTileType.Mvt.value) + header.put(0) + header.put(15) + file.writeBytes(header.array() + metadata) + } + + private class CountingStore(private val record: BurningManPackRecord) : BurningManPackStore { + var loadCalls = 0 + + override fun load(): BurningManPackRecord? { + loadCalls++ + return record + } + + override fun save(record: BurningManPackRecord?) = Unit + } + + private class ApplicationContext : ContextWrapper(null) { + override fun getApplicationContext(): Context = this + } + + private data object EmptyStore : BurningManPackStore { + override fun load(): BurningManPackRecord? = null + + override fun save(record: BurningManPackRecord?) = Unit + } +} diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloaderTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloaderTest.kt new file mode 100644 index 0000000000..0cc0457cc1 --- /dev/null +++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloaderTest.kt @@ -0,0 +1,258 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProtomapsRegionDownloaderTest { + + @Test + fun `downloads newest range-capable UTC build and writes MVT PMTiles`() = runTest { + val today = LocalDate(2026, 9, 2) + val newestBuild = today.minus(DatePeriod(days = 1)) + val resolver = ProtomapsArchiveResolver { date -> "https://example.test/$date.pmtiles" } + val fakeClient = + FakeRangeClient( + mapOf( + resolver.urlFor(today) to FakeArchive(statusCode = 200, bytes = vectorPmtiles()), + resolver.urlFor(newestBuild) to FakeArchive(statusCode = 206, bytes = vectorPmtiles()), + ), + ) + val directory = Files.createTempDirectory("protomaps-region-test").toFile() + val destination = File(directory, "region.pmtiles") + + try { + val pack = + ProtomapsRegionDownloader(archiveResolver = resolver, rangeClient = fakeClient, utcDate = { today }) + .download( + bounds = GeoBounds(minLon = -119.21, minLat = 40.78, maxLon = -119.20, maxLat = 40.79), + destination = destination, + ) + + assertEquals("20260901", pack.sourceBuild) + assertEquals(destination, pack.file) + assertTrue(destination.isFile) + assertEquals(PmtilesTileType.Mvt, PmtilesV3Reader(destination).header.tileType) + assertEquals("20260901", PmtilesV3Reader(destination).metadata[REPLICATION_TIME_KEY]) + assertEquals(listOf(resolver.urlFor(today), resolver.urlFor(newestBuild)), fakeClient.probedUrls) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `retains z15 streets within Burning Man pack limits`() = runTest { + val today = LocalDate(2026, 9, 2) + val resolver = ProtomapsArchiveResolver { date -> "https://example.test/$date.pmtiles" } + val fakeClient = + FakeRangeClient( + mapOf( + resolver.urlFor(today) to + FakeArchive(statusCode = 206, bytes = vectorPmtiles(maxZoom = 15, runLength = Int.MAX_VALUE)), + ), + ) + val directory = Files.createTempDirectory("protomaps-region-test").toFile() + val destination = File(directory, "region.pmtiles") + + try { + ProtomapsRegionDownloader( + archiveResolver = resolver, + rangeClient = fakeClient, + utcDate = { today }, + maxZoom = 15, + ) + .download(bounds = BURNING_MAN_BOUNDS, destination = destination) + + val header = PmtilesV3Reader(destination).header + assertEquals(15, header.maxZoom) + assertTrue(header.rootDirectoryLength <= MAX_ROOT_DIRECTORY_BYTES) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `keeps default bounded extraction at z13`() = runTest { + val today = LocalDate(2026, 9, 2) + val resolver = ProtomapsArchiveResolver { date -> "https://example.test/$date.pmtiles" } + val fakeClient = + FakeRangeClient( + mapOf( + resolver.urlFor(today) to + FakeArchive(statusCode = 206, bytes = vectorPmtiles(maxZoom = 15, runLength = Int.MAX_VALUE)), + ), + ) + val directory = Files.createTempDirectory("protomaps-region-test").toFile() + val destination = File(directory, "region.pmtiles") + + try { + ProtomapsRegionDownloader(archiveResolver = resolver, rangeClient = fakeClient, utcDate = { today }) + .download(bounds = BURNING_MAN_BOUNDS, destination = destination) + + assertEquals(13, PmtilesV3Reader(destination).header.maxZoom) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `rejects a region whose root directory exceeds the PMTiles limit`() = runTest { + val today = LocalDate(2026, 9, 2) + val resolver = ProtomapsArchiveResolver { date -> "https://example.test/$date.pmtiles" } + val fakeClient = + FakeRangeClient( + mapOf( + resolver.urlFor(today) to + FakeArchive(statusCode = 206, bytes = vectorPmtiles(runLength = 100_000_000)), + ), + ) + val directory = Files.createTempDirectory("protomaps-region-test").toFile() + val destination = File(directory, "region.pmtiles") + + try { + val failure = + runCatching { + ProtomapsRegionDownloader( + archiveResolver = resolver, + rangeClient = fakeClient, + utcDate = { today }, + ) + .download( + bounds = GeoBounds(minLon = -3.0, minLat = -3.0, maxLon = 3.0, maxLat = 3.0), + destination = destination, + ) + } + .exceptionOrNull() + + assertTrue(checkNotNull(failure).message?.contains("root directory") == true) + assertTrue(!destination.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `rejects truncated range responses and removes temporary file`() = runTest { + val today = LocalDate(2026, 9, 2) + val resolver = ProtomapsArchiveResolver { date -> "https://example.test/$date.pmtiles" } + val fakeClient = + FakeRangeClient( + archives = mapOf(resolver.urlFor(today) to FakeArchive(statusCode = 206, bytes = vectorPmtiles())), + truncateTilePayload = true, + ) + val directory = Files.createTempDirectory("protomaps-region-test").toFile() + val destination = File(directory, "region.pmtiles") + + try { + val failure = + runCatching { + ProtomapsRegionDownloader( + archiveResolver = resolver, + rangeClient = fakeClient, + utcDate = { today }, + ) + .download( + bounds = GeoBounds(minLon = -119.21, minLat = 40.78, maxLon = -119.20, maxLat = 40.79), + destination = destination, + ) + } + .exceptionOrNull() + + assertTrue(checkNotNull(failure).message?.contains("truncated") == true) + assertTrue(!destination.exists()) + } finally { + directory.deleteRecursively() + } + } + + private class FakeRangeClient( + private val archives: Map, + private val truncateTilePayload: Boolean = false, + ) : PmtilesRangeClient { + val probedUrls = mutableListOf() + + override suspend fun fetch(url: String, range: LongRange): PmtilesRangeResponse { + val archive = checkNotNull(archives[url]) + if (range.first == 0L && range.last == PmtilesV3Reader.HEADER_SIZE - 1L) probedUrls += url + val start = range.first.toInt() + val end = (range.last + 1).coerceAtMost(archive.bytes.size.toLong()).toInt() + val body = archive.bytes.copyOfRange(start, end) + val result = + if (truncateTilePayload && range.first >= TILE_DATA_OFFSET) body.dropLast(1).toByteArray() else body + return PmtilesRangeResponse(archive.statusCode, result) + } + } + + private data class FakeArchive(val statusCode: Int, val bytes: ByteArray) + + private fun vectorPmtiles(maxZoom: Int = 13, runLength: Int = 1): ByteArray { + val tile = byteArrayOf(0x1A, 0x00) + val directory = byteArrayOf(1, 0) + varint(runLength) + byteArrayOf(tile.size.toByte(), 1) + val metadata = "{}".encodeToByteArray() + val header = ByteBuffer.allocate(PmtilesV3Reader.HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN) + header.put("PMTiles".encodeToByteArray()) + header.put(3) + header.putLong(PmtilesV3Reader.HEADER_SIZE.toLong()) + header.putLong(directory.size.toLong()) + header.putLong((PmtilesV3Reader.HEADER_SIZE + directory.size).toLong()) + header.putLong(metadata.size.toLong()) + header.putLong((PmtilesV3Reader.HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(0) + header.putLong((PmtilesV3Reader.HEADER_SIZE + directory.size + metadata.size).toLong()) + header.putLong(tile.size.toLong()) + header.putLong(1) + header.putLong(1) + header.putLong(1) + header.put(1) + header.put(1) + header.put(1) + header.put(PmtilesTileType.Mvt.value) + header.put(0) + header.put(maxZoom.toByte()) + + return header.array() + directory + metadata + tile + } + + private fun varint(value: Int): ByteArray { + var remainder = value + val result = mutableListOf() + do { + var next = remainder and 0x7f + remainder = remainder ushr 7 + if (remainder > 0) next = next or 0x80 + result += next.toByte() + } while (remainder > 0) + return result.toByteArray() + } + + private companion object { + const val TILE_DATA_OFFSET = 134L + const val MAX_ROOT_DIRECTORY_BYTES = 16 * 1024L + val BURNING_MAN_BOUNDS = + GeoBounds(minLon = -119.287957, minLat = 40.722536, maxLon = -119.128520, maxLat = 40.843420) + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt new file mode 100644 index 0000000000..5367e9c0a1 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt @@ -0,0 +1,225 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") // The bounded region is the approved Burning Man 2026 policy boundary. + +package org.meshtastic.feature.map.offline + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import kotlin.time.Clock +import kotlin.time.Instant + +data class BurningManPackRecord( + val packId: String, + val sourceBuild: String, + val replicationTimestamp: String, + val installedAt: Instant, + val userSuppressed: Boolean, +) + +data class SelectedBurningManPack(val file: File, val record: BurningManPackRecord) + +fun interface BurningManPackDownloader { + suspend fun download(bounds: GeoBounds, destination: File): DownloadedPack +} + +interface BurningManPackStore { + fun load(): BurningManPackRecord? + + fun save(record: BurningManPackRecord?) +} + +class SharedPreferencesBurningManPackStore(context: Context) : BurningManPackStore { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + override fun load(): BurningManPackRecord? = preferences.getString(KEY_PACK_ID, null)?.let { packId -> + preferences.getString(KEY_INSTALLED_AT, null)?.let(Instant::parse)?.let { installedAt -> + BurningManPackRecord( + packId = packId, + sourceBuild = preferences.getString(KEY_SOURCE_BUILD, "") ?: "", + replicationTimestamp = preferences.getString(KEY_REPLICATION_TIMESTAMP, "") ?: "", + installedAt = installedAt, + userSuppressed = preferences.getBoolean(KEY_USER_SUPPRESSED, false), + ) + } + } + + override fun save(record: BurningManPackRecord?) { + preferences + .edit() + .apply { + if (record == null) { + clear() + } else { + putString(KEY_PACK_ID, record.packId) + putString(KEY_SOURCE_BUILD, record.sourceBuild) + putString(KEY_REPLICATION_TIMESTAMP, record.replicationTimestamp) + putString(KEY_INSTALLED_AT, record.installedAt.toString()) + putBoolean(KEY_USER_SUPPRESSED, record.userSuppressed) + } + } + .apply() + } + + private companion object { + const val PREFERENCES_NAME = "burning_man_pack" + const val KEY_PACK_ID = "pack_id" + const val KEY_SOURCE_BUILD = "source_build" + const val KEY_REPLICATION_TIMESTAMP = "replication_timestamp" + const val KEY_INSTALLED_AT = "installed_at" + const val KEY_USER_SUPPRESSED = "user_suppressed" + } +} + +class BurningManPackCoordinator( + private val filesDirectory: File, + private val store: BurningManPackStore, + private val downloader: BurningManPackDownloader = BurningManPackDownloader { bounds, destination -> + ProtomapsRegionDownloader(maxZoom = MAX_ZOOM).download(bounds, destination) + }, +) { + private val stateMutex = Mutex() + private val _selectedPack = MutableStateFlow(null) + val selectedPack: StateFlow = _selectedPack.asStateFlow() + + /** Restores the persisted pack only when it still passes the same validation used by reconciliation. */ + suspend fun restoreValidatedSelection(): SelectedBurningManPack? = stateMutex.withLock { + val record = store.load() + val selected = record?.takeUnless { it.userSuppressed }?.let(::validatedSelection) + if (record != null && !record.userSuppressed && selected == null) { + destination.delete() + store.save(null) + } + selected.also { _selectedPack.value = it } + } + + suspend fun reconcile(now: Instant, lastAuthorizedLocation: PackLocation?): SelectedBurningManPack? = + stateMutex.withLock { reconcileLocked(now, lastAuthorizedLocation) } + + suspend fun removeByUser() { + stateMutex.withLock { + destination.delete() + val record = + store.load() + ?: BurningManPackRecord( + packId = PACK_ID, + sourceBuild = "", + replicationTimestamp = "", + installedAt = Clock.System.now(), + userSuppressed = true, + ) + store.save(record.copy(userSuppressed = true)) + _selectedPack.value = null + } + } + + private suspend fun reconcileLocked(now: Instant, lastAuthorizedLocation: PackLocation?): SelectedBurningManPack? { + var record = store.load() + var selected = record?.let(::validatedSelection) + if (record != null && (record.packId != PACK_ID || (selected == null && !record.userSuppressed))) { + destination.delete() + store.save(null) + record = null + selected = null + } + if (record?.userSuppressed == true) { + _selectedPack.value = null + return null + } + + return when (BurningManPackPolicy(record?.asManifest()).reconcile(now, lastAuthorizedLocation)) { + PackAction.Install -> install(now) + PackAction.Retain -> selected.also { _selectedPack.value = it } + PackAction.Remove -> removeAutomatically() + } + } + + private suspend fun install(now: Instant): SelectedBurningManPack? = runCatching { + val downloadedPack = downloader.download(BOUNDS, destination) + val reader = PmtilesV3Reader(destination) + validatePack(reader) + val replicationTimestamp = + requireNotNull(reader.metadata[REPLICATION_TIME_KEY]) { + "Burning Man pack has no replication timestamp" + } + BurningManPackRecord( + packId = PACK_ID, + sourceBuild = downloadedPack.sourceBuild, + replicationTimestamp = replicationTimestamp, + installedAt = now, + userSuppressed = false, + ) + } + .fold( + onSuccess = { record -> + val selected = SelectedBurningManPack(destination, record) + store.save(record) + _selectedPack.value = selected + selected + }, + onFailure = { + destination.delete() + _selectedPack.value = null + null + }, + ) + + private fun removeAutomatically(): SelectedBurningManPack? { + destination.delete() + store.save(null) + _selectedPack.value = null + return null + } + + private fun validatedSelection(record: BurningManPackRecord): SelectedBurningManPack? = runCatching { + require(record.packId == PACK_ID) { "Burning Man pack identifier does not match" } + require(destination.isFile) { "Burning Man pack is missing" } + val reader = PmtilesV3Reader(destination) + validatePack(reader) + require(reader.metadata[REPLICATION_TIME_KEY] == record.replicationTimestamp) { + "Burning Man pack replication timestamp does not match" + } + SelectedBurningManPack(destination, record) + } + .getOrNull() + + private fun BurningManPackRecord.asManifest(): BurningManPackManifest = BurningManPackManifest( + packId = packId, + sourceBuild = sourceBuild, + installedAt = installedAt, + userSuppressed = userSuppressed, + ) + + private fun validatePack(reader: PmtilesV3Reader) { + require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" } + require(reader.header.maxZoom == MAX_ZOOM) { "Burning Man pack does not include zoom $MAX_ZOOM" } + } + + private val destination: File + get() = File(filesDirectory, "offline/$PACK_ID.pmtiles") + + private companion object { + const val PACK_ID = "burning-man-2026" + const val MAX_ZOOM = 15 + val BOUNDS = GeoBounds(minLon = -119.287957, minLat = 40.722536, maxLon = -119.128520, maxLat = 40.843420) + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt new file mode 100644 index 0000000000..cdb44ef6b3 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt @@ -0,0 +1,76 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import android.content.Context +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Application-scoped coordinator shared by map flavors and foreground lifecycle work. */ +class BurningManPackRuntime private constructor(private val coordinatorFactory: () -> BurningManPackCoordinator) { + val coordinator: BurningManPackCoordinator by lazy(coordinatorFactory) + + private val restoreMutex = Mutex() + private var restored = false + + suspend fun restoreSelection(): SelectedBurningManPack? = restoreMutex.withLock { + if (!restored) { + coordinator.restoreValidatedSelection() + restored = true + } + coordinator.selectedPack.value + } + + companion object { + private val instanceLock = Any() + + @Volatile private var instance: BurningManPackRuntime? = null + private var factoryForTest: ((Context) -> BurningManPackCoordinator)? = null + + fun forContext(context: Context): BurningManPackRuntime { + val applicationContext = context.applicationContext + return instance + ?: synchronized(instanceLock) { + instance + ?: run { + val coordinatorFactory = factoryForTest ?: ::newCoordinator + BurningManPackRuntime(coordinatorFactory = { coordinatorFactory(applicationContext) }) + .also { instance = it } + } + } + } + + fun installFactoryForTest(factory: (Context) -> BurningManPackCoordinator) { + synchronized(instanceLock) { + check(instance == null) { "Reset BurningManPackRuntime before installing a test factory" } + factoryForTest = factory + } + } + + fun resetForTest() { + synchronized(instanceLock) { + instance = null + factoryForTest = null + } + } + + private fun newCoordinator(context: Context) = BurningManPackCoordinator( + filesDirectory = context.filesDir, + store = SharedPreferencesBurningManPackStore(context), + ) + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt new file mode 100644 index 0000000000..d2c4d20df9 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt @@ -0,0 +1,568 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") // PMTiles v3 specifies its 127-byte header as fixed byte offsets. + +package org.meshtastic.feature.map.offline + +import kotlinx.coroutines.withContext +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime +import org.meshtastic.core.common.util.ioDispatcher +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.net.URL +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.zip.GZIPInputStream +import kotlin.math.PI +import kotlin.math.asinh +import kotlin.math.max +import kotlin.math.min +import kotlin.math.tan +import kotlin.time.Clock + +const val REPLICATION_TIME_KEY = "planetiler:osm:osmosisreplicationtime" + +data class GeoBounds(val minLon: Double, val minLat: Double, val maxLon: Double, val maxLat: Double) + +data class DownloadedPack(val sourceBuild: String, val file: File) + +fun interface ProtomapsArchiveResolver { + fun urlFor(date: LocalDate): String +} + +fun interface PmtilesRangeClient { + suspend fun fetch(url: String, range: LongRange): PmtilesRangeResponse +} + +data class PmtilesRangeResponse(val statusCode: Int, val body: ByteArray) + +class ProtomapsRegionDownloader( + private val archiveResolver: ProtomapsArchiveResolver = ProtomapsArchiveResolver { date -> + "https://build.protomaps.com/${date.compact()}.pmtiles" + }, + private val rangeClient: PmtilesRangeClient = HttpPmtilesRangeClient, + private val utcDate: () -> LocalDate = { Clock.System.now().toLocalDateTime(TimeZone.UTC).date }, + private val minZoom: Int = MIN_ZOOM, + private val maxZoom: Int = MAX_ZOOM, +) { + + suspend fun download(bounds: GeoBounds, destination: File): DownloadedPack { + val source = resolveNewestSource() ?: throw IllegalStateException("No range-capable Protomaps build found") + val header = source.header + require(header.tileType == PmtilesTileType.Mvt) { "Protomaps source is not vector MVT" } + + val root = + readDirectory( + source.url, + header.rootDirectoryOffset, + header.rootDirectoryLength, + header.internalCompression, + ) + val effectiveMinZoom = max(minZoom, header.minZoom) + val effectiveMaxZoom = min(maxZoom, header.maxZoom) + require(effectiveMinZoom <= effectiveMaxZoom) { "Source has no requested zoom levels" } + + val requestedTileIds = tileIds(bounds, effectiveMinZoom, effectiveMaxZoom) + require(requestedTileIds.isNotEmpty() && requestedTileIds.size <= MAX_TILES) { + "Requested map area is too large" + } + val resolved = requestedTileIds.mapNotNull { tileId -> resolveTile(source.url, header, root, tileId) } + require(resolved.isNotEmpty()) { "Source has no tiles in the requested area" } + + val parent = destination.parentFile ?: File(".") + parent.mkdirs() + val temporary = File.createTempFile(".${destination.name}.", ".tmp", parent) + var completed = false + try { + writeExtractedArchive( + source = source, + bounds = bounds, + minZoom = effectiveMinZoom, + maxZoom = effectiveMaxZoom, + tiles = resolved, + destination = temporary, + ) + require(PmtilesV3Reader(temporary).header.tileType == PmtilesTileType.Mvt) { + "Extracted file is not vector MVT" + } + Files.move(temporary.toPath(), destination.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) + completed = true + return DownloadedPack(source.build, destination) + } finally { + if (!completed) temporary.delete() + } + } + + private suspend fun resolveNewestSource(): Source? { + val today = utcDate() + for (offset in 0 until BUILD_LOOKBACK_DAYS) { + val day = today.minus(DatePeriod(days = offset)) + sourceFor(day)?.let { + return it + } + } + return null + } + + private suspend fun sourceFor(day: LocalDate): Source? { + val url = archiveResolver.urlFor(day) + val first = 0L + val last = PmtilesV3Reader.HEADER_SIZE - 1L + val response = rangeClient.fetch(url, first..last) + val header = + if (response.statusCode == HttpURLConnection.HTTP_PARTIAL) { + PmtilesV3Reader.parseHeader(exactRangeBody(response, first, last)) + } else { + null + } + return header?.let { Source(url = url, build = day.compact(), header = it) } + } + + @Suppress("ReturnCount") // A PMTiles leaf traversal has terminal hit and miss states. + private suspend fun resolveTile( + sourceUrl: String, + header: PmtilesHeader, + root: List, + tileId: Long, + ): ResolvedTile? { + var entries = root + var leafOffset = header.rootDirectoryOffset + var leafLength = header.rootDirectoryLength + repeat(MAX_DIRECTORY_DEPTH) { depth -> + if (depth > 0) entries = readDirectory(sourceUrl, leafOffset, leafLength, header.internalCompression) + val entry = findEntry(entries, tileId) ?: return null + if (entry.runLength == 0) { + leafOffset = header.leafDirectoryOffset + entry.offset + leafLength = entry.length.toLong() + } else { + return ResolvedTile(tileId, header.tileDataOffset + entry.offset, entry.length) + } + } + return null + } + + private suspend fun readDirectory( + sourceUrl: String, + offset: Long, + length: Long, + compression: PmtilesCompression, + ): List { + require(length > 0) { "Empty PMTiles directory" } + val body = range(sourceUrl, offset, offset + length - 1) + return parseDirectory(decompress(body, compression)) + } + + private suspend fun writeExtractedArchive( + source: Source, + bounds: GeoBounds, + minZoom: Int, + maxZoom: Int, + tiles: List, + destination: File, + ) { + val payloads = linkedMapOf, ByteArray>() + for (tile in tiles.sortedBy { it.sourceOffset }) { + val key = tile.sourceOffset to tile.length + if (key !in payloads) { + payloads[key] = range(source.url, tile.sourceOffset, tile.sourceOffset + tile.length - 1) + } + } + + val payloadOffsets = mutableMapOf, Long>() + var tileDataLength = 0L + for ((key, payload) in payloads) { + payloadOffsets[key] = tileDataLength + tileDataLength += payload.size + } + val entries = + tiles + .map { tile -> + PmtilesEntry( + tileId = tile.tileId, + offset = checkNotNull(payloadOffsets[tile.sourceOffset to tile.length]), + length = tile.length, + runLength = 1, + ) + } + .sortedBy { it.tileId } + val rootDirectory = serializeDirectory(entries) + require(rootDirectory.size <= MAX_ROOT_DIRECTORY_SIZE) { + "PMTiles root directory exceeds $MAX_ROOT_DIRECTORY_SIZE bytes" + } + val metadata = "{\"$REPLICATION_TIME_KEY\":\"${source.build}\"}".encodeToByteArray() + val tileDataOffset = PmtilesV3Reader.HEADER_SIZE + rootDirectory.size + metadata.size + val header = + buildHeader( + rootDirectoryLength = rootDirectory.size, + metadataLength = metadata.size, + tileDataOffset = tileDataOffset, + tileDataLength = tileDataLength, + addressedTileCount = entries.size, + tileContentsCount = payloads.size, + bounds = bounds, + minZoom = minZoom, + maxZoom = maxZoom, + tileCompression = source.header.tileCompression, + ) + + withContext(ioDispatcher) { + FileOutputStream(destination).use { output -> + output.write(header) + output.write(rootDirectory) + output.write(metadata) + payloads.values.forEach(output::write) + output.fd.sync() + } + } + } + + private suspend fun range(url: String, first: Long, last: Long): ByteArray { + require(first >= 0 && last >= first) { "Invalid PMTiles byte range" } + val response = rangeClient.fetch(url, first..last) + require(response.statusCode == HttpURLConnection.HTTP_PARTIAL) { "Range request was not honored" } + return exactRangeBody(response, first, last) + } + + private fun exactRangeBody(response: PmtilesRangeResponse, first: Long, last: Long): ByteArray { + val expectedLength = last - first + 1 + require(expectedLength > 0 && response.body.size.toLong() == expectedLength) { + "Range response was truncated or oversized" + } + return response.body + } + + private data class Source(val url: String, val build: String, val header: PmtilesHeader) + + private data class ResolvedTile(val tileId: Long, val sourceOffset: Long, val length: Int) + + private companion object { + const val BUILD_LOOKBACK_DAYS = 16 + const val MIN_ZOOM = 0 + const val MAX_ZOOM = 13 + const val MAX_TILES = 600_000 + const val MAX_ROOT_DIRECTORY_SIZE = 16 * 1024 + const val MAX_DIRECTORY_DEPTH = 4 + } +} + +object HttpPmtilesRangeClient : PmtilesRangeClient { + override suspend fun fetch(url: String, range: LongRange): PmtilesRangeResponse = withContext(ioDispatcher) { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.requestMethod = "GET" + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + connection.setRequestProperty("Range", "bytes=${range.first}-${range.last}") + connection.setRequestProperty("Accept-Encoding", "identity") + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + PmtilesRangeResponse(status, stream?.use { it.readBytes() } ?: byteArrayOf()) + } finally { + connection.disconnect() + } + } +} + +class PmtilesV3Reader(file: File) { + val header: PmtilesHeader + val metadata: Map + + init { + RandomAccessFile(file, "r").use { input -> + val headerBytes = ByteArray(HEADER_SIZE) + input.readFully(headerBytes) + header = requireNotNull(parseHeader(headerBytes)) { "Invalid PMTiles v3 header" } + val fileSize = input.length() + require( + header.metadataOffset >= 0 && + header.metadataLength >= 0 && + header.metadataOffset <= fileSize && + header.metadataLength <= fileSize - header.metadataOffset && + header.metadataLength <= MAX_METADATA_BYTES, + ) { + "Invalid PMTiles metadata range" + } + val metadataBytes = ByteArray(header.metadataLength.toInt()) + input.seek(header.metadataOffset) + input.readFully(metadataBytes) + metadata = parseMetadata(metadataBytes.decodeToString()) + } + } + + companion object { + const val HEADER_SIZE = 127 + private const val MAX_METADATA_BYTES = 16 * 1024 * 1024 + + fun parseHeader(bytes: ByteArray): PmtilesHeader? { + if ( + bytes.size < HEADER_SIZE || + bytes.copyOfRange(0, 7).decodeToString() != "PMTiles" || + bytes[7] != 3.toByte() + ) { + return null + } + return PmtilesHeader( + rootDirectoryOffset = bytes.longAt(8), + rootDirectoryLength = bytes.longAt(16), + metadataOffset = bytes.longAt(24), + metadataLength = bytes.longAt(32), + leafDirectoryOffset = bytes.longAt(40), + tileDataOffset = bytes.longAt(56), + internalCompression = PmtilesCompression.from(bytes[97]), + tileCompression = PmtilesCompression.from(bytes[98]), + tileType = PmtilesTileType.from(bytes[99]), + minZoom = bytes[100].toInt() and 0xff, + maxZoom = bytes[101].toInt() and 0xff, + ) + } + } +} + +private const val CONNECT_TIMEOUT_MILLIS = 15_000 +private const val READ_TIMEOUT_MILLIS = 30_000 + +data class PmtilesHeader( + val rootDirectoryOffset: Long, + val rootDirectoryLength: Long, + val metadataOffset: Long, + val metadataLength: Long, + val leafDirectoryOffset: Long, + val tileDataOffset: Long, + val internalCompression: PmtilesCompression, + val tileCompression: PmtilesCompression, + val tileType: PmtilesTileType, + val minZoom: Int, + val maxZoom: Int, +) + +enum class PmtilesTileType(val value: Byte) { + Unknown(0), + Mvt(1), + Png(2), + Jpeg(3), + Webp(4), + Avif(5), + ; + + companion object { + fun from(value: Byte): PmtilesTileType = entries.firstOrNull { it.value == value } ?: Unknown + } +} + +enum class PmtilesCompression(val value: Byte) { + Unknown(0), + None(1), + Gzip(2), + Brotli(3), + Zstd(4), + ; + + companion object { + fun from(value: Byte): PmtilesCompression = entries.firstOrNull { it.value == value } ?: Unknown + } +} + +private data class PmtilesEntry(val tileId: Long, val offset: Long, val length: Int, val runLength: Int) + +private fun parseDirectory(bytes: ByteArray): List { + val reader = VarintReader(bytes) + val count = reader.read().toInt() + require(count in 1..10_000_000) { "Invalid PMTiles directory" } + val tileIds = LongArray(count) + var previousId = 0L + repeat(count) { index -> + previousId += reader.read() + tileIds[index] = previousId + } + val runLengths = IntArray(count) { reader.read().toInt() } + val lengths = IntArray(count) { reader.read().toInt() } + val offsets = LongArray(count) + repeat(count) { index -> + val encoded = reader.read() + offsets[index] = if (encoded == 0L && index > 0) offsets[index - 1] + lengths[index - 1] else encoded - 1 + } + return List(count) { index -> PmtilesEntry(tileIds[index], offsets[index], lengths[index], runLengths[index]) } +} + +private fun findEntry(entries: List, tileId: Long): PmtilesEntry? { + var low = 0 + var high = entries.lastIndex + while (low <= high) { + val middle = (low + high) ushr 1 + when { + tileId > entries[middle].tileId -> low = middle + 1 + tileId < entries[middle].tileId -> high = middle - 1 + else -> return entries[middle] + } + } + return entries.getOrNull(high)?.takeIf { it.runLength == 0 || tileId - it.tileId < it.runLength } +} + +private fun serializeDirectory(entries: List): ByteArray { + val bytes = ArrayList() + fun put(value: Long) { + var current = value + while (current >= 0x80) { + bytes += ((current and 0x7f) or 0x80).toByte() + current = current ushr 7 + } + bytes += current.toByte() + } + put(entries.size.toLong()) + var previousId = 0L + entries.forEach { entry -> + put(entry.tileId - previousId) + previousId = entry.tileId + } + entries.forEach { entry -> put(entry.runLength.toLong()) } + entries.forEach { entry -> put(entry.length.toLong()) } + entries.forEach { entry -> put(entry.offset + 1) } + return bytes.toByteArray() +} + +private fun buildHeader( + rootDirectoryLength: Int, + metadataLength: Int, + tileDataOffset: Int, + tileDataLength: Long, + addressedTileCount: Int, + tileContentsCount: Int, + bounds: GeoBounds, + minZoom: Int, + maxZoom: Int, + tileCompression: PmtilesCompression, +): ByteArray = ByteBuffer.allocate(PmtilesV3Reader.HEADER_SIZE) + .order(ByteOrder.LITTLE_ENDIAN) + .apply { + put("PMTiles".encodeToByteArray()) + put(3) + putLong(PmtilesV3Reader.HEADER_SIZE.toLong()) + putLong(rootDirectoryLength.toLong()) + putLong((PmtilesV3Reader.HEADER_SIZE + rootDirectoryLength).toLong()) + putLong(metadataLength.toLong()) + putLong(tileDataOffset.toLong()) + putLong(0) + putLong(tileDataOffset.toLong()) + putLong(tileDataLength) + putLong(addressedTileCount.toLong()) + putLong(addressedTileCount.toLong()) + putLong(tileContentsCount.toLong()) + put(0) + put(PmtilesCompression.None.value) + put(tileCompression.value) + put(PmtilesTileType.Mvt.value) + put(minZoom.toByte()) + put(maxZoom.toByte()) + putInt((bounds.minLon * 10_000_000).toInt()) + putInt((bounds.minLat * 10_000_000).toInt()) + putInt((bounds.maxLon * 10_000_000).toInt()) + putInt((bounds.maxLat * 10_000_000).toInt()) + put(minZoom.toByte()) + putInt(((bounds.minLon + bounds.maxLon) * 5_000_000).toInt()) + putInt(((bounds.minLat + bounds.maxLat) * 5_000_000).toInt()) + } + .array() + +private fun decompress(bytes: ByteArray, compression: PmtilesCompression): ByteArray = when (compression) { + PmtilesCompression.None -> bytes + PmtilesCompression.Gzip -> GZIPInputStream(ByteArrayInputStream(bytes)).use { it.readBytes() } + else -> throw IllegalArgumentException("Unsupported PMTiles compression") +} + +private fun tileIds(bounds: GeoBounds, minZoom: Int, maxZoom: Int): List { + val ids = ArrayList() + for (zoom in minZoom..maxZoom) { + val (left, top) = tileCoordinates(bounds.minLon, bounds.maxLat, zoom) + val (right, bottom) = tileCoordinates(bounds.maxLon, bounds.minLat, zoom) + for (x in min(left, right)..max(left, right)) { + for (y in min(top, bottom)..max(top, bottom)) { + ids += zxyToTileId(zoom, x, y) + } + } + } + return ids +} + +private fun tileCoordinates(longitude: Double, latitude: Double, zoom: Int): Pair { + val tileCount = 1 shl zoom + val clampedLatitude = latitude.coerceIn(-85.05112878, 85.05112878) + val latitudeRadians = clampedLatitude * PI / 180 + val x = (((longitude + 180) / 360) * tileCount).toInt().coerceIn(0, tileCount - 1) + val y = (((1 - asinh(tan(latitudeRadians)) / PI) / 2) * tileCount).toInt().coerceIn(0, tileCount - 1) + return x to y +} + +private fun zxyToTileId(zoom: Int, x: Int, y: Int): Long { + var accumulated = 0L + repeat(zoom) { index -> accumulated += 1L shl (2 * index) } + var xx = x + var yy = y + var distance = 0L + var size = if (zoom == 0) 0 else 1 shl (zoom - 1) + while (size > 0) { + val rx = if ((xx and size) != 0) 1 else 0 + val ry = if ((yy and size) != 0) 1 else 0 + distance += size.toLong() * size * ((3 * rx) xor ry) + if (ry == 0) { + if (rx == 1) { + xx = size - 1 - xx + yy = size - 1 - yy + } + val temporary = xx + xx = yy + yy = temporary + } + size /= 2 + } + return accumulated + distance +} + +private fun ByteArray.longAt(offset: Int): Long = + ByteBuffer.wrap(this, offset, Long.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).long + +private fun parseMetadata(json: String): Map = + Regex("\\\"([^\\\"]+)\\\"\\s*:\\s*\\\"([^\\\"]*)\\\"").findAll(json).associate { + it.groupValues[1] to it.groupValues[2] + } + +private fun LocalDate.compact(): String = toString().replace("-", "") + +private class VarintReader(private val bytes: ByteArray) { + private var index = 0 + + fun read(): Long { + var result = 0L + var shift = 0 + while (index < bytes.size && shift < Long.SIZE_BITS) { + val value = bytes[index++].toInt() and 0xff + result = result or ((value and 0x7f).toLong() shl shift) + if (value and 0x80 == 0) return result + shift += 7 + } + throw IllegalArgumentException("Invalid PMTiles varint") + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicy.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicy.kt new file mode 100644 index 0000000000..b9431fe522 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicy.kt @@ -0,0 +1,70 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +data class PackLocation(val latitude: Double, val longitude: Double, val timestamp: Instant) + +data class BurningManPackManifest( + val packId: String, + val sourceBuild: String, + val installedAt: Instant, + val userSuppressed: Boolean, +) + +enum class PackAction { + Install, + Retain, + Remove, +} + +class BurningManPackPolicy( + private val manifest: BurningManPackManifest? = null, + private val installationLatched: Boolean = false, +) { + + fun reconcile(now: Instant, location: PackLocation?): PackAction { + val recentLocation = location?.takeIf { it.timestamp >= now - locationMaxAge } + return when { + now >= noLocationCleanupDate -> PackAction.Remove + + now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation) -> PackAction.Remove + + recentLocation != null && contains(recentLocation) && manifest == null && !installationLatched -> + PackAction.Install + + else -> PackAction.Retain + } + } + + fun contains(location: PackLocation): Boolean = + location.longitude in MIN_LON..MAX_LON && location.latitude in MIN_LAT..MAX_LAT + + private companion object { + // Midnight in America/Los_Angeles is 07:00 UTC while daylight saving time is active. + val outsideAreaCleanupDate = Instant.parse("2026-09-08T07:00:00Z") + val noLocationCleanupDate = Instant.parse("2026-09-12T07:00:00Z") + val locationMaxAge = 24.hours + + const val MIN_LON = -119.287957 + const val MIN_LAT = 40.722536 + const val MAX_LON = -119.128520 + const val MAX_LAT = 40.843420 + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicyTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicyTest.kt new file mode 100644 index 0000000000..7c47e3da71 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicyTest.kt @@ -0,0 +1,103 @@ +/* + * 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 . + */ +package org.meshtastic.feature.map.offline + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class BurningManPackPolicyTest { + + private val policy = BurningManPackPolicy() + + @Test + fun `outside area after event removes`() { + assertEquals( + PackAction.Remove, + policy.reconcile( + instant("2026-09-08T08:00:00Z"), + PackLocation(37.3346, -122.0090, instant("2026-09-08T07:00:00Z")), + ), + ) + } + + @Test + fun `no location before grace deadline retains`() { + assertEquals(PackAction.Retain, policy.reconcile(instant("2026-09-10T08:00:00Z"), null)) + } + + @Test + fun `no location at cleanup deadline removes`() { + assertEquals(PackAction.Remove, policy.reconcile(instant("2026-09-12T07:00:00Z"), null)) + } + + @Test + fun `inside area installs before cleanup when no manifest is present`() { + assertEquals( + PackAction.Install, + policy.reconcile( + instant("2026-09-07T20:00:00Z"), + PackLocation(40.7864, -119.2065, instant("2026-09-07T19:00:00Z")), + ), + ) + } + + @Test + fun `inside area installs during the grace period when no manifest is present`() { + assertEquals( + PackAction.Install, + policy.reconcile( + instant("2026-09-10T08:00:00Z"), + PackLocation(40.7864, -119.2065, instant("2026-09-10T07:00:00Z")), + ), + ) + } + + @Test + fun `manifest prevents a second installation`() { + val installedPolicy = + BurningManPackPolicy( + manifest = + BurningManPackManifest( + packId = "burning-man-2026", + sourceBuild = "2026-08-29", + installedAt = instant("2026-09-01T07:00:00Z"), + userSuppressed = false, + ), + ) + + assertEquals( + PackAction.Retain, + installedPolicy.reconcile( + instant("2026-09-07T20:00:00Z"), + PackLocation(40.7864, -119.2065, instant("2026-09-07T19:00:00Z")), + ), + ) + } + + @Test + fun `bounds include their edge and exclude points outside`() { + val timestamp = instant("2026-09-07T19:00:00Z") + + assertTrue(policy.contains(PackLocation(40.722536, -119.287957, timestamp))) + assertFalse(policy.contains(PackLocation(40.843421, -119.287957, timestamp))) + } + + private fun instant(value: String): Instant = Instant.parse(value) +}