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,