From daf3323856ad10244114498841a043656af10431 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:53:35 -0700
Subject: [PATCH 01/16] feat(map): add Burning Man pack policy
---
.../map/offline/BurningManPackPolicy.kt | 80 ++++++++++++++
.../map/offline/BurningManPackPolicyTest.kt | 101 ++++++++++++++++++
2 files changed, 181 insertions(+)
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicy.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicyTest.kt
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..a26e134626
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicy.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 {
+ if (now >= noLocationCleanupDate) return PackAction.Remove
+
+ val recentLocation = location?.takeIf { it.timestamp >= now - locationMaxAge }
+ if (now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation)) {
+ return PackAction.Remove
+ }
+
+ return if (
+ recentLocation != null &&
+ contains(recentLocation) &&
+ manifest == null &&
+ !installationLatched
+ ) {
+ PackAction.Install
+ } else {
+ PackAction.Retain
+ }
+ }
+
+ fun contains(location: PackLocation): Boolean =
+ location.longitude in minLon..maxLon && location.latitude in minLat..maxLat
+
+ 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 minLon = -119.287957
+ const val minLat = 40.722536
+ const val maxLon = -119.128520
+ const val maxLat = 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..73d008c8e6
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackPolicyTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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)
+}
From e5ddad4e2c9ef90882313d5fe0ef7aafc9651216 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:13:16 -0700
Subject: [PATCH 02/16] feat(map): download bounded Protomaps region
---
.../offline/ProtomapsRegionDownloaderTest.kt | 106 ++++
.../map/offline/ProtomapsRegionDownloader.kt | 531 ++++++++++++++++++
2 files changed, 637 insertions(+)
create mode 100644 feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloaderTest.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt
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..7b3f611a43
--- /dev/null
+++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloaderTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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()
+ }
+ }
+
+ private class FakeRangeClient(private val archives: Map) : 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()
+ return PmtilesRangeResponse(archive.statusCode, archive.bytes.copyOfRange(start, end))
+ }
+ }
+
+ private data class FakeArchive(val statusCode: Int, val bytes: ByteArray)
+
+ private fun vectorPmtiles(): ByteArray {
+ val tile = byteArrayOf(0x1A, 0x00)
+ val directory = byteArrayOf(1, 0, 1, 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)
+
+ return header.array() + directory + metadata + tile
+ }
+}
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..9e1daee848
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt
@@ -0,0 +1,531 @@
+/*
+ * 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.FileInputStream
+import java.io.FileOutputStream
+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 temporary = File(destination.parentFile, ".${destination.name}.tmp")
+ destination.parentFile?.mkdirs()
+ temporary.delete()
+ 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 response = rangeClient.fetch(url, 0L until PmtilesV3Reader.HEADER_SIZE.toLong())
+ val header = if (response.statusCode == HttpURLConnection.HTTP_PARTIAL) {
+ PmtilesV3Reader.parseHeader(response.body)
+ } 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)
+ 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 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_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.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 {
+ val bytes = FileInputStream(file).use { it.readBytes() }
+ header = requireNotNull(parseHeader(bytes)) { "Invalid PMTiles v3 header" }
+ val metadataEnd = header.metadataOffset + header.metadataLength
+ require(metadataEnd <= bytes.size) { "Invalid PMTiles metadata range" }
+ metadata = parseMetadata(bytes.copyOfRange(header.metadataOffset.toInt(), metadataEnd.toInt()).decodeToString())
+ }
+
+ companion object {
+ const val HEADER_SIZE = 127
+
+ 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,
+ )
+ }
+ }
+}
+
+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")
+ }
+}
From c19287051ab9d3e81cb6b4cbbe781dad684daf33 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:22:17 -0700
Subject: [PATCH 03/16] fix(map): validate bounded Protomaps output
---
.../offline/ProtomapsRegionDownloaderTest.kt | 91 ++++++++++++++++++-
.../map/offline/ProtomapsRegionDownloader.kt | 18 +++-
2 files changed, 103 insertions(+), 6 deletions(-)
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
index 7b3f611a43..8eea1e9021 100644
--- 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
@@ -64,7 +64,69 @@ class ProtomapsRegionDownloaderTest {
}
}
- private class FakeRangeClient(private val archives: Map) : PmtilesRangeClient {
+ @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 {
@@ -72,15 +134,18 @@ class ProtomapsRegionDownloaderTest {
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()
- return PmtilesRangeResponse(archive.statusCode, archive.bytes.copyOfRange(start, end))
+ 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(): ByteArray {
+ private fun vectorPmtiles(runLength: Int = 1): ByteArray {
val tile = byteArrayOf(0x1A, 0x00)
- val directory = byteArrayOf(1, 0, 1, tile.size.toByte(), 1)
+ 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())
@@ -100,7 +165,25 @@ class ProtomapsRegionDownloaderTest {
header.put(1)
header.put(1)
header.put(PmtilesTileType.Mvt.value)
+ header.put(0)
+ header.put(13)
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
+ }
}
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
index 9e1daee848..645b486ed0 100644
--- 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
@@ -128,9 +128,11 @@ class ProtomapsRegionDownloader(
private suspend fun sourceFor(day: LocalDate): Source? {
val url = archiveResolver.urlFor(day)
- val response = rangeClient.fetch(url, 0L until PmtilesV3Reader.HEADER_SIZE.toLong())
+ 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(response.body)
+ PmtilesV3Reader.parseHeader(exactRangeBody(response, first, last))
} else {
null
}
@@ -205,6 +207,9 @@ class ProtomapsRegionDownloader(
}
.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 =
@@ -236,6 +241,14 @@ class ProtomapsRegionDownloader(
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
}
@@ -248,6 +261,7 @@ class ProtomapsRegionDownloader(
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
}
}
From 01d9996fe2ad8880e4891e7480134c13e4cbd0bb Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:27:41 -0700
Subject: [PATCH 04/16] style(map): satisfy Burning Man policy lint
---
.../map/offline/BurningManPackPolicy.kt | 39 ++++++++-----------
1 file changed, 16 insertions(+), 23 deletions(-)
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
index a26e134626..f271890c46 100644
--- 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
@@ -19,11 +19,7 @@ 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 PackLocation(val latitude: Double, val longitude: Double, val timestamp: Instant)
data class BurningManPackManifest(
val packId: String,
@@ -44,27 +40,24 @@ class BurningManPackPolicy(
) {
fun reconcile(now: Instant, location: PackLocation?): PackAction {
- if (now >= noLocationCleanupDate) return PackAction.Remove
-
val recentLocation = location?.takeIf { it.timestamp >= now - locationMaxAge }
- if (now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation)) {
- return PackAction.Remove
- }
+ return when {
+ now >= noLocationCleanupDate -> PackAction.Remove
+
+ now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation) ->
+ PackAction.Remove
- return if (
recentLocation != null &&
- contains(recentLocation) &&
- manifest == null &&
- !installationLatched
- ) {
- PackAction.Install
- } else {
- PackAction.Retain
+ contains(recentLocation) &&
+ manifest == null &&
+ !installationLatched -> PackAction.Install
+
+ else -> PackAction.Retain
}
}
fun contains(location: PackLocation): Boolean =
- location.longitude in minLon..maxLon && location.latitude in minLat..maxLat
+ 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.
@@ -72,9 +65,9 @@ class BurningManPackPolicy(
val noLocationCleanupDate = Instant.parse("2026-09-12T07:00:00Z")
val locationMaxAge = 24.hours
- const val minLon = -119.287957
- const val minLat = 40.722536
- const val maxLon = -119.128520
- const val maxLat = 40.843420
+ const val MIN_LON = -119.287957
+ const val MIN_LAT = 40.722536
+ const val MAX_LON = -119.128520
+ const val MAX_LAT = 40.843420
}
}
From 6ff8bbefc4c42ba8f54a635469db8b7938afa906 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:38:22 -0700
Subject: [PATCH 05/16] feat(map): manage Burning Man map pack
---
.../offline/BurningManPackCoordinatorTest.kt | 196 ++++++++++++++++++
.../map/offline/BurningManPackCoordinator.kt | 195 +++++++++++++++++
2 files changed, 391 insertions(+)
create mode 100644 feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinatorTest.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt
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..0043c33817
--- /dev/null
+++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinatorTest.kt
@@ -0,0 +1,196 @@
+/*
+ * 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 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
+import java.io.File
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+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 `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()
+ }
+ }
+
+ 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,
+ ) : 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)
+ 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) {
+ 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)
+ destination.writeBytes(header.array() + root + metadata + byteArrayOf(0))
+}
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..2c10b2e156
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt
@@ -0,0 +1,195 @@
+/*
+ * 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 kotlin.time.Clock
+import kotlin.time.Instant
+import java.io.File
+
+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().download(bounds, destination) },
+) {
+ private val _selectedPack = MutableStateFlow(null)
+ val selectedPack: StateFlow = _selectedPack.asStateFlow()
+
+ suspend fun reconcile(now: Instant, lastAuthorizedLocation: PackLocation?): SelectedBurningManPack? {
+ var record = store.load()
+ var selected = record?.let(::validatedSelection)
+ if (record != null && selected == null && !record.userSuppressed) {
+ store.save(null)
+ record = 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()
+ }
+ }
+
+ fun removeByUser() {
+ 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 install(now: Instant): SelectedBurningManPack? = runCatching {
+ val downloadedPack = downloader.download(BOUNDS, destination)
+ val reader = PmtilesV3Reader(destination)
+ require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" }
+ 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(destination.isFile) { "Burning Man pack is missing" }
+ val reader = PmtilesV3Reader(destination)
+ require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" }
+ 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 val destination: File
+ get() = File(filesDirectory, "offline/$PACK_ID.pmtiles")
+
+ private companion object {
+ const val PACK_ID = "burning-man-2026"
+ val BOUNDS =
+ GeoBounds(
+ minLon = -119.287957,
+ minLat = 40.722536,
+ maxLon = -119.128520,
+ maxLat = 40.843420,
+ )
+ }
+}
From cfd30ab73c7e255303786f940d6997033c1d587d Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:44:46 -0700
Subject: [PATCH 06/16] fix(map): serialize Burning Man pack changes
---
.../offline/BurningManPackCoordinatorTest.kt | 85 +++++++++++++++++++
.../map/offline/BurningManPackCoordinator.kt | 46 ++++++----
.../map/offline/ProtomapsRegionDownloader.kt | 6 +-
3 files changed, 118 insertions(+), 19 deletions(-)
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
index 0043c33817..07e80cc0ad 100644
--- 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
@@ -16,7 +16,10 @@
*/
package org.meshtastic.feature.map.offline
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.runCurrent
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -122,6 +125,74 @@ class BurningManPackCoordinatorTest {
}
}
+ @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 `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
@@ -148,6 +219,20 @@ class BurningManPackCoordinatorTest {
}
}
+ 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",
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
index 2c10b2e156..db7d95ea9b 100644
--- 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
@@ -22,6 +22,8 @@ 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 kotlin.time.Clock
import kotlin.time.Instant
import java.io.File
@@ -91,15 +93,40 @@ class BurningManPackCoordinator(
private val downloader: BurningManPackDownloader =
BurningManPackDownloader { bounds, destination -> ProtomapsRegionDownloader().download(bounds, destination) },
) {
+ private val stateMutex = Mutex()
private val _selectedPack = MutableStateFlow(null)
val selectedPack: StateFlow = _selectedPack.asStateFlow()
- suspend fun reconcile(now: Instant, lastAuthorizedLocation: PackLocation?): SelectedBurningManPack? {
+ 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 && selected == null && !record.userSuppressed) {
+ 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
@@ -113,20 +140,6 @@ class BurningManPackCoordinator(
}
}
- fun removeByUser() {
- 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 install(now: Instant): SelectedBurningManPack? = runCatching {
val downloadedPack = downloader.download(BOUNDS, destination)
val reader = PmtilesV3Reader(destination)
@@ -163,6 +176,7 @@ class BurningManPackCoordinator(
}
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)
require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" }
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
index 645b486ed0..400cbbc7f2 100644
--- 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
@@ -93,9 +93,9 @@ class ProtomapsRegionDownloader(
val resolved = requestedTileIds.mapNotNull { tileId -> resolveTile(source.url, header, root, tileId) }
require(resolved.isNotEmpty()) { "Source has no tiles in the requested area" }
- val temporary = File(destination.parentFile, ".${destination.name}.tmp")
- destination.parentFile?.mkdirs()
- temporary.delete()
+ val parent = destination.parentFile ?: File(".")
+ parent.mkdirs()
+ val temporary = File.createTempFile(".${destination.name}.", ".tmp", parent)
var completed = false
try {
writeExtractedArchive(
From 061095d218b82a2ade5ab7caf6c1bffc66b1d815 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:48:44 -0700
Subject: [PATCH 07/16] test(map): cover queued Burning Man cleanup
---
.../offline/BurningManPackCoordinatorTest.kt | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
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
index 07e80cc0ad..00f0e494be 100644
--- 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
@@ -150,6 +150,31 @@ class BurningManPackCoordinatorTest {
}
}
+ @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()
From 81eb072d460e05de38c51297a01de233bb8a0d5c Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:58:23 -0700
Subject: [PATCH 08/16] fix(map): retain Burning Man streets at z15
---
.../offline/ProtomapsRegionDownloaderTest.kt | 32 +++++++++++++++++--
.../map/offline/ProtomapsRegionDownloader.kt | 2 +-
2 files changed, 31 insertions(+), 3 deletions(-)
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
index 8eea1e9021..a3ca8dad12 100644
--- 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
@@ -64,6 +64,32 @@ class ProtomapsRegionDownloaderTest {
}
}
+ @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 })
+ .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 `rejects a region whose root directory exceeds the PMTiles limit`() = runTest {
val today = LocalDate(2026, 9, 2)
@@ -143,7 +169,7 @@ class ProtomapsRegionDownloaderTest {
private data class FakeArchive(val statusCode: Int, val bytes: ByteArray)
- private fun vectorPmtiles(runLength: Int = 1): 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()
@@ -166,7 +192,7 @@ class ProtomapsRegionDownloaderTest {
header.put(1)
header.put(PmtilesTileType.Mvt.value)
header.put(0)
- header.put(13)
+ header.put(maxZoom.toByte())
return header.array() + directory + metadata + tile
}
@@ -185,5 +211,7 @@ class ProtomapsRegionDownloaderTest {
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/ProtomapsRegionDownloader.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/ProtomapsRegionDownloader.kt
index 400cbbc7f2..36e42e6963 100644
--- 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
@@ -259,7 +259,7 @@ class ProtomapsRegionDownloader(
private companion object {
const val BUILD_LOOKBACK_DAYS = 16
const val MIN_ZOOM = 0
- const val MAX_ZOOM = 13
+ const val MAX_ZOOM = 15
const val MAX_TILES = 600_000
const val MAX_ROOT_DIRECTORY_SIZE = 16 * 1024
const val MAX_DIRECTORY_DEPTH = 4
From 93af3a657eca90d85dc0936814c333aa4a8077ed Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:01:33 -0700
Subject: [PATCH 09/16] fix(map): scope Burning Man z15 extraction
---
.../offline/ProtomapsRegionDownloaderTest.kt | 31 ++++++++++++++++++-
.../map/offline/BurningManPackCoordinator.kt | 5 ++-
.../map/offline/ProtomapsRegionDownloader.kt | 2 +-
3 files changed, 35 insertions(+), 3 deletions(-)
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
index a3ca8dad12..6cb7f21be8 100644
--- 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
@@ -79,7 +79,12 @@ class ProtomapsRegionDownloaderTest {
val destination = File(directory, "region.pmtiles")
try {
- ProtomapsRegionDownloader(archiveResolver = resolver, rangeClient = fakeClient, utcDate = { today })
+ ProtomapsRegionDownloader(
+ archiveResolver = resolver,
+ rangeClient = fakeClient,
+ utcDate = { today },
+ maxZoom = 15,
+ )
.download(bounds = BURNING_MAN_BOUNDS, destination = destination)
val header = PmtilesV3Reader(destination).header
@@ -90,6 +95,30 @@ class ProtomapsRegionDownloaderTest {
}
}
+ @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)
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
index db7d95ea9b..d69bfb034b 100644
--- 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
@@ -91,7 +91,9 @@ class BurningManPackCoordinator(
private val filesDirectory: File,
private val store: BurningManPackStore,
private val downloader: BurningManPackDownloader =
- BurningManPackDownloader { bounds, destination -> ProtomapsRegionDownloader().download(bounds, destination) },
+ BurningManPackDownloader { bounds, destination ->
+ ProtomapsRegionDownloader(maxZoom = MAX_ZOOM).download(bounds, destination)
+ },
) {
private val stateMutex = Mutex()
private val _selectedPack = MutableStateFlow(null)
@@ -198,6 +200,7 @@ class BurningManPackCoordinator(
private companion object {
const val PACK_ID = "burning-man-2026"
+ const val MAX_ZOOM = 15
val BOUNDS =
GeoBounds(
minLon = -119.287957,
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
index 36e42e6963..400cbbc7f2 100644
--- 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
@@ -259,7 +259,7 @@ class ProtomapsRegionDownloader(
private companion object {
const val BUILD_LOOKBACK_DAYS = 16
const val MIN_ZOOM = 0
- const val MAX_ZOOM = 15
+ 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
From 3d4d68096fe056bbcc594db11908d0ff99afa06c Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:22:05 -0700
Subject: [PATCH 10/16] feat(map): render Burning Man pack in Google Maps
---
.../kotlin/org/meshtastic/app/map/MapView.kt | 24 +-
.../org/meshtastic/app/map/MapViewModel.kt | 13 +
.../offline/BurningManGoogleTileProvider.kt | 483 ++++++++++++++++++
.../BurningManGoogleTileProviderTest.kt | 131 +++++
.../map/offline/BurningManPackCoordinator.kt | 6 +
5 files changed, 656 insertions(+), 1 deletion(-)
create mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt
create mode 100644 androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt
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..863226c968 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -121,6 +121,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 +281,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 +290,12 @@ 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 +558,14 @@ 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 burningManPackCoversCamera =
+ currentCustomTileProviderUrl == null &&
+ burningManTileProvider?.covers(
+ latitude = cameraPositionState.position.target.latitude,
+ longitude = cameraPositionState.position.target.longitude,
+ ) == true
+ val effectiveGoogleMapType =
+ if (currentCustomTileProviderUrl != null || burningManPackCoversCamera) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf?>(null) }
@@ -619,6 +634,13 @@ fun MapView(
}
},
) {
+ // The validated local pack is active only while the camera remains inside its coverage boundary.
+ key(selectedBurningManPack?.file, burningManPackCoversCamera) {
+ if (burningManPackCoversCamera) {
+ TileOverlay(tileProvider = burningManTileProvider, fadeIn = false, transparency = 0f, zIndex = -2f)
+ }
+ }
+
// Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
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..be2e2fbda0 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,9 @@ 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.BurningManPackCoordinator
+import org.meshtastic.feature.map.offline.SelectedBurningManPack
+import org.meshtastic.feature.map.offline.SharedPreferencesBurningManPackStore
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -98,6 +101,14 @@ class MapViewModel(
radioConfigRepository,
notificationPrefs,
) {
+ private val burningManPackCoordinator =
+ BurningManPackCoordinator(
+ filesDirectory = application.filesDir,
+ store = SharedPreferencesBurningManPackStore(application),
+ )
+
+ val selectedBurningManPack: StateFlow = burningManPackCoordinator.selectedPack
+
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId"))
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
@@ -368,6 +379,8 @@ class MapViewModel(
loadPersistedMapType()
}
+ viewModelScope.launch { burningManPackCoordinator.restoreValidatedSelection() }
+
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..1fc8a606c8
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt
@@ -0,0 +1,483 @@
+/*
+ * 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 com.google.android.gms.maps.model.Tile
+import com.google.android.gms.maps.model.TileProvider
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+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. */
+class BurningManGoogleTileProvider(file: File) : TileProvider {
+ private val archive = LocalPmtiles(file)
+
+ override fun getTile(x: Int, y: Int, zoom: Int): Tile? =
+ archive.tile(x, y, zoom)
+ ?.let(::rasterize)
+ ?.let { bytes -> Tile(TILE_SIZE, TILE_SIZE, bytes) }
+ ?: TileProvider.NO_TILE
+
+ fun covers(latitude: Double, longitude: Double): Boolean = archive.covers(latitude, longitude)
+
+ private fun rasterize(vectorTile: ByteArray): ByteArray {
+ 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 ByteArrayOutputStream().use { output ->
+ check(bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, output))
+ output.toByteArray()
+ }
+ }
+
+ 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 companion object {
+ const val TILE_SIZE = 256
+ const val PNG_QUALITY = 100
+ const val PATH_KIND = "path"
+ const val PEDESTRIAN_KIND_DETAIL = "pedestrian"
+ val EXCLUDED_PATH_KINDS = setOf("footway", "cycleway", "track")
+ val PLAYA_COLOR = Color.rgb(234, 234, 234)
+ val LANDCOVER_COLOR = Color.rgb(228, 221, 202)
+ val LANDUSE_COLOR = Color.rgb(238, 228, 196)
+ val WATER_COLOR = Color.rgb(144, 198, 231)
+ val ROAD_COLOR = Color.rgb(255, 255, 255)
+ val MINOR_ROAD_COLOR = Color.rgb(254, 181, 196)
+ const val ROAD_WIDTH = 4f
+ const val PEDESTRIAN_ROAD_WIDTH = 3f
+ }
+}
+
+private class LocalPmtiles(private val file: File) {
+ private val header: PmtilesHeader
+ private val rootDirectory: List
+
+ 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
+
+ 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 // Task 2 writes a bounded root-only directory.
+ 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: Boolean 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 = 256f
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..6ae16c7ca3
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt
@@ -0,0 +1,131 @@
+/*
+ * 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/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackCoordinator.kt
index d69bfb034b..837c1484bb 100644
--- 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
@@ -99,6 +99,12 @@ class BurningManPackCoordinator(
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 {
+ store.load()?.let(::validatedSelection).also { selected -> _selectedPack.value = selected }
+ }
+
suspend fun reconcile(now: Instant, lastAuthorizedLocation: PackLocation?): SelectedBurningManPack? =
stateMutex.withLock { reconcileLocked(now, lastAuthorizedLocation) }
From 0d12b97bff5fb9eb2fea613806485a5e4ec03cb1 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:27:38 -0700
Subject: [PATCH 11/16] fix(map): require local provider for overlay
---
.../kotlin/org/meshtastic/app/map/MapView.kt | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
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 863226c968..fcbf2abbce 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -558,12 +558,15 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- val burningManPackCoversCamera =
- currentCustomTileProviderUrl == null &&
- burningManTileProvider?.covers(
- latitude = cameraPositionState.position.target.latitude,
- longitude = cameraPositionState.position.target.longitude,
- ) == true
+ val burningManTileProviderForCamera =
+ burningManTileProvider?.takeIf { tileProvider ->
+ currentCustomTileProviderUrl == null &&
+ tileProvider.covers(
+ latitude = cameraPositionState.position.target.latitude,
+ longitude = cameraPositionState.position.target.longitude,
+ )
+ }
+ val burningManPackCoversCamera = burningManTileProviderForCamera != null
val effectiveGoogleMapType =
if (currentCustomTileProviderUrl != null || burningManPackCoversCamera) MapType.NONE else selectedGoogleMapType
@@ -636,8 +639,8 @@ fun MapView(
) {
// The validated local pack is active only while the camera remains inside its coverage boundary.
key(selectedBurningManPack?.file, burningManPackCoversCamera) {
- if (burningManPackCoversCamera) {
- TileOverlay(tileProvider = burningManTileProvider, fadeIn = false, transparency = 0f, zIndex = -2f)
+ burningManTileProviderForCamera?.let { tileProvider ->
+ TileOverlay(tileProvider = tileProvider, fadeIn = false, transparency = 0f, zIndex = -2f)
}
}
From fbf74905af5e69f9f9b0d2b4f0084368d4d2dcdb Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:39:27 -0700
Subject: [PATCH 12/16] feat(map): share Burning Man pack runtime
---
.../org/meshtastic/app/map/MapViewModel.kt | 13 +--
.../map/offline/BurningManPackRuntimeTest.kt | 100 ++++++++++++++++++
.../map/offline/BurningManPackRuntime.kt | 75 +++++++++++++
3 files changed, 179 insertions(+), 9 deletions(-)
create mode 100644 feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntimeTest.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt
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 be2e2fbda0..62503273a3 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -55,9 +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.BurningManPackCoordinator
+import org.meshtastic.feature.map.offline.BurningManPackRuntime
import org.meshtastic.feature.map.offline.SelectedBurningManPack
-import org.meshtastic.feature.map.offline.SharedPreferencesBurningManPackStore
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -101,13 +100,9 @@ class MapViewModel(
radioConfigRepository,
notificationPrefs,
) {
- private val burningManPackCoordinator =
- BurningManPackCoordinator(
- filesDirectory = application.filesDir,
- store = SharedPreferencesBurningManPackStore(application),
- )
+ private val burningManPackRuntime = BurningManPackRuntime.forContext(application)
- val selectedBurningManPack: StateFlow = burningManPackCoordinator.selectedPack
+ val selectedBurningManPack: StateFlow = burningManPackRuntime.coordinator.selectedPack
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId"))
@@ -379,7 +374,7 @@ class MapViewModel(
loadPersistedMapType()
}
- viewModelScope.launch { burningManPackCoordinator.restoreValidatedSelection() }
+ viewModelScope.launch { burningManPackRuntime.restoreSelection() }
selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
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..f4792a1c7c
--- /dev/null
+++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntimeTest.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.
+ */
+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()
+ }
+ }
+
+ 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)
+ 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
+ }
+}
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..31276be9fc
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt
@@ -0,0 +1,75 @@
+/*
+ * 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 ?: BurningManPackRuntime(
+ coordinatorFactory = {
+ val factory = factoryForTest ?: ::newCoordinator
+ factory(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),
+ )
+ }
+}
From f83ec99412ce5cc098e2b13808d3a501e718e280 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:44:38 -0700
Subject: [PATCH 13/16] fix(map): snapshot pack runtime factory
---
.../map/offline/BurningManPackRuntimeTest.kt | 24 +++++++++++++++++++
.../map/offline/BurningManPackRuntime.kt | 12 +++++-----
2 files changed, 30 insertions(+), 6 deletions(-)
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
index f4792a1c7c..339a7d16a0 100644
--- 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
@@ -50,6 +50,24 @@ class BurningManPackRuntimeTest {
}
}
+ @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",
@@ -97,4 +115,10 @@ class BurningManPackRuntimeTest {
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/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/offline/BurningManPackRuntime.kt
index 31276be9fc..83f7b7ea2f 100644
--- 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
@@ -44,12 +44,12 @@ class BurningManPackRuntime private constructor(private val coordinatorFactory:
fun forContext(context: Context): BurningManPackRuntime {
val applicationContext = context.applicationContext
return instance ?: synchronized(instanceLock) {
- instance ?: BurningManPackRuntime(
- coordinatorFactory = {
- val factory = factoryForTest ?: ::newCoordinator
- factory(applicationContext)
- },
- ).also { instance = it }
+ instance ?: run {
+ val coordinatorFactory = factoryForTest ?: ::newCoordinator
+ BurningManPackRuntime(
+ coordinatorFactory = { coordinatorFactory(applicationContext) },
+ ).also { instance = it }
+ }
}
}
From a1b92019a951c4a637e5039ed38e340c02632e70 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:02:45 -0700
Subject: [PATCH 14/16] feat(map): render Burning Man pack in F-Droid
---
.../kotlin/org/meshtastic/app/map/MapView.kt | 46 +-
.../offline/BurningManOsmDroidTileProvider.kt | 533 ++++++++++++++++++
.../kotlin/org/meshtastic/app/MainActivity.kt | 30 +
.../BurningManOsmDroidTileProviderTest.kt | 142 +++++
4 files changed, 750 insertions(+), 1 deletion(-)
create mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt
create mode 100644 androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProviderTest.kt
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..d0aa2a6015
--- /dev/null
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt
@@ -0,0 +1,533 @@
+/*
+ * 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 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.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. */
+class BurningManOsmDroidTileProvider(file: File) : MapTileProviderBase(LocalTileSource) {
+ private val archive = LocalPmtiles(file)
+
+ init {
+ setUseDataConnection(false)
+ }
+
+ override fun getMapTile(pMapTileIndex: Long): Drawable? = archive
+ .tile(
+ MapTileIndex.getX(pMapTileIndex),
+ MapTileIndex.getY(pMapTileIndex),
+ MapTileIndex.getZoom(pMapTileIndex),
+ )
+ ?.let(::rasterize)
+ ?.let { bitmap -> BitmapDrawable(null, bitmap) }
+
+ override fun getMinimumZoomLevel(): Int = archive.minZoom
+
+ override fun getMaximumZoomLevel(): Int = archive.maxZoom
+
+ override fun getTileWriter(): IFilesystemCache? = null
+
+ override fun getQueueSize(): Long = 0
+
+ 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 object LocalTileSource : BitmapTileSourceBase("Burning Man local", 0, 15, TILE_SIZE, ".png")
+
+ private companion object {
+ const val TILE_SIZE = 256
+ const val PATH_KIND = "path"
+ const val PEDESTRIAN_KIND_DETAIL = "pedestrian"
+ val EXCLUDED_PATH_KINDS = setOf("footway", "cycleway", "track")
+ val PLAYA_COLOR = Color.rgb(234, 234, 234)
+ val LANDCOVER_COLOR = Color.rgb(228, 221, 202)
+ val LANDUSE_COLOR = Color.rgb(238, 228, 196)
+ val WATER_COLOR = Color.rgb(144, 198, 231)
+ val ROAD_COLOR = Color.rgb(255, 255, 255)
+ val MINOR_ROAD_COLOR = Color.rgb(254, 181, 196)
+ const val ROAD_WIDTH = 4f
+ const val PEDESTRIAN_ROAD_WIDTH = 3f
+ }
+}
+
+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 = 256f
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/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
+ }
+}
From 483a6522ee86416a9713002e81359770fc11a77f Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:22:54 -0700
Subject: [PATCH 15/16] fix(map): validate Burning Man pack precedence
---
.../app/map/GoogleMapTileLayerSelection.kt | 35 +++++++++++++
.../kotlin/org/meshtastic/app/map/MapView.kt | 35 +++++++------
.../map/GoogleMapTileLayerSelectionTest.kt | 52 +++++++++++++++++++
.../offline/BurningManPackCoordinatorTest.kt | 40 +++++++++++++-
.../map/offline/BurningManPackRuntimeTest.kt | 2 +
.../map/offline/BurningManPackCoordinator.kt | 24 ++++++---
6 files changed, 164 insertions(+), 24 deletions(-)
create mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt
create mode 100644 androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelectionTest.kt
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..935fc7eb0a
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 fcbf2abbce..56697d880b 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -560,15 +560,18 @@ fun MapView(
val burningManTileProviderForCamera =
burningManTileProvider?.takeIf { tileProvider ->
- currentCustomTileProviderUrl == null &&
- tileProvider.covers(
- latitude = cameraPositionState.position.target.latitude,
- longitude = cameraPositionState.position.target.longitude,
- )
+ tileProvider.covers(
+ latitude = cameraPositionState.position.target.latitude,
+ longitude = cameraPositionState.position.target.longitude,
+ )
}
val burningManPackCoversCamera = burningManTileProviderForCamera != null
- val effectiveGoogleMapType =
- if (currentCustomTileProviderUrl != null || burningManPackCoversCamera) MapType.NONE else selectedGoogleMapType
+ val tileLayerSelection =
+ googleMapTileLayerSelection(
+ selectedMapType = selectedGoogleMapType,
+ hasCustomTileSource = currentCustomTileProviderUrl != null,
+ burningManPackCoversCamera = burningManPackCoversCamera,
+ )
var showClusterItemsDialog by remember { mutableStateOf?>(null) }
@@ -604,7 +607,7 @@ fun MapView(
),
properties =
MapProperties(
- mapType = effectiveGoogleMapType,
+ mapType = tileLayerSelection.mapType,
isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
),
onMapClick = { latLng ->
@@ -645,14 +648,16 @@ fun MapView(
}
// 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/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/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
index 00f0e494be..202fbfa980 100644
--- 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
@@ -71,6 +71,39 @@ class BurningManPackCoordinatorTest {
}
}
+ @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()
@@ -230,6 +263,7 @@ class BurningManPackCoordinatorTest {
private class FakeDownloader(
private var failFirstDownload: Boolean = false,
+ private val maxZoom: Int = 15,
) : BurningManPackDownloader {
val destinations = mutableListOf()
@@ -239,7 +273,7 @@ class BurningManPackCoordinatorTest {
failFirstDownload = false
throw IllegalStateException("download failed")
}
- writeValidatedPack(destination)
+ writeValidatedPack(destination, maxZoom)
return DownloadedPack("20260902", destination)
}
}
@@ -280,7 +314,7 @@ class BurningManPackCoordinatorTest {
}
}
-private fun writeValidatedPack(destination: File) {
+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)
@@ -302,5 +336,7 @@ private fun writeValidatedPack(destination: File) {
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
index 339a7d16a0..64f60f15c0 100644
--- 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
@@ -98,6 +98,8 @@ class BurningManPackRuntimeTest {
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)
}
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
index 837c1484bb..855e1938e7 100644
--- 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
@@ -100,10 +100,15 @@ class BurningManPackCoordinator(
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 {
- store.load()?.let(::validatedSelection).also { selected -> _selectedPack.value = selected }
+ 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) }
@@ -151,7 +156,7 @@ class BurningManPackCoordinator(
private suspend fun install(now: Instant): SelectedBurningManPack? = runCatching {
val downloadedPack = downloader.download(BOUNDS, destination)
val reader = PmtilesV3Reader(destination)
- require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" }
+ validatePack(reader)
val replicationTimestamp = requireNotNull(reader.metadata[REPLICATION_TIME_KEY]) {
"Burning Man pack has no replication timestamp"
}
@@ -187,7 +192,7 @@ class BurningManPackCoordinator(
require(record.packId == PACK_ID) { "Burning Man pack identifier does not match" }
require(destination.isFile) { "Burning Man pack is missing" }
val reader = PmtilesV3Reader(destination)
- require(reader.header.tileType == PmtilesTileType.Mvt) { "Burning Man pack is not vector MVT" }
+ validatePack(reader)
require(reader.metadata[REPLICATION_TIME_KEY] == record.replicationTimestamp) {
"Burning Man pack replication timestamp does not match"
}
@@ -198,8 +203,13 @@ class BurningManPackCoordinator(
packId = packId,
sourceBuild = sourceBuild,
installedAt = installedAt,
- userSuppressed = userSuppressed,
- )
+ 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")
From 0955565e671dae3902468df0b52cfd77c4e1719b Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Mon, 20 Jul 2026 14:51:29 -0700
Subject: [PATCH 16/16] fix(map): address Burning Man Android review
---
.../offline/BurningManOsmDroidTileProvider.kt | 487 +----------------
.../app/map/GoogleMapTileLayerSelection.kt | 14 +-
.../kotlin/org/meshtastic/app/map/MapView.kt | 5 +-
.../org/meshtastic/app/map/MapViewModel.kt | 1 -
.../offline/BurningManGoogleTileProvider.kt | 456 +---------------
.../map/offline/BurningManPmtilesRenderer.kt | 508 ++++++++++++++++++
.../BurningManGoogleTileProviderTest.kt | 35 +-
.../offline/BurningManPackCoordinatorTest.kt | 35 +-
.../map/offline/BurningManPackRuntimeTest.kt | 23 +-
.../offline/ProtomapsRegionDownloaderTest.kt | 24 +-
.../map/offline/BurningManPackCoordinator.kt | 129 +++--
.../map/offline/BurningManPackRuntime.kt | 15 +-
.../map/offline/ProtomapsRegionDownloader.kt | 47 +-
.../map/offline/BurningManPackPolicy.kt | 9 +-
.../map/offline/BurningManPackPolicyTest.kt | 18 +-
15 files changed, 711 insertions(+), 1095 deletions(-)
create mode 100644 androidApp/src/main/kotlin/org/meshtastic/app/map/offline/BurningManPmtilesRenderer.kt
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
index d0aa2a6015..bffa279fc6 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/offline/BurningManOsmDroidTileProvider.kt
@@ -14,520 +14,45 @@
* 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 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.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. */
class BurningManOsmDroidTileProvider(file: File) : MapTileProviderBase(LocalTileSource) {
- private val archive = LocalPmtiles(file)
+ private val renderer = BurningManPmtilesRenderer(file)
init {
setUseDataConnection(false)
}
- override fun getMapTile(pMapTileIndex: Long): Drawable? = archive
+ override fun getMapTile(pMapTileIndex: Long): Drawable? = renderer
.tile(
MapTileIndex.getX(pMapTileIndex),
MapTileIndex.getY(pMapTileIndex),
MapTileIndex.getZoom(pMapTileIndex),
)
- ?.let(::rasterize)
- ?.let { bitmap -> BitmapDrawable(null, bitmap) }
+ ?.let { BitmapDrawable(null, it) }
- override fun getMinimumZoomLevel(): Int = archive.minZoom
+ override fun getMinimumZoomLevel(): Int = renderer.minZoom
- override fun getMaximumZoomLevel(): Int = archive.maxZoom
+ override fun getMaximumZoomLevel(): Int = renderer.maxZoom
override fun getTileWriter(): IFilesystemCache? = null
override fun getQueueSize(): Long = 0
- 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)
- }
- }
- }
- }
- }
+ 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
- const val PATH_KIND = "path"
- const val PEDESTRIAN_KIND_DETAIL = "pedestrian"
- val EXCLUDED_PATH_KINDS = setOf("footway", "cycleway", "track")
- val PLAYA_COLOR = Color.rgb(234, 234, 234)
- val LANDCOVER_COLOR = Color.rgb(228, 221, 202)
- val LANDUSE_COLOR = Color.rgb(238, 228, 196)
- val WATER_COLOR = Color.rgb(144, 198, 231)
- val ROAD_COLOR = Color.rgb(255, 255, 255)
- val MINOR_ROAD_COLOR = Color.rgb(254, 181, 196)
- const val ROAD_WIDTH = 4f
- const val PEDESTRIAN_ROAD_WIDTH = 3f
- }
-}
-
-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 = 256f
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt
index 935fc7eb0a..147ff3bd30 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapTileLayerSelection.kt
@@ -19,17 +19,13 @@ 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 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,
- )
+): 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 56697d880b..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
@@ -292,9 +291,7 @@ fun MapView(
if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
val burningManTileProvider =
remember(selectedBurningManPack?.file) {
- selectedBurningManPack?.file?.let { file ->
- runCatching { BurningManGoogleTileProvider(file) }.getOrNull()
- }
+ selectedBurningManPack?.file?.let { file -> runCatching { BurningManGoogleTileProvider(file) }.getOrNull() }
}
if (mode is GoogleMapMode.Main) {
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 62503273a3..bd6fb39b4a 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -104,7 +104,6 @@ class MapViewModel(
val selectedBurningManPack: StateFlow = burningManPackRuntime.coordinator.selectedPack
-
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId"))
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
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
index 1fc8a606c8..ec872634c7 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProvider.kt
@@ -14,470 +14,28 @@
* 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 com.google.android.gms.maps.model.Tile
import com.google.android.gms.maps.model.TileProvider
-import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
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. */
class BurningManGoogleTileProvider(file: File) : TileProvider {
- private val archive = LocalPmtiles(file)
-
- override fun getTile(x: Int, y: Int, zoom: Int): Tile? =
- archive.tile(x, y, zoom)
- ?.let(::rasterize)
- ?.let { bytes -> Tile(TILE_SIZE, TILE_SIZE, bytes) }
- ?: TileProvider.NO_TILE
-
- fun covers(latitude: Double, longitude: Double): Boolean = archive.covers(latitude, longitude)
-
- private fun rasterize(vectorTile: ByteArray): ByteArray {
- 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 ByteArrayOutputStream().use { output ->
- check(bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, output))
- output.toByteArray()
- }
- }
+ private val renderer = BurningManPmtilesRenderer(file)
- 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) }
+ 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())
}
- }
-
- 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
+ } ?: TileProvider.NO_TILE
- 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)
- }
- }
- }
- }
+ fun covers(latitude: Double, longitude: Double): Boolean = renderer.covers(latitude, longitude)
private companion object {
const val TILE_SIZE = 256
const val PNG_QUALITY = 100
- const val PATH_KIND = "path"
- const val PEDESTRIAN_KIND_DETAIL = "pedestrian"
- val EXCLUDED_PATH_KINDS = setOf("footway", "cycleway", "track")
- val PLAYA_COLOR = Color.rgb(234, 234, 234)
- val LANDCOVER_COLOR = Color.rgb(228, 221, 202)
- val LANDUSE_COLOR = Color.rgb(238, 228, 196)
- val WATER_COLOR = Color.rgb(144, 198, 231)
- val ROAD_COLOR = Color.rgb(255, 255, 255)
- val MINOR_ROAD_COLOR = Color.rgb(254, 181, 196)
- const val ROAD_WIDTH = 4f
- const val PEDESTRIAN_ROAD_WIDTH = 3f
- }
-}
-
-private class LocalPmtiles(private val file: File) {
- private val header: PmtilesHeader
- private val rootDirectory: List
-
- 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
-
- 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 // Task 2 writes a bounded root-only directory.
- 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: Boolean 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 = 256f
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/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt
index 6ae16c7ca3..c6bf4c19ed 100644
--- a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/offline/BurningManGoogleTileProviderTest.kt
@@ -81,35 +81,36 @@ class BurningManGoogleTileProviderTest {
header.put(0)
header.put(0)
- return temporaryFolder.newFile("burning-man.pmtiles").apply { writeBytes(header.array() + directory + metadata + tile) }
+ 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 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)
+ 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 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 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)
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
index 202fbfa980..9e55b90330 100644
--- 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
@@ -18,8 +18,11 @@ package org.meshtastic.feature.map.offline
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
-import kotlinx.coroutines.test.runTest
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
@@ -27,9 +30,6 @@ import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
-import java.io.File
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
class BurningManPackCoordinatorTest {
@@ -261,10 +261,8 @@ class BurningManPackCoordinatorTest {
}
}
- private class FakeDownloader(
- private var failFirstDownload: Boolean = false,
- private val maxZoom: Int = 15,
- ) : BurningManPackDownloader {
+ 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 {
@@ -292,24 +290,19 @@ class BurningManPackCoordinatorTest {
}
}
- private fun installedRecord() =
- BurningManPackRecord(
- packId = "burning-man-2026",
- sourceBuild = "20260901",
- replicationTimestamp = REPLICATION_TIME,
- installedAt = INSTALL_TIME,
- userSuppressed = false,
- )
+ 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"),
- )
+ PackLocation(latitude = 40.7864, longitude = -119.2065, timestamp = Instant.parse("2026-09-07T19:00:00Z"))
const val REPLICATION_TIME = "20260901"
}
}
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
index 64f60f15c0..8a1ba30ab1 100644
--- 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
@@ -5,6 +5,14 @@
* 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
@@ -68,14 +76,13 @@ class BurningManPackRuntimeTest {
}
}
- private fun installedRecord() =
- BurningManPackRecord(
- packId = "burning-man-2026",
- sourceBuild = "20260720",
- replicationTimestamp = "20260720",
- installedAt = Instant.parse("2026-08-25T00:00:00Z"),
- userSuppressed = false,
- )
+ 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()
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
index 6cb7f21be8..0cc0457cc1 100644
--- 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
@@ -126,7 +126,8 @@ class ProtomapsRegionDownloaderTest {
val fakeClient =
FakeRangeClient(
mapOf(
- resolver.urlFor(today) to FakeArchive(statusCode = 206, bytes = vectorPmtiles(runLength = 100_000_000)),
+ resolver.urlFor(today) to
+ FakeArchive(statusCode = 206, bytes = vectorPmtiles(runLength = 100_000_000)),
),
)
val directory = Files.createTempDirectory("protomaps-region-test").toFile()
@@ -135,12 +136,17 @@ class ProtomapsRegionDownloaderTest {
try {
val failure =
runCatching {
- ProtomapsRegionDownloader(archiveResolver = resolver, rangeClient = fakeClient, utcDate = { today })
+ 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()
+ }
+ .exceptionOrNull()
assertTrue(checkNotNull(failure).message?.contains("root directory") == true)
assertTrue(!destination.exists())
@@ -164,12 +170,17 @@ class ProtomapsRegionDownloaderTest {
try {
val failure =
runCatching {
- ProtomapsRegionDownloader(archiveResolver = resolver, rangeClient = fakeClient, utcDate = { today })
+ 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()
+ }
+ .exceptionOrNull()
assertTrue(checkNotNull(failure).message?.contains("truncated") == true)
assertTrue(!destination.exists())
@@ -241,6 +252,7 @@ class ProtomapsRegionDownloaderTest {
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)
+ 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
index 855e1938e7..5367e9c0a1 100644
--- 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
@@ -24,9 +24,9 @@ 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
-import java.io.File
data class BurningManPackRecord(
val packId: String,
@@ -52,29 +52,32 @@ class SharedPreferencesBurningManPackStore(context: Context) : BurningManPackSto
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),
- )
- }
+ 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)
+ 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()
+ .apply()
}
private companion object {
@@ -90,10 +93,9 @@ class SharedPreferencesBurningManPackStore(context: Context) : BurningManPackSto
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 downloader: BurningManPackDownloader = BurningManPackDownloader { bounds, destination ->
+ ProtomapsRegionDownloader(maxZoom = MAX_ZOOM).download(bounds, destination)
+ },
) {
private val stateMutex = Mutex()
private val _selectedPack = MutableStateFlow(null)
@@ -117,13 +119,14 @@ class BurningManPackCoordinator(
stateMutex.withLock {
destination.delete()
val record =
- store.load() ?: BurningManPackRecord(
- packId = PACK_ID,
- sourceBuild = "",
- replicationTimestamp = "",
- installedAt = Clock.System.now(),
- userSuppressed = true,
- )
+ store.load()
+ ?: BurningManPackRecord(
+ packId = PACK_ID,
+ sourceBuild = "",
+ replicationTimestamp = "",
+ installedAt = Clock.System.now(),
+ userSuppressed = true,
+ )
store.save(record.copy(userSuppressed = true))
_selectedPack.value = null
}
@@ -132,10 +135,7 @@ class BurningManPackCoordinator(
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))
- ) {
+ if (record != null && (record.packId != PACK_ID || (selected == null && !record.userSuppressed))) {
destination.delete()
store.save(null)
record = null
@@ -154,20 +154,22 @@ class BurningManPackCoordinator(
}
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]) {
+ 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(
+ BurningManPackRecord(
+ packId = PACK_ID,
+ sourceBuild = downloadedPack.sourceBuild,
+ replicationTimestamp = replicationTimestamp,
+ installedAt = now,
+ userSuppressed = false,
+ )
+ }
+ .fold(
onSuccess = { record ->
val selected = SelectedBurningManPack(destination, record)
store.save(record)
@@ -189,20 +191,21 @@ class BurningManPackCoordinator(
}
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()
+ 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,
+ packId = packId,
+ sourceBuild = sourceBuild,
+ installedAt = installedAt,
userSuppressed = userSuppressed,
)
@@ -217,12 +220,6 @@ class BurningManPackCoordinator(
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,
- )
+ 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
index 83f7b7ea2f..cdb44ef6b3 100644
--- 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
@@ -43,14 +43,15 @@ class BurningManPackRuntime private constructor(private val coordinatorFactory:
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 }
+ return instance
+ ?: synchronized(instanceLock) {
+ instance
+ ?: run {
+ val coordinatorFactory = factoryForTest ?: ::newCoordinator
+ BurningManPackRuntime(coordinatorFactory = { coordinatorFactory(applicationContext) })
+ .also { instance = it }
+ }
}
- }
}
fun installFactoryForTest(factory: (Context) -> BurningManPackCoordinator) {
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
index 400cbbc7f2..d2c4d20df9 100644
--- 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
@@ -27,8 +27,8 @@ import kotlinx.datetime.toLocalDateTime
import org.meshtastic.core.common.util.ioDispatcher
import java.io.ByteArrayInputStream
import java.io.File
-import java.io.FileInputStream
import java.io.FileOutputStream
+import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
@@ -121,7 +121,9 @@ class ProtomapsRegionDownloader(
val today = utcDate()
for (offset in 0 until BUILD_LOOKBACK_DAYS) {
val day = today.minus(DatePeriod(days = offset))
- sourceFor(day)?.let { return it }
+ sourceFor(day)?.let {
+ return it
+ }
}
return null
}
@@ -131,11 +133,12 @@ class ProtomapsRegionDownloader(
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
- }
+ 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) }
}
@@ -271,6 +274,8 @@ object HttpPmtilesRangeClient : PmtilesRangeClient {
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
@@ -287,15 +292,30 @@ class PmtilesV3Reader(file: File) {
val metadata: Map
init {
- val bytes = FileInputStream(file).use { it.readBytes() }
- header = requireNotNull(parseHeader(bytes)) { "Invalid PMTiles v3 header" }
- val metadataEnd = header.metadataOffset + header.metadataLength
- require(metadataEnd <= bytes.size) { "Invalid PMTiles metadata range" }
- metadata = parseMetadata(bytes.copyOfRange(header.metadataOffset.toInt(), metadataEnd.toInt()).decodeToString())
+ 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 (
@@ -322,6 +342,9 @@ class PmtilesV3Reader(file: File) {
}
}
+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,
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
index f271890c46..b9431fe522 100644
--- 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
@@ -44,13 +44,10 @@ class BurningManPackPolicy(
return when {
now >= noLocationCleanupDate -> PackAction.Remove
- now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation) ->
- PackAction.Remove
+ now >= outsideAreaCleanupDate && recentLocation != null && !contains(recentLocation) -> PackAction.Remove
- recentLocation != null &&
- contains(recentLocation) &&
- manifest == null &&
- !installationLatched -> PackAction.Install
+ recentLocation != null && contains(recentLocation) && manifest == null && !installationLatched ->
+ PackAction.Install
else -> PackAction.Retain
}
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
index 73d008c8e6..7c47e3da71 100644
--- 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
@@ -71,14 +71,16 @@ class BurningManPackPolicyTest {
@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,
- ),
- )
+ 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,