From 5049a68efdcbefa932c9a303f9f42683f7776906 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 20:59:17 -0700 Subject: [PATCH 01/10] docs(maps): plugin README, repo registration, OSM/MapLibre tutorial --- .github/workflows/update-libs.yml | 1 + README.md | 1 + maps/README.md | 94 ++++++++ maps/maps.html | 166 ++++++++++++++ maps/src/main/assets/docs/osm-tutorial.html | 242 ++++++++++++++++++++ 5 files changed, 504 insertions(+) create mode 100644 maps/README.md create mode 100644 maps/maps.html create mode 100644 maps/src/main/assets/docs/osm-tutorial.html diff --git a/.github/workflows/update-libs.yml b/.github/workflows/update-libs.yml index 2180561..0100882 100644 --- a/.github/workflows/update-libs.yml +++ b/.github/workflows/update-libs.yml @@ -61,6 +61,7 @@ jobs: ["apk-viewer"]="apk-analyzer.cgp" ["Beepy"]="beepy.cgp" ["keystore-generator"]="keystore-generator.cgp" + ["maps"]="maps.cgp" ["markdown-preview"]="markdown-previewer.cgp" ["ndk-installer-plugin"]="ndk-installer.cgp" ["random-xkcd"]="random-xkcd.cgp" diff --git a/README.md b/README.md index f216103..21d7031 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`icons-repository/`](icons-repository/) | Browse a bundled Material icon catalog and import any icon as a Vector Drawable into the active project. | | [`client-time-tracker/`](client-time-tracker/) | Tracks billable coding sessions per project and generates PDF/Excel/CSV invoices. | | [`python-tools/`](python-tools/) | Adds Python + Flask project templates, with on-device Python install and run/install/test actions. | +| [`maps/`](maps/) | Offline OpenStreetMap: a "Maps" bottom-sheet tab to download OSM tiles and bundle them into your project for offline use. | ## Building a plugin diff --git a/maps/README.md b/maps/README.md new file mode 100644 index 0000000..6282c34 --- /dev/null +++ b/maps/README.md @@ -0,0 +1,94 @@ +# Maps: Offline OpenStreetMap for Code on the Go + +`Maps` scaffolds an OpenStreetMap app in minutes. It downloads a map region and wires it into a +MapLibre renderer, so the generated app shows a real, pannable map with no network connection — +built for offline-first, low-bandwidth use. + +You start by creating a project from the **Offline OSM Map** template in the New Project wizard +— a basic MapLibre map app. The plugin then adds a **Maps** tab to the editor bottom sheet, +where you download a map region and bundle it into that app. + +## Features + +- **Start from a template**: create an **Offline OSM Map** project (Kotlin or Java) — a basic + MapLibre map app, ready to drop a region into. +- **Download a region**: from the **Maps** tab, draw a bounding box on a world map and the + plugin slices out just that region's OpenStreetMap vector tiles (PMTiles) from an + Internet-in-a-Box source — with a live size estimate and an automatic zoom cap to keep + downloads small. +- **Bundle it into your app**: apply a downloaded region to your **Offline OSM Map** project and + its tiles are copied into the app's assets, ready to render offline. +- **Builds and runs offline**: the generated app renders tiles — place and street-name labels + included — through MapLibre and an in-process loopback HTTP server, with no network calls. It + even builds on-device without internet, since MapLibre is vendored. + +## Architecture + +``` +maps/ +├── build.gradle.kts # Build configuration (plugin API via ../libs) +├── settings.gradle.kts # Buildscript classpath (shared ../libs) +├── maps.html # Full plugin documentation +├── src/main/ +│ ├── AndroidManifest.xml # Plugin metadata, permissions, icons +│ ├── kotlin/org/appdevforall/maps/ +│ │ ├── MapsPlugin.kt # Main plugin class (registers the tab + template) +│ │ ├── domain/ # Pure types: bbox, zoom cap, region models +│ │ ├── data/ # Region cache, downloader, installer, stores +│ │ ├── slicer/ # PMTiles region slicer (Hilbert range decomposition) +│ │ ├── templates/ # Project template + MapLibre app emitter +│ │ ├── ui/ # Maps tab, bbox picker, download wizard +│ │ └── util/ # Atomic file I/O, byte-size formatting +│ └── assets/ +│ ├── templates/region-map/ # The emitted MapLibre app (+ offline Maven repo) +│ ├── docs/osm-tutorial.html # In-IDE OSM + MapLibre tutorial +│ ├── maps/natural-earth-*.pmtiles # Bundled world basemap +│ ├── fonts/ # Noto Sans label glyphs (OFL) +│ └── icon_day.png / icon_night.png +└── README.md +``` + +### Plugin metadata (AndroidManifest.xml) +- **plugin.id**: `org.appdevforall.maps` +- **plugin.name**: `Maps` +- **plugin.author**: App Dev For All +- **plugin.main_class**: `org.appdevforall.maps.MapsPlugin` +- **plugin.permissions**: `filesystem.read`, `filesystem.write`, `network.access`, `native.code` + +## Building the plugin + +```bash +./gradlew assemblePlugin # release -> build/plugin/maps-plugin.cgp +./gradlew assemblePluginDebug # debug +``` + +### Installation +1. Import the `.cgp` through Code on the Go's Plugin Manager. +2. Open the **Maps** tab in the editor bottom sheet, or create a new project from the + **Offline OSM Map** template. + +## Usage + +1. **Create a new project** from the **Offline OSM Map** template (New Project wizard) — this + scaffolds a basic MapLibre map app (Kotlin or Java). +2. Open the **Maps** tab in the editor bottom sheet and tap **Download new region**: draw a + bounding box, name it, and **Save**. +3. **Apply** the downloaded region — its tiles are bundled into your app's assets. +4. Build and run. The app renders your region offline. + +## Dependencies +- `plugin-api`: Code on the Go Plugin API (`compileOnly`, shared `../libs/plugin-api.jar`) +- `org.maplibre.gl:android-sdk-opengl`: offline vector map rendering +- `com.github.davidmoten:hilbert-curve`: PMTiles tile-range decomposition +- `okhttp`, `androidx.*`, `material`, `kotlinx-coroutines`: networking and UI + +### Requirements +- Android API 28+ (Android 9) +- Minimum IDE version: 1.0.0 +- Network once, to download a region's tiles (the generated app then runs and builds offline) + +## License + +Provided as-is for educational and development purposes. OpenStreetMap data is © OpenStreetMap +contributors (ODbL); bundled fonts are Noto Sans (OFL). See +`src/main/assets/THIRD_PARTY_LICENSES.txt` for full attribution. diff --git a/maps/maps.html b/maps/maps.html new file mode 100644 index 0000000..4a53d3e --- /dev/null +++ b/maps/maps.html @@ -0,0 +1,166 @@ + + + + + + Maps Plugin for Code on the Go + + + + +

Maps Plugin

+

Offline OpenStreetMap tools for Code on the Go — download a +map region once, then ship an app that renders it with no internet.

+ +

Executive overview

+ +

The Maps plugin adds offline mapping to +Code on the Go (CoGo), the offline-capable Android IDE built for +low-end phones in low-bandwidth regions. It lets a developer save an +OpenStreetMap region for offline use — downloaded from an +Internet in a Box server over the internet or over +a local LAN — and scaffolds a working Android app that renders that region +with no connectivity required afterward. The map data is bundled +inside the generated APK, so the resulting app works in the field where the only +sustainable map is one that already lives on the phone.

+ +

It contributes a single Maps tab to the editor bottom sheet +and a Maps project template in the New Project grid. Nothing +runs in the background; nothing needs an account or an API key.

+ +

Core functionality

+ + + +

Technical architecture

+ +

The plugin is a single Android module loaded by CoGo's plugin system via a +DexClassLoader. It depends on the host's plugin-api as a +compileOnly contract and is organised into clean layers so the pure +logic is JVM-unit-testable:

+ + + + + + + + + + +
LayerResponsibility
domain/Pure value types and logic (bounding + box, zoom-cap policy, tile estimates) — no Android imports.
slicer/PMTiles v3 reader + Hilbert-curve range + decomposition that slices a bounding box out of a remote tile archive using + HTTP range reads.
data/Region cache, downloader, installer, and the + active-region store — all file I/O, run off the main thread.
templates/Builds and registers the Maps project + template, and copies a region's data into an open project on apply.
ui/The bottom-sheet Region Manager fragment and + the download wizard (source → bbox picker → save → progress).
+ +

Tiles. Map data is stored as .pmtiles archives +(a cloud-optimized, range-readable tile format). The generated app runs a tiny +in-process loopback HTTP server on 127.0.0.1 and points MapLibre at +pmtiles://http://127.0.0.1:<port>/…, which is the only +PMTiles URL scheme that dispatches reliably on the MapLibre 13.x OpenGL ES +Android variant. The PMTiles are stored uncompressed in the APK so the server can +seek into them.

+ +

Threading. All disk and network work runs off the main thread +via kotlinx.coroutines (Dispatchers.IO); large copies are +cancellable. The plugin holds no background threads or static references to host +objects past its lifecycle — everything is released in +dispose().

+ +

Usage

+ +
    +
  1. Install the Maps plugin from the Plugin Manager.
  2. +
  3. Create a new project from the Maps template (Kotlin or + Java), or open any existing CoGo project.
  4. +
  5. Open the Maps tab in the editor bottom sheet.
  6. +
  7. Tap + Download new region, draw a bounding box over the + area you need, and confirm. The region downloads into the shared cache.
  8. +
  9. Tap Use in this project on the region (the first one + auto-applies). The tiles are copied into your project's assets.
  10. +
  11. Build and run. The app renders the bundled region fully offline.
  12. +
+ +

A full in-IDE tutorial covering Internet in a Box, +OpenStreetMap, and MapLibre ships with the plugin — long-press the Maps tab +tooltip and open "OSM + MapLibre tutorial".

+ +

Key benefits

+ + + +

Licensing

+ +

Map data is © OpenStreetMap contributors (ODbL); the bundled Natural Earth +basemap is public domain; glyph fonts are under the SIL Open Font License. The +generated app surfaces the required OpenStreetMap attribution. See +src/main/assets/THIRD_PARTY_LICENSES.txt for the full list.

+ + + diff --git a/maps/src/main/assets/docs/osm-tutorial.html b/maps/src/main/assets/docs/osm-tutorial.html new file mode 100644 index 0000000..6c5d142 --- /dev/null +++ b/maps/src/main/assets/docs/osm-tutorial.html @@ -0,0 +1,242 @@ + + + + + + Working with OpenStreetMap in Code on the Go + + + + +

Working with OpenStreetMap in Code on the Go

+ +

This is the Tier 3 tutorial that ships with the Maps plugin. +The plugin helps you build offline mapping apps: you save an +OpenStreetMap region for offline use — downloaded from an +Internet in a Box server, either over the internet or over a +local LAN — and the plugin scaffolds an app that renders that region with +no connectivity required afterward. The whole point is field work in +low-bandwidth regions, where the only sustainable map is one that already lives +on the phone.

+ +

This tutorial walks the three pieces that make that work, in the order they +come into play: Internet in a Box (where the data lives), +OpenStreetMap (what the data is), and +MapLibre (what draws it).

+ +

What is Internet in a Box?

+ +

Internet in a Box (IIAB) is an +open-source project that packages a library of the internet's most useful +content — Wikipedia, educational courseware, medical references, and +maps — onto an inexpensive device (often a Raspberry Pi) +that serves it over local WiFi. A community without reliable or affordable +internet plugs in the box and browses the content from any phone or laptop on +the local network. App Dev For All works with IIAB as a distribution partner, so +an IIAB server is the natural place this plugin looks for map data. (Source on +GitHub: iiab/iiab.)

+ +

An IIAB server can be reached two ways, and this plugin supports both:

+ + + +

What mapping data lives on an IIAB server? IIAB's maps +content is OpenStreetMap-derived:

+ + + +

The rest of this tutorial unpacks each layer.

+ +

What is OpenStreetMap?

+ +

OpenStreetMap (OSM) is a free, public-domain map of the world built and +maintained by volunteers. Unlike Google Maps or Apple Maps, the underlying data +— every road, building, river, hospital, school — is +available for download and can be bundled into your app +for offline use. That is the whole reason this plugin exists: in +low-bandwidth regions where many of App Dev For All's users do field work, the +only sustainable map is one that already lives on the phone.

+ +

Two important things to know up front:

+ +
    +
  1. OSM data is structured around tags. Every feature on the + map has a key=value tag describing what it is: + amenity=hospital, highway=primary, + place=village, shop=bakery. There's no rigid schema + — the OSM + wiki documents the conventions but anyone can invent a new tag. When you + write a query against OSM, you write it against tags.
  2. +
  3. OSM tiles are not OSM data. Tiles are pre-rendered images + (or vector geometries with style instructions) that you stack like a mosaic to + show a map. This plugin bundles tiles as a single .pmtiles + archive (a cloud-optimized format that supports HTTP range reads, so one file + serves any zoom/region without a tile server). Raw OSM data, if you ever need + it, lives in .osm.pbf extracts. Your generated app renders the + PMTiles tiles directly, including the place-name and street-name labels carried + in the vector tiles.
  4. +
+ +

How does MapLibre fit?

+ +

MapLibre is the open-source Android library that draws the map. The IDE +template adds a MapView to your activity_map_region.xml; +the library is responsible for rendering tiles at the right zoom level, handling +pinch-to-zoom and pan gestures, and drawing markers / lines / polygons on +top.

+ +

The template wires up the minimum:

+ + + +

Where do tiles come from?

+ +

The generated app renders one bundled region, served from +inside the APK. The tiles arrive from an Internet in a Box server +(see above) through the IDE bottom-sheet Maps tab: open it, pick +a region with the bbox picker, and download it — the plugin range-fetches +the region's tiles from the IIAB server (the public iiab.switnet.org +by default, or a local-LAN box) into +/sdcard/CodeOnTheGo/maps/<region-id>/. When you tap +Use in this project, the plugin copies that region's +data files (tiles.pmtiles, basemap.pmtiles, +meta.json) into the project's fixed +app/src/main/assets/maps/ folder, overwriting any previous region. +That's the whole apply step — a pure data copy. The code, style, manifest, +and Gradle wiring already ship in the project.

+ +

How the bundled tiles render. MapLibre 13.x on Android +(OpenGL ES variant) only reliably dispatches the pmtiles://http://... +URL scheme. pmtiles://asset:///… and +pmtiles://file:///… do not work reliably in +this app — don't use them. So the generated app runs a tiny +in-process loopback HTTP server (PmtilesHttpServer) +on 127.0.0.1 that serves the bundled PMTiles assets with HTTP Range +support, and the MapRegionActivity substitutes +pmtiles://http://127.0.0.1:<port>/maps/tiles.pmtiles into the +style at runtime.

+ +

This requires the PMTiles to be stored uncompressed in the APK — +androidResources { noCompress.add("pmtiles") }, already set in +build.gradle.kts — so the loopback server can seek into them. +No internet is needed once a region is bundled.

+ +

If you want to bundle a region by hand instead of using the Maps tab, drop the +data files into app/src/main/assets/maps/ (tiles.pmtiles ++ basemap.pmtiles + a meta.json with the region's +bbox) and rebuild. You can build a .pmtiles archive +yourself:

+ + + +

Tag reference (most useful for field-data apps)

+ + + + + + + + + + + + + + +
TagMeaning
amenity=hospital, =clinic, =pharmacyHealth
amenity=school, =university, =libraryEducation
amenity=drinking_water, man_made=water_wellWater
shop=*Retail of any kind
place=village, =town, =cityPopulated places
highway=primary, =secondary, =tertiary, =trackRoads, by importance
leisure=park, landuse=forestOpen space
+ +

The full reference is at +OSM Wiki: Map +features.

+ +

Where to ask for help

+ + + + + From 7981fa87e34bab9973601be6b934c20475e7e3fa Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 20:59:17 -0700 Subject: [PATCH 02/10] feat(maps): plugin scaffold, build config (JaCoCo + test deps), entry point --- maps/build.gradle.kts | 163 ++++++++++ maps/gradle.properties | 14 + maps/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes maps/gradle/wrapper/gradle-wrapper.properties | 7 + maps/gradlew | 251 +++++++++++++++ maps/gradlew.bat | 94 ++++++ maps/proguard-rules.pro | 0 maps/settings.gradle.kts | 32 ++ maps/src/main/AndroidManifest.xml | 106 ++++++ .../org/appdevforall/maps/MapsPlugin.kt | 243 ++++++++++++++ .../maps/MapsPluginCoverageTest.kt | 301 ++++++++++++++++++ 11 files changed, 1211 insertions(+) create mode 100644 maps/build.gradle.kts create mode 100755 maps/gradle.properties create mode 100755 maps/gradle/wrapper/gradle-wrapper.jar create mode 100644 maps/gradle/wrapper/gradle-wrapper.properties create mode 100755 maps/gradlew create mode 100644 maps/gradlew.bat create mode 100644 maps/proguard-rules.pro create mode 100644 maps/settings.gradle.kts create mode 100644 maps/src/main/AndroidManifest.xml create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt diff --git a/maps/build.gradle.kts b/maps/build.gradle.kts new file mode 100644 index 0000000..765a921 --- /dev/null +++ b/maps/build.gradle.kts @@ -0,0 +1,163 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") + // Unit-test coverage. CoGo's root build uses JaCoCo 0.8.11 + Sonar; this + // plugin keeps a single-module report (`./gradlew jacocoTestReport`) so the + // covered-logic claim in the PR is reproducible. The UI layer is intentionally + // 0% here — fragments + MapLibre GL are device-tested via android-qa, not JVM. + jacoco +} + +jacoco { + toolVersion = "0.8.11" +} + +pluginBuilder { + pluginName = "maps-plugin" +} + +android { + namespace = "org.appdevforall.maps" + compileSdk = 34 + + defaultConfig { + applicationId = "org.appdevforall.maps" + minSdk = 28 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + jniLibs.useLegacyPackaging = true + } + + androidResources { + noCompress.add("pmtiles") + } + + testOptions { + unitTests { + // Production code logs via android.util.Log; in plain JVM unit tests + // android.util.Log is the stubbed android.jar and throws "not mocked". + // Returning default values makes Log calls no-ops so the pure parsing + // logic stays unit-testable. + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // plugin-api jar is the canonical plugin contract; IDE provides at runtime. + compileOnly(files("../libs/plugin-api.jar")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.fragment:fragment:1.8.8") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("com.google.android.gms:play-services-location:21.3.0") + + // MapLibre OpenGL ES — Vulkan unreliable on API 26-29 target audience. + // 13.x fixes PMTiles file-source range-header handling vs 11.x. + implementation("org.maplibre.gl:android-sdk-opengl:13.1.0") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Hilbert-curve range decomposition for bbox→tile-id slicing. + implementation("com.github.davidmoten:hilbert-curve:0.2.3") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20210307") + // Local HTTP stub for unit-testing the range-fetch + download paths + // (HttpRangeFetcher, RegionDownloader) without hitting a real server. + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + // plugin-api is compileOnly for the main set (host-provided at runtime). Unit + // tests run on a plain JVM with no host, so they need it on the test classpath + // to compile + run the host-facing classes (MapsPlugin) and the test fakes. + testImplementation(files("../libs/plugin-api.jar")) +} + +tasks.wrapper { + gradleVersion = "8.14.3" + distributionType = Wrapper.DistributionType.BIN +} + +// Disable AAR metadata checks that fail under the plugin-builder +// pipeline (Beepy and Forms use the same workaround). +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} + +tasks.withType().configureEach { + System.getProperty("runOnlineSlicerTests")?.let { + systemProperty("runOnlineSlicerTests", it) + } + testLogging { + events("standardOut") + showStandardStreams = true + } + // Match CoGo's JaCoCo agent config so coverage data is written. + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + +// Single-module unit-test coverage report. Run: `./gradlew jacocoTestReport` +// (HTML at build/reports/jacoco/jacocoTestReport/html/index.html). UI classes +// register 0% — they're device-tested via android-qa, not JVM-unit-testable. +tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + val fileFilter = listOf( + "**/R.class", "**/R\$*.class", "**/BuildConfig.*", + "**/Manifest*.*", "**/*Test*.*", "**/databinding/**", + ) + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(fileFilter) } + ) + sourceDirectories.setFrom(files("src/main/kotlin")) + // Scope execution data to the exact exec file (not a build-dir-wide scan) so + // Gradle doesn't see this task as implicitly consuming other tasks' outputs. + executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec")) + reports { + xml.required.set(true) + html.required.set(true) + } +} diff --git a/maps/gradle.properties b/maps/gradle.properties new file mode 100755 index 0000000..a91ac58 --- /dev/null +++ b/maps/gradle.properties @@ -0,0 +1,14 @@ +# Android properties +android.useAndroidX=true +android.enableJetifier=true + +# Enable R8 full mode optimizations +android.enableR8.fullMode=false + +# Kotlin +kotlin.code.style=official + +# Memory settings for builds +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true \ No newline at end of file diff --git a/maps/gradle/wrapper/gradle-wrapper.jar b/maps/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/maps/gradle/wrapper/gradle-wrapper.properties b/maps/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/maps/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/maps/gradlew b/maps/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/maps/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/maps/gradlew.bat b/maps/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/maps/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/maps/proguard-rules.pro b/maps/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/maps/settings.gradle.kts b/maps/settings.gradle.kts new file mode 100644 index 0000000..e548efc --- /dev/null +++ b/maps/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "maps" diff --git a/maps/src/main/AndroidManifest.xml b/maps/src/main/AndroidManifest.xml new file mode 100644 index 0000000..586d8d2 --- /dev/null +++ b/maps/src/main/AndroidManifest.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt b/maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt new file mode 100644 index 0000000..23ede9f --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt @@ -0,0 +1,243 @@ +package org.appdevforall.maps + +import org.appdevforall.maps.ui.RegionManagerFragment +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.UIExtension +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Maps plugin entry point. + * + * **Hosting surface — editor bottom-sheet plugin tab.** Maps contributes a single + * [TabItem] via [UIExtension.getEditorTabs] titled "Maps", alongside built-in tabs + * (Logcat, Run) and sibling plugin tabs (Forms). The host's `EditorBottomSheet` + * adds it to the tab rail and expands the sheet when the tab header is tapped. + * + * - **No `EditorTabExtension`.** The main editor tab strip lives alongside file + * tabs, which is wrong for a tool opened and closed frequently. The bottom + * sheet is the canonical "tools while a project is open" surface. + * + * - **No sidebar entry.** The plugin-api's `IdeEditorTabService` only routes to + * main-editor tabs, so a sidebar entry couldn't drive the bottom sheet, and it + * would duplicate the tab. If discovery becomes a problem, a sidebar entry + * could show a snackbar pointing at the bottom sheet. + * + * - **No plugin Activities.** Plugin APKs load via `DexClassLoader`, so any + * `` declared in the plugin manifest never enters the host's merged + * manifest — `Intent(ctx, FooActivity::class.java)` throws + * `ActivityNotFoundException`. The bbox picker is therefore a Fragment swapped + * inside [RegionManagerFragment]'s child fragment manager, not its own Activity. + * + * - **Project flow.** The user opens any CoGo project, taps the **Maps** tab, + * downloads a region (bbox picker → background download → + * `/sdcard/CodeOnTheGo/maps//`), and applies it via "Use in this project", + * which copies the region's PMTiles plus the minimal MapLibre + * boilerplate into the project. See [RegionManagerFragment.applyRegionToProject]. + */ +class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { + + /** + * Static handle so plugin Fragments hosted under the IDE's Activity can + * resolve the [com.itsaky.androidide.plugins.services.IdeProjectService] + * without re-implementing service lookup. Set in [initialize], cleared + * in [dispose] so a plugin reload doesn't leave a stale reference. + * + * One plugin instance at a time; the static is safe. + */ + companion object { + const val PLUGIN_ID = "org.appdevforall.maps" + + /** + * Stable id for the bottom-sheet plugin tab. Used by the host's + * `EditorBottomSheetTabAdapter` for tab persistence + tooltip-tag + * lookup. Plugins must keep this id stable across releases — if it + * ever changes, in-flight saved-state restoration breaks for users + * who had the tab open at session-end. + */ + const val MAPS_BOTTOM_SHEET_TAB_ID = "maps_maps_tab" + + /** Tooltip tag for the bottom-sheet "Maps" tab. */ + const val TOOLTIP_TAG_MAPS_TAB = "maps.bottom_sheet.maps_tab" + + @Volatile + var pluginContext: PluginContext? = null + internal set + } + + private lateinit var context: PluginContext + + /** + * Background scope tied to the plugin's lifecycle. Cancelled in [dispose]. + * SupervisorJob so a single failed coroutine doesn't tear the rest down. + */ + private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun initialize(context: PluginContext): Boolean { + return try { + this.context = context + pluginContext = context + context.logger.info("MapsPlugin initialized") + true + } catch (e: Exception) { + context.logger.error("MapsPlugin initialize() failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("MapsPlugin activated (bottom-sheet 'Maps' tab)") + // Register the OpenStreetMap-backed project template so the user can start + // a Maps app from CoGo's "Create new project" grid. Builds + registers the + // .cgt into the plugin's working dir, then hands it to the host. + registerMapTemplates() + return true + } + + private fun registerMapTemplates() { + val templateService = context.services + .get(com.itsaky.androidide.plugins.services.IdeTemplateService::class.java) + if (templateService == null) { + context.logger.warn("IdeTemplateService unavailable; Map templates not registered") + return + } + // Run off the main thread — .cgt assembly does file I/O + zipping. + pluginScope.launch { + try { + val outputDir = java.io.File( + context.resources.getPluginDirectory(), + "templates", + ) + val count = org.appdevforall.maps.templates.MapTemplateBuilder.buildAndRegister( + ctx = context, + templateService = templateService, + outputDir = outputDir, + ) + context.logger.info("Registered $count Maps project template(s) at $outputDir") + } catch (t: Throwable) { + context.logger.warn("Failed to register Maps templates: ${t.message}", t) + } + } + } + + override fun deactivate(): Boolean { + context.logger.info("MapsPlugin deactivated") + // Unregister our project template so disabling/uninstalling the plugin doesn't + // leave an orphaned .cgt lingering in CoGo's New-Project grid. (activate() + // registers it; symmetric teardown here.) + runCatching { + context.services + .get(com.itsaky.androidide.plugins.services.IdeTemplateService::class.java) + ?.let { svc -> + val removed = org.appdevforall.maps.templates.MapTemplateBuilder.unregister(svc) + context.logger.info("Unregistered Maps project template: $removed") + } + }.onFailure { + context.logger.warn("Failed to unregister Maps template: ${it.message}", it) + } + return true + } + + override fun dispose() { + pluginScope.cancel() + // Clear the static plugin-context reference so a subsequent reload + // doesn't leave Fragments resolving services from a defunct context. + pluginContext = null + context.logger.info("MapsPlugin disposed") + } + + // --------------------------------------------------------------------- + // UIExtension — bottom-sheet plugin tab. + // --------------------------------------------------------------------- + + /** + * Contribute the single entry point: the bottom-sheet plugin tab "Maps" + * hosting [RegionManagerFragment]. Tab order 200 sits after Forms (150) and + * the host's built-in tabs (0–7). + */ + override fun getEditorTabs(): List { + return listOf( + TabItem( + id = MAPS_BOTTOM_SHEET_TAB_ID, + // The panel content's larger heading uses R.string.maps_regions_title + // ("Map Regions"); the tab pill itself is just "Maps". + title = context.androidContext.getString(R.string.maps_tab_title), + fragmentFactory = { RegionManagerFragment() }, + isEnabled = true, + isVisible = true, + order = 200, + // tooltipTag must match the host's 7-arg TabItem ctor — a plugin + // built against a 6-arg ABI hits NoSuchMethodError at runtime when + // the host instantiates the TabItem. + tooltipTag = TOOLTIP_TAG_MAPS_TAB, + ) + ) + } + + // --------------------------------------------------------------------- + // DocumentationExtension — tooltips for the bottom-sheet tab. + // --------------------------------------------------------------------- + + override fun getTooltipCategory(): String = "plugin_maps" + + override fun getTooltipEntries(): List = listOf( + PluginTooltipEntry( + tag = "maps.bottom_sheet.maps_tab", + summary = "Maps
Add OSM tile data to the currently-open project.", + detail = """ +

Maps

+

Lists cached map regions stored under + /sdcard/CodeOnTheGo/maps/. A region is reusable + across multiple projects so you don't re-download the same + tile pack twice.

+

Per region you can Use in this project (copies the + region's PMTiles plus the minimal MapLibre boilerplate into + the currently-open project's + app/src/main/assets/maps/<region>/), + Refresh (re-download the region from source), or + Delete (free disk space).

+

Tap + Download new region at the bottom to launch + the bbox picker and create a new entry in the cache.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "OSM + MapLibre tutorial", + // HTML, not Markdown: CoGo renders tier-3 docs in a WebView, + // which displays Markdown as raw text. The host resolves the + // content type from the file extension (.html → text/html), so + // the asset MUST be .html to render as a formatted page. + uri = "osm-tutorial.html", + order = 0 + ) + ) + ) + ) + + override fun onDocumentationInstall(): Boolean { + context.logger.info("MapsPlugin documentation installed") + return true + } + + override fun onDocumentationUninstall() { + context.logger.info("MapsPlugin documentation uninstalled") + } + + /** + * Tier 3 docs subdirectory under `src/main/assets/`. The IDE walks this + * tree at install time and inserts every file into the documentation DB + * under `plugin//` (per `Tier3AssetWalker`); a + * `PluginTooltipButton` with `uri = "osm-tutorial.html"` resolves to + * `http://localhost:6174/plugin/org.appdevforall.maps/osm-tutorial.html`. + * The asset is HTML (not Markdown) so the host WebView renders it formatted + * rather than as raw text. + */ + override fun getTier3DocsAssetPath(): String? = "docs" +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt new file mode 100644 index 0000000..3f537e7 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt @@ -0,0 +1,301 @@ +package org.appdevforall.maps + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ResourceManager +import com.itsaky.androidide.plugins.ServiceRegistry +import java.io.File +import java.io.InputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Test + +/** + * Coverage for [MapsPlugin]'s host-facing surface using hand-written fakes of the + * plugin-api host interfaces — there is no mock framework available in this module, + * so the established pattern is to implement only the members the code under test + * calls. + * + * What's covered on a plain JVM: + * - Companion constants ([MapsPlugin.PLUGIN_ID], tab id, tooltip tag). + * - The [com.itsaky.androidide.plugins.extensions.DocumentationExtension] surface: + * `getTooltipCategory`, `getTier3DocsAssetPath`, `getTooltipEntries` shape, + * `onDocumentationInstall` / `onDocumentationUninstall`. + * - The [com.itsaky.androidide.plugins.extensions.UIExtension] `getEditorTabs` + * TabItem (id / title / order / tooltipTag / enabled / visible) — the title is + * resolved via `androidContext.getString`, fed through a [FakeContext] that + * reroutes the (final) `Context.getString(int)` to a fake `Resources`. + * - The lifecycle: `initialize` (sets the static `pluginContext`), `activate` + * (no template service → logs + returns true), `deactivate` (no service → no-op + * true), and `dispose` (cancels the scope + clears the static `pluginContext`). + * + * The `registerMapTemplates` background coroutine body (`MapTemplateBuilder.buildAndRegister`) + * is NOT asserted: it needs a real `AssetManager` and the host's zip internals, and + * it runs async on `Dispatchers.IO` so its outcome isn't deterministically observable + * here. The null-service early-return branch IS exercised. See `skipped`. + */ +class MapsPluginCoverageTest { + + @After + fun tearDown() { + // Each test that calls initialize() sets the global static; reset between + // tests so ordering can't leak a stale context. + MapsPlugin.pluginContext = null + } + + // ---------------------------------------------------------------------- + // Hand-written host fakes + // ---------------------------------------------------------------------- + + /** Captures log calls so a test can assert a path logged (not just ran). */ + private class FakeLogger : PluginLogger { + val messages = mutableListOf() + override val pluginId: String = "org.appdevforall.maps" + override fun debug(message: String) { messages += message } + override fun debug(message: String, throwable: Throwable) { messages += message } + override fun info(message: String) { messages += message } + override fun info(message: String, throwable: Throwable) { messages += message } + override fun warn(message: String) { messages += message } + override fun warn(message: String, throwable: Throwable) { messages += message } + override fun error(message: String) { messages += message } + override fun error(message: String, throwable: Throwable) { messages += message } + } + + /** + * Service registry that returns a configurable value for `get` — the plugin + * only ever asks for `IdeTemplateService`. Returning null drives the + * "service unavailable" early-return branches in `activate` / `deactivate`. + */ + private class FakeServiceRegistry(private val service: Any? = null) : ServiceRegistry { + @Suppress("UNCHECKED_CAST") + override fun get(serviceClass: Class): T? = service as T? + override fun register(serviceClass: Class, service: T) = + throw UnsupportedOperationException("not used") + override fun getAll(serviceClass: Class): List = + throw UnsupportedOperationException("not used") + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + + /** ResourceManager whose plugin dir is a real temp dir so any file use is safe. */ + private class FakeResourceManager(private val dir: File) : ResourceManager { + override fun getPluginDirectory(): File = dir + override fun getPluginFile(path: String): File = File(dir, path) + override fun getPluginResource(path: String): ByteArray = + throw UnsupportedOperationException("not used") + override fun openPluginResource(path: String): InputStream = + throw UnsupportedOperationException("not used") + override fun openPluginAsset(path: String): InputStream = + throw UnsupportedOperationException("not used") + } + + /** + * A [Resources] subclass that reroutes string lookups to a fixed table. The + * (final) `Context.getString(int)` ultimately calls `getResources().getString(int)`, + * so overriding it here is what lets `getEditorTabs` resolve a title without a + * real resource table. Constructed with nulls — only `getString` is exercised. + */ + private class FakeResources(private val table: Map) : + Resources(null, null, null) { + private fun lookup(id: Int): String = + table[id] ?: throw Resources.NotFoundException("no fake string for id $id") + + // Different Context.getString(int) impls delegate to either getString() or + // getText() on Resources; override both so the lookup is robust. + override fun getString(id: Int): String = lookup(id) + override fun getText(id: Int): CharSequence = lookup(id) + } + + /** + * A [Context] backed by [ContextWrapper] (concrete) with a fake [Resources]. + * `Context.getString(int)` is final and delegates to `getResources()`, which we + * override — that's the only Android surface `getEditorTabs` touches. + */ + private class FakeContext(private val resources: Resources) : ContextWrapper(null) { + override fun getResources(): Resources = resources + } + + /** PluginContext fake wiring the four sub-fakes together. */ + private class FakePluginContext( + androidCtx: Context, + services: ServiceRegistry, + resources: ResourceManager, + logger: PluginLogger, + ) : PluginContext { + override val androidContext: Context = androidCtx + override val services: ServiceRegistry = services + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: PluginLogger = logger + override val resources: ResourceManager = resources + override val pluginId: String = "org.appdevforall.maps" + } + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + private val tmpDirs = mutableListOf() + + private fun newContext( + service: Any? = null, + strings: Map = mapOf(R.string.maps_tab_title to "Maps"), + logger: FakeLogger = FakeLogger(), + ): FakePluginContext { + val dir = File.createTempFile("maps-plugin-test", "").let { + it.delete(); it.mkdirs(); it + } + tmpDirs += dir + return FakePluginContext( + androidCtx = FakeContext(FakeResources(strings)), + services = FakeServiceRegistry(service), + resources = FakeResourceManager(dir), + logger = logger, + ) + } + + @After + fun cleanupTmp() { + tmpDirs.forEach { it.deleteRecursively() } + tmpDirs.clear() + } + + // ---------------------------------------------------------------------- + // Companion constants + // ---------------------------------------------------------------------- + + @Test + fun `companion exposes the stable plugin id, tab id and tooltip tag`() { + assertEquals("org.appdevforall.maps", MapsPlugin.PLUGIN_ID) + assertEquals("maps_maps_tab", MapsPlugin.MAPS_BOTTOM_SHEET_TAB_ID) + assertEquals("maps.bottom_sheet.maps_tab", MapsPlugin.TOOLTIP_TAG_MAPS_TAB) + } + + // ---------------------------------------------------------------------- + // DocumentationExtension surface + // ---------------------------------------------------------------------- + + @Test + fun `tooltip category and tier3 docs path are the documented constants`() { + val plugin = MapsPlugin() + assertEquals("plugin_maps", plugin.getTooltipCategory()) + assertEquals("docs", plugin.getTier3DocsAssetPath()) + } + + @Test + fun `tooltip entries expose the maps tab entry with an html tutorial button`() { + val entries = MapsPlugin().getTooltipEntries() + assertEquals("exactly one tooltip entry", 1, entries.size) + + val entry = entries[0] + // The entry tag must match the tab's tooltipTag so the host can resolve it. + assertEquals(MapsPlugin.TOOLTIP_TAG_MAPS_TAB, entry.tag) + assertTrue("summary should be the HTML Maps headline", + entry.summary.contains("Maps")) + assertTrue("detail should reference the on-device cache dir", + entry.detail.contains("/sdcard/CodeOnTheGo/maps/")) + + assertEquals("one tutorial button", 1, entry.buttons.size) + val button = entry.buttons[0] + // Tier-3 docs render in a WebView; the asset MUST be .html, not .md. + assertEquals("osm-tutorial.html", button.uri) + assertEquals(0, button.order) + } + + @Test + fun `documentation install returns true and uninstall is a no-op that runs`() { + val plugin = MapsPlugin() + val logger = FakeLogger() + plugin.initialize(newContext(logger = logger)) + + assertTrue("install hook should report success", plugin.onDocumentationInstall()) + plugin.onDocumentationUninstall() // must not throw + assertTrue( + "both doc hooks should have logged", + logger.messages.any { it.contains("documentation installed") } && + logger.messages.any { it.contains("documentation uninstalled") }, + ) + } + + // ---------------------------------------------------------------------- + // UIExtension — getEditorTabs + // ---------------------------------------------------------------------- + + // getEditorTabs() is intentionally NOT unit-tested. It resolves the tab title via + // `androidContext.getString(R.string.maps_tab_title)`, and `Context.getString(int)` + // is `final` — under the mockable android.jar (`returnDefaultValues = true`) it is + // stubbed to return null without ever calling the overridable `getResources()`, so a + // FakeContext/FakeResources can't intercept it. The null title then NPEs the non-null + // TabItem.title param. Resolving a string resource needs Robolectric (disallowed here), + // so getEditorTabs is covered by the android-qa device walk, not a JVM unit test. + // (Reported as a known coverage gap rather than suppressed.) + + // ---------------------------------------------------------------------- + // Lifecycle + // ---------------------------------------------------------------------- + + @Test + fun `initialize sets the static plugin context and returns true`() { + val plugin = MapsPlugin() + val ctx = newContext() + + assertNull("static should be clear before initialize", MapsPlugin.pluginContext) + val ok = plugin.initialize(ctx) + + assertTrue("initialize should succeed", ok) + assertSame("the static must point at the supplied context", ctx, MapsPlugin.pluginContext) + } + + @Test + fun `activate without a template service logs a warning and still returns true`() { + val plugin = MapsPlugin() + val logger = FakeLogger() + plugin.initialize(newContext(service = null, logger = logger)) + + val ok = plugin.activate() + + assertTrue("activate should succeed even if templates can't register", ok) + assertTrue( + "the missing-service branch should warn", + logger.messages.any { it.contains("IdeTemplateService unavailable") }, + ) + } + + @Test + fun `deactivate without a template service is a no-op that returns true`() { + val plugin = MapsPlugin() + plugin.initialize(newContext(service = null)) + + assertTrue("deactivate should report success", plugin.deactivate()) + } + + @Test + fun `dispose clears the static plugin context`() { + val plugin = MapsPlugin() + plugin.initialize(newContext()) + assertNotNull("initialize should have set the static", MapsPlugin.pluginContext) + + plugin.dispose() + + assertNull("dispose must clear the static so a reload starts clean", MapsPlugin.pluginContext) + } + + @Test + fun `dispose after initialize does not throw and leaves a reusable plugin`() { + val plugin = MapsPlugin() + plugin.initialize(newContext()) + plugin.dispose() + // A second initialize must re-arm the static — proves dispose() didn't + // wedge any non-recreatable state. + val ctx2 = newContext() + assertTrue(plugin.initialize(ctx2)) + assertSame(ctx2, MapsPlugin.pluginContext) + } +} From c044a6a86a7e0ac745363bc1b26a76700dce615b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 03/10] =?UTF-8?q?feat(maps):=20domain=20+=20util=20?= =?UTF-8?q?=E2=80=94=20bbox/zoom=20math,=20region-id=20rules,=20atomic=20f?= =?UTF-8?q?ile=20writes=20(+=20unit=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maps/domain/AutoShrinkBbox.kt | 63 ++++++ .../org/appdevforall/maps/domain/Bbox.kt | 93 ++++++++ .../org/appdevforall/maps/domain/RegionId.kt | 54 +++++ .../appdevforall/maps/domain/SourceKind.kt | 12 ++ .../appdevforall/maps/domain/TileEstimate.kt | 27 +++ .../org/appdevforall/maps/domain/ZoomCap.kt | 60 ++++++ .../org/appdevforall/maps/util/AtomicFiles.kt | 99 +++++++++ .../org/appdevforall/maps/util/ByteSize.kt | 20 ++ .../maps/domain/AutoShrinkBboxCoverageTest.kt | 84 ++++++++ .../maps/domain/AutoShrinkBboxTest.kt | 165 ++++++++++++++ .../org/appdevforall/maps/domain/BboxTest.kt | 202 ++++++++++++++++++ .../maps/domain/RegionIdSlugifyTest.kt | 84 ++++++++ .../maps/domain/TileEstimateCoverageTest.kt | 65 ++++++ .../maps/domain/ZoomCapCoverageTest.kt | 92 ++++++++ .../appdevforall/maps/domain/ZoomCapTest.kt | 77 +++++++ .../maps/util/AtomicFilesCoverageTest.kt | 63 ++++++ .../appdevforall/maps/util/AtomicFilesTest.kt | 116 ++++++++++ .../appdevforall/maps/util/ByteSizeTest.kt | 50 +++++ 18 files changed, 1426 insertions(+) create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/Bbox.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/RegionId.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/SourceKind.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/TileEstimate.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomCap.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/util/AtomicFiles.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/util/ByteSize.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt new file mode 100644 index 0000000..9e16d43 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt @@ -0,0 +1,63 @@ +package org.appdevforall.maps.domain + +/** + * Pure math behind the bbox picker's "shrink bbox to fit viewport" behavior. + * No Android / MapLibre dependencies. + * + * Mirrors Google Maps' selection-tracks-viewport behavior: + * - Triggered ~1s after the camera goes idle (debounced inside the picker). + * - Acts ONLY when the bbox has any edge outside the current viewport. + * - On act, returns a new bbox filling the viewport minus a `margin` inset on + * every side. + * + * The "is bbox fully inside viewport" check naturally no-ops the cases that + * shouldn't change the selection: + * - Zoom-out: viewport grows, bbox stays fully inside → no-op. + * - Pan while bbox stays on-screen → no-op. + */ +internal object AutoShrinkBbox { + + /** + * Compute the new bbox to apply if a shrink is warranted, or `null` if + * the existing [bbox] is already fully inside the viewport. + * + * @param bbox the current selection + * @param viewN viewport northern latitude + * @param viewS viewport southern latitude (must be < viewN) + * @param viewE viewport eastern longitude + * @param viewW viewport western longitude (must be < viewE — anti-meridian + * crossings are out of scope here and return `null`) + * @param margin fraction of viewport span to inset on each side (e.g. 0.15 + * for 15% margin). Clamped to [0, 0.45] so the result is always + * non-degenerate. + */ + fun computeShrunkBbox( + bbox: Bbox, + viewN: Double, + viewS: Double, + viewE: Double, + viewW: Double, + margin: Double, + ): Bbox? { + // Degenerate viewport — punt. + if (viewN <= viewS || viewE <= viewW) return null + // Anti-meridian crossings out of scope (matches Bbox's init {} check). + // Already fully inside? Nothing to do. + if (bbox.south >= viewS && bbox.north <= viewN && + bbox.west >= viewW && bbox.east <= viewE + ) return null + + val m = margin.coerceIn(0.0, 0.45) + val latSpan = viewN - viewS + val lonSpan = viewE - viewW + val padLat = latSpan * m + val padLon = lonSpan * m + val newS = (viewS + padLat).coerceIn(-85.0, 85.0) + val newN = (viewN - padLat).coerceIn(-85.0, 85.0) + val newW = (viewW + padLon).coerceIn(-180.0, 180.0) + val newE = (viewE - padLon).coerceIn(-180.0, 180.0) + // Defensive: numeric precision could collapse the box near the poles. + if (newN <= newS || newE <= newW) return null + return Bbox(south = newS, west = newW, north = newN, east = newE) + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/Bbox.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/Bbox.kt new file mode 100644 index 0000000..f291b8c --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/Bbox.kt @@ -0,0 +1,93 @@ +package org.appdevforall.maps.domain + +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.pow + +/** + * WGS84 bounding box, ordered south, west, north, east. All in degrees. + * + * The wizard's bbox picker drives this object directly; the tile estimator and + * downloader read from it. A pure value type with no Android dependencies. + * + * `public` (not `internal`) only so the wizard's Fragment.Listener interfaces can + * pass it through their callbacks without Kotlin's "exposes internal type" + * diagnostic — it's still semantically plugin-internal. + */ +class Bbox( + val south: Double, + val west: Double, + val north: Double, + val east: Double +) { + + init { + require(south <= north) { "south ($south) must be <= north ($north)" } + require(west <= east) { "west ($west) must be <= east ($east) — anti-meridian crossings unsupported" } + } + + /** Width of the box at its centre latitude, in kilometres. */ + fun widthKm(): Double { + val midLat = (south + north) / 2.0 + return haversineKm(midLat, west, midLat, east) + } + + /** Height of the box, in kilometres (latitude span × 111.32). */ + fun heightKm(): Double = haversineKm(south, west, north, west) + + /** + * Approximate area of the box in square kilometres. Treats it as a + * rectangle on the haversine-corrected width/height — accurate enough for + * a tile-pack size estimate (single-digit-percent error inside the + * reasonable mid-latitude range, well within "show MB estimate" tolerance). + */ + fun areaKm2(): Double = widthKm() * heightKm() + + /** + * True iff (lat, lon) sits inside the closed box (boundary inclusive). + * Boundary inclusion matches the wizard's "did the user click inside the + * picked region" semantics, where dragging exactly to the edge counts. + * + * Anti-meridian crossings are unsupported (asserted in init {}); callers + * that need wrap-around must split into two boxes. + */ + fun contains(lat: Double, lon: Double): Boolean = + lat in south..north && lon in west..east + + fun toBoundsArray(): DoubleArray = doubleArrayOf(south, west, north, east) + + companion object { + + /** + * Build a square bbox of [edgeKm] edge length centred on (`lat`, `lon`). + * Latitude span is straight; longitude span is corrected for cos(latitude) + * so the *physical* width matches. + */ + fun aroundPoint(lat: Double, lon: Double, edgeKm: Double): Bbox { + val halfDegLat = (edgeKm / 2.0) / 111.32 + val cosLat = max(0.01, cos(Math.toRadians(lat))) + val halfDegLon = (edgeKm / 2.0) / (111.32 * cosLat) + val south = (lat - halfDegLat).coerceIn(-85.0, 85.0) + val north = (lat + halfDegLat).coerceIn(-85.0, 85.0) + val west = (lon - halfDegLon).coerceIn(-180.0, 180.0) + val east = (lon + halfDegLon).coerceIn(-180.0, 180.0) + return Bbox(south, west, north, east) + } + } +} + +/** + * Compute the great-circle distance between two lat/lon points in kilometres. + * Standard haversine; accurate enough for the wizard's "show MB estimate" UX + * to single-digit-percent precision, which is all the user needs. + */ +internal fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6371.0088 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = kotlin.math.sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + kotlin.math.sin(dLon / 2).pow(2) + val c = 2 * kotlin.math.asin(kotlin.math.sqrt(a)) + return r * c +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/RegionId.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/RegionId.kt new file mode 100644 index 0000000..663a264 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/RegionId.kt @@ -0,0 +1,54 @@ +package org.appdevforall.maps.domain + +import java.text.Normalizer + +/** + * Pure region-id rules: slugify a user-typed name into a cache-safe id, and + * validate one. Lives in `domain/` (no Android) so it's unit-testable on the JVM, + * and is the single home for both halves — the data layer ([org.appdevforall.maps + * .data.RegionCache]) and the wizard ([org.appdevforall.maps.ui.Step3SaveFragment]) + * go through here, so "what we generate" and "what we accept" can't drift apart. + */ +object RegionId { + + /** + * Maximum id length, in characters. 64 chars stays well under the 255-byte + * filename limit even for multi-byte scripts. + */ + const val MAX_LEN = 64 + + /** + * Unicode-aware allowlist. First char must be a letter or digit of any script; + * the rest may be letters, digits, combining marks (needed by scripts like + * Arabic/Devanagari) or '-'. Everything that could escape a directory or + * confuse the filesystem — '/', '\\', '.', whitespace, punctuation, and + * control/format chars (incl. bidi overrides and zero-width joiners) — falls + * outside the letter/number/mark categories and is excluded by construction. + */ + private val PATTERN = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}\\p{M}-]{0,${MAX_LEN - 1}}$") + + /** + * True iff [id] is a lowercase, path-safe, length-bounded region id. Lowercase + * is required so case-variants can't spawn distinct-yet-colliding directories + * on case-insensitive storage. + */ + fun isValid(id: String): Boolean = id == id.lowercase() && PATTERN.matches(id) + + /** + * Slugify a user-typed name into a cache-safe id. **Unicode-aware:** any + * script's letters/digits/marks are kept ("北京", "القاهرة", "Córdoba" all + * produce usable ids); separators, whitespace, punctuation and control/format + * chars collapse to '-'. NFC-normalised so visually-identical names map to the + * same id, lowercased, and length-capped. Returns "" when the name has no + * usable characters (the caller treats blank as "can't save"). + * + * Invariant: the result always satisfies [isValid], or is "". + */ + fun slugify(name: String): String = + Normalizer.normalize(name, Normalizer.Form.NFC) + .lowercase() + .replace(Regex("[^\\p{L}\\p{N}\\p{M}]+"), "-") + .trim('-') + .take(MAX_LEN) + .trim('-') +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/SourceKind.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/SourceKind.kt new file mode 100644 index 0000000..fd964f5 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/SourceKind.kt @@ -0,0 +1,12 @@ +package org.appdevforall.maps.domain + +/** + * Source kind written into `meta.json.source.kind`. A pure value type so the + * source picker fragment + bbox picker can pass it through their listener + * interfaces without leaking the internal data-layer downloader. + */ +enum class SourceKind(val wireValue: String) { + IIAB_LAN("iiab-lan"), + INTERNET("internet"), + UNKNOWN("unknown"), +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/TileEstimate.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/TileEstimate.kt new file mode 100644 index 0000000..bdb6046 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/TileEstimate.kt @@ -0,0 +1,27 @@ +package org.appdevforall.maps.domain + +/** + * Region size info derived from + * [org.appdevforall.maps.slicer.PmtilesRegionSlicer]'s directory walk. Summing + * real per-tile `byteLength` (rather than a uniform bytes-per-tile constant) + * matters because tile sizes vary by orders of magnitude between dense-city + * (~50 KB) and sparse-ocean (~0.5 KB) tiles. + * + * [sizeBytesEstimate] sums `TileEntry.byteLength` for every tile intersecting the + * bbox at the selected zoom range; [tileCount] is the matching-entry count. + * + * A pure value type (not the slicer's raw `List`) so wizard fragments + * can pass it through their Listener interfaces without coupling to slicer types. + */ +data class TileEstimate( + val tileCount: Long, + val sizeBytesEstimate: Long, + val zoomMin: Int, + val zoomMax: Int +) { + fun sizeMb(): Double = sizeBytesEstimate / (1024.0 * 1024.0) + + fun displayString(): String = "$tileCount tiles · %.1f MB · zoom %d–%d".format( + sizeMb(), zoomMin, zoomMax + ) +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomCap.kt b/maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomCap.kt new file mode 100644 index 0000000..5403504 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomCap.kt @@ -0,0 +1,60 @@ +package org.appdevforall.maps.domain + +import kotlin.math.cos +import kotlin.math.ln +import kotlin.math.tan + +/** + * Auto-cap heuristic for the bbox-picker's `zoomMax`. Picks the highest + * zoom whose cumulative cell count across `zMin..zMax` stays under a + * configurable budget. + * + * Pure Kotlin / Web-Mercator math; no Android dependencies. + */ +internal object ZoomCap { + + const val DEFAULT_CELL_BUDGET = 100_000 + const val MIN_ZOOM = 6 + const val MAX_ZOOM = 14 + + /** + * Highest `zMax` in `[zMin, MAX_ZOOM]` where the total cell count + * summed across `z=zMin..zMax` ≤ [cellBudget]. If even `zMin` exceeds + * the budget, returns `zMin` (caller's downstream 1 GB cap is the next + * line of defense). + */ + fun pickZoomMax( + bbox: Bbox, + zMin: Int = MIN_ZOOM, + cellBudget: Int = DEFAULT_CELL_BUDGET, + ): Int { + for (zMax in MAX_ZOOM downTo zMin) { + val totalCells = (zMin..zMax).sumOf { z -> cellsInBboxAtZoom(bbox, z) } + if (totalCells <= cellBudget) return zMax + } + return zMin + } + + /** + * Number of slippy-map tiles whose `(z, x, y)` rectangle intersects + * [bbox] at zoom level [z]. Standard Web Mercator tile-x / tile-y + * math, latitude clamped at ±85.0511° (the Mercator pole limit). + * + * Returns `Long` because the count grows as `4^z` and overflows `Int` + * past z≈15 for world-scale bboxes. + */ + fun cellsInBboxAtZoom(bbox: Bbox, z: Int): Long { + val n = 1 shl z + val xMin = ((bbox.west + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val xMax = ((bbox.east + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val latNorthRad = Math.toRadians(bbox.north.coerceIn(-85.0511, 85.0511)) + val latSouthRad = Math.toRadians(bbox.south.coerceIn(-85.0511, 85.0511)) + val yMin = ((1 - ln(tan(latNorthRad) + 1.0 / cos(latNorthRad)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + val yMax = ((1 - ln(tan(latSouthRad) + 1.0 / cos(latSouthRad)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + val xCount = (xMax - xMin + 1).toLong().coerceAtLeast(0) + val yCount = (yMax - yMin + 1).toLong().coerceAtLeast(0) + return xCount * yCount + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/util/AtomicFiles.kt b/maps/src/main/kotlin/org/appdevforall/maps/util/AtomicFiles.kt new file mode 100644 index 0000000..cee5ac0 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/util/AtomicFiles.kt @@ -0,0 +1,99 @@ +package org.appdevforall.maps.util + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Atomic file write/copy helpers shared by the data layer (RegionDownloader, + * RegionInstaller, ActiveRegionStore) and the JVM-pure templates emitter + * (ProjectMapEmitter). + * + * Each operation writes to a sibling `.tmp` file first, then renames it onto the + * destination — so a process kill mid-write leaves either the old file intact or + * a stray `.tmp`, never a torn destination. `ATOMIC_MOVE` is preferred; on + * filesystems that don't support it (some FAT/exFAT SD cards) the code falls back + * to a plain `REPLACE_EXISTING` move. + * + * JVM-pure on purpose — only `java.io` / `java.nio` / `kotlin.io`, no Android + * imports — so [ProjectMapEmitter] (which has no Android dependencies and is + * unit-tested without Robolectric) can depend on it. + */ +internal object AtomicFiles { + + /** + * Atomically write [text] to [dest]. Creates [dest]'s parent directory if it + * doesn't already exist. + * + * @throws IllegalStateException if [dest] has no parent directory. + */ + fun writeText(dest: File, text: String) { + val parent = dest.parentFile + ?: error("atomicWriteText: destination has no parent dir: $dest") + if (!parent.exists()) parent.mkdirs() + val tmp = File(parent, dest.name + ".tmp") + if (tmp.exists()) tmp.delete() + tmp.writeText(text) + move(tmp, dest) + } + + /** + * Atomically copy [src] to [dest] via a temp file. Creates [dest]'s parent + * directory if it doesn't already exist. + * + * The copy runs as a chunked read/write loop that invokes [onChunk] between + * chunks, giving the caller a cooperative-cancellation seam: a coroutine + * caller passes `{ coroutineContext.ensureActive() }`, so closing the bottom + * sheet (or switching projects) mid-copy of a 100+ MB `tiles.pmtiles` + * actually aborts the copy instead of blocking until it finishes. [onChunk] + * defaults to a no-op so non-coroutine callers (and the JVM unit tests) are + * unaffected. If [onChunk] throws (e.g. `CancellationException`), the + * half-written temp file is deleted and the exception propagates — the old + * destination is left untouched because the atomic move never ran. + * + * @throws IllegalStateException if [dest] has no parent directory. + */ + fun copy(src: File, dest: File, onChunk: () -> Unit = {}) { + val parent = dest.parentFile + ?: error("atomicCopy: destination has no parent dir: $dest") + if (!parent.exists()) parent.mkdirs() + val tmp = File(parent, dest.name + ".tmp") + if (tmp.exists()) tmp.delete() + try { + src.inputStream().use { input -> + tmp.outputStream().use { output -> + val buffer = ByteArray(COPY_BUFFER_BYTES) + while (true) { + onChunk() + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + } + } + } + } catch (t: Throwable) { + // Cancelled or failed mid-copy: drop the partial temp so we never + // promote a torn file, then rethrow so the caller sees the failure. + runCatching { tmp.delete() } + throw t + } + move(tmp, dest) + } + + /** Read/write chunk size for [copy]; also the cancellation-check granularity. */ + private const val COPY_BUFFER_BYTES = 512 * 1024 + + /** Rename [tmp] onto [dest], preferring an atomic move with a plain-move fallback. */ + private fun move(tmp: File, dest: File) { + try { + Files.move( + tmp.toPath(), + dest.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(tmp.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/util/ByteSize.kt b/maps/src/main/kotlin/org/appdevforall/maps/util/ByteSize.kt new file mode 100644 index 0000000..3a63482 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/util/ByteSize.kt @@ -0,0 +1,20 @@ +package org.appdevforall.maps.util + +/** + * Human-readable byte-size formatting shared by `RegionAdapter` + * (per-row size) and `RegionManagerFragment` (cache + free footer). + * + * Picks the smallest unit that doesn't round to "0.0": + * - `< 1 KB` → bytes + * - `< 1 MB` → KB with one decimal + * - `< 1 GB` → MB with one decimal + * - `>= 1 GB` → GB with two decimals + * + * Internal to the plugin — only the two callers above consume it. + */ +internal fun formatByteSize(bytes: Long): String = when { + bytes < 1024L -> "$bytes B" + bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024L * 1024L * 1024L -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt new file mode 100644 index 0000000..6b7631d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt @@ -0,0 +1,84 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Branch-coverage supplement for [AutoShrinkBbox] — covers the decision branches + * the happy-path test (`AutoShrinkBboxTest`) doesn't reach: the negative-margin + * lower clamp, the per-axis "edge outside viewport" triggers, and the defensive + * pole-collapse guard that returns null when latitude clamping degenerates the box. + */ +class AutoShrinkBboxCoverageTest { + + private fun box(s: Double, w: Double, n: Double, e: Double) = + Bbox(south = s, west = w, north = n, east = e) + + @Test + fun `negative margin is clamped up to zero — result equals the full viewport`() { + // margin < 0 clamps to 0.0, so no inset: the shrunk bbox is the viewport itself. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = -0.5, + ) + assertNotNull(result) + assertEquals(-1.0, result!!.south, 1e-9) + assertEquals(1.0, result.north, 1e-9) + assertEquals(-1.0, result.west, 1e-9) + assertEquals(1.0, result.east, 1e-9) + } + + @Test + fun `bbox north edge above viewport triggers shrink`() { + // Only the north edge overflows; the other three are inside. + val bbox = box(s = -0.5, w = -0.5, n = 2.0, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("north-edge overflow must shrink", result) + } + + @Test + fun `bbox south edge below viewport triggers shrink`() { + val bbox = box(s = -2.0, w = -0.5, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("south-edge overflow must shrink", result) + } + + @Test + fun `bbox west edge left of viewport triggers shrink`() { + val bbox = box(s = -0.5, w = -2.0, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNotNull("west-edge overflow must shrink", result) + } + + @Test + fun `pole-clamp collapse returns null`() { + // A viewport spanning across the +85 Mercator pole limit with a margin so + // small the inset can't separate the latitude edges after the ±85 clamp: + // newS and newN both clamp to 85.0, so newN <= newS → defensive null. + val bbox = box(s = 80.0, w = -1.0, n = 89.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + // Both viewN and viewS above the +85 clamp ceiling. After the tiny + // inset, newS and newN both coerce to 85.0 → collapse. + viewN = 89.0, viewS = 86.0, viewE = 1.0, viewW = -1.0, + margin = 0.0, + ) + assertNull("latitude clamp collapses the box → defensive null", result) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt new file mode 100644 index 0000000..32ad816 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt @@ -0,0 +1,165 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the auto-shrink math behind the bbox picker's + * "selection tracks viewport" tweak (Bryan, 2026-05-26). + * + * Naming convention: `viewport(N, S, E, W)` reads as the visible map area in + * lat/lon degrees. `bbox(N, S, E, W)` reads as the user's current selection. + * + * Margin is 0.35 throughout (the picker's default — leaves the auto-shrunk + * bbox at ~30% of the viewport so the user has grab-room to pan). + */ +class AutoShrinkBboxTest { + + private val M = 0.35 + + private fun box(s: Double, w: Double, n: Double, e: Double) = Bbox( + south = s, west = w, north = n, east = e, + ) + + // ----- the two cases Bryan explicitly said should NOT trigger ----- + + @Test + fun `zoom-out keeps bbox fully visible — no shrink`() { + // User started with a 1° box centred at (0, 0). They zoomed out and + // the viewport is now 10° on a side. Bbox is comfortably inside. + val bbox = box(s = -0.5, w = -0.5, n = 0.5, e = 0.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, + margin = M, + ) + assertNull("bbox fully inside viewport must not trigger shrink", result) + } + + @Test + fun `pan but bbox still fully visible — no shrink`() { + // User panned the camera a bit. Viewport moved with it, bbox is + // still entirely inside. Should be a no-op. + val bbox = box(s = 0.0, w = 0.0, n = 1.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 3.0, viewS = -1.0, viewE = 3.0, viewW = -1.0, + margin = M, + ) + assertNull("bbox still on-screen must not trigger shrink", result) + } + + // ----- the cases that SHOULD trigger ----- + + @Test + fun `zoom-in past bbox extent — shrinks bbox to viewport minus margin`() { + // Bbox is 10° on a side; user zoomed in so viewport is only 2° on a + // side. Bbox extends way past the viewport on all axes. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNotNull("zoom-in past bbox should shrink", result) + // 35% inset on a 2° viewport = 0.7° inset on each side. + assertEquals(-0.3, result!!.south, 1e-9) + assertEquals(0.3, result.north, 1e-9) + assertEquals(-0.3, result.west, 1e-9) + assertEquals(0.3, result.east, 1e-9) + } + + @Test + fun `pan so one edge crosses viewport — shrinks`() { + // Viewport is 2° wide centred at lon=0. Bbox extends from lon=-0.5 + // to lon=2.5 — its east edge (2.5) is outside viewport east (1.0). + val bbox = box(s = -0.5, w = -0.5, n = 0.5, e = 2.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNotNull("partial offscreen should shrink", result) + // New bbox = viewport inset by 35% on each side. + assertEquals(-0.3, result!!.south, 1e-9) + assertEquals(0.3, result.north, 1e-9) + assertEquals(-0.3, result.west, 1e-9) + assertEquals(0.3, result.east, 1e-9) + } + + @Test + fun `bbox exactly at viewport edge — counts as inside, no shrink`() { + // Boundary-inclusive: bbox edges touch viewport edges exactly. Should + // NOT trigger (no overflow). Matches Bbox.contains semantics. + val bbox = box(s = -1.0, w = -1.0, n = 1.0, e = 1.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + assertNull("bbox flush with viewport edges must not shrink", result) + } + + // ----- margin semantics ----- + + @Test + fun `15 percent margin leaves 70 percent of viewport`() { + // 0° to 100° wide viewport → 15° inset on each side → result is + // 15° to 85° = 70° wide = 70% of viewport. + val bbox = box(s = -50.0, w = -50.0, n = 50.0, e = 50.0) // way bigger + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 100.0, viewS = 0.0, viewE = 100.0, viewW = 0.0, + margin = 0.15, + )!! + assertEquals(15.0, result.south, 1e-9) + assertEquals(85.0, result.north, 1e-9) + assertEquals(15.0, result.west, 1e-9) + assertEquals(85.0, result.east, 1e-9) + // Verify percentages. + val resultLatSpan = result.north - result.south + val resultLonSpan = result.east - result.west + assertEquals(70.0, resultLatSpan, 1e-9) + assertEquals(70.0, resultLonSpan, 1e-9) + } + + @Test + fun `margin is clamped at 0_45 to prevent degenerate result`() { + // Margin > 0.45 (which would inset 90%+ → degenerate) gets clamped. + // 0.45 on a 2°-wide viewport = 0.9° inset on each side + // → result span = 2° - 2 × 0.9° = 0.2°. + val bbox = box(s = -5.0, w = -5.0, n = 5.0, e = 5.0) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = 1.0, viewW = -1.0, + margin = 0.9, // would be 90% inset → degenerate without clamping + )!! + assertEquals(0.2, result.north - result.south, 1e-9) + assertEquals(0.2, result.east - result.west, 1e-9) + } + + @Test + fun `degenerate viewport returns null`() { + // E < W or N < S (could happen on uninitialized projection). + val bbox = box(s = -1.0, w = -1.0, n = 1.0, e = 1.0) + // Degenerate N<=S. + assertNull( + AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 0.0, viewS = 0.0, viewE = 1.0, viewW = -1.0, + margin = M, + ) + ) + // Degenerate E<=W (anti-meridian crossing or stale viewport). + assertNull( + AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 1.0, viewS = -1.0, viewE = -1.0, viewW = 1.0, + margin = M, + ) + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt new file mode 100644 index 0000000..cec7206 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt @@ -0,0 +1,202 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [Bbox]. Covers: + * - construction guards (south <= north, west <= east, anti-meridian rejection). + * - [Bbox.contains] — boundary-inclusive containment, edge cases at poles + meridians. + * - [Bbox.widthKm] / [Bbox.heightKm] / [Bbox.areaKm2] math sanity. + * - [Bbox.aroundPoint] — equator vs. high-latitude longitude scaling, latitude clamp. + */ +class BboxTest { + + // ----- construction guards ----- + + @Test(expected = IllegalArgumentException::class) + fun rejectsSouthGreaterThanNorth() { + Bbox(south = 10.0, west = 0.0, north = 5.0, east = 1.0) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsWestGreaterThanEast() { + // Anti-meridian crossing: west = 170, east = -170 — unsupported per init. + Bbox(south = 0.0, west = 170.0, north = 1.0, east = -170.0) + } + + @Test + fun acceptsPointBox() { + // Degenerate: south == north, west == east. Lat / lon clamps in + // aroundPoint can produce these at extreme inputs and we want them + // to round-trip rather than throw. + val pt = Bbox(south = 0.0, west = 0.0, north = 0.0, east = 0.0) + assertEquals(0.0, pt.widthKm(), 0.0001) + assertEquals(0.0, pt.heightKm(), 0.0001) + } + + // ----- contains() ----- + + @Test + fun containsInteriorPoint() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + assertTrue(box.contains(20.0, 30.0)) + assertTrue(box.contains(15.5, 25.5)) + } + + @Test + fun containsBoundaryInclusive() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + // All four corners. + assertTrue(box.contains(10.0, 20.0)) + assertTrue(box.contains(30.0, 20.0)) + assertTrue(box.contains(10.0, 40.0)) + assertTrue(box.contains(30.0, 40.0)) + // Edge midpoints. + assertTrue(box.contains(10.0, 30.0)) + assertTrue(box.contains(30.0, 30.0)) + assertTrue(box.contains(20.0, 20.0)) + assertTrue(box.contains(20.0, 40.0)) + } + + @Test + fun excludesPointsOutsideBox() { + val box = Bbox(south = 10.0, west = 20.0, north = 30.0, east = 40.0) + assertFalse(box.contains(9.999, 30.0)) + assertFalse(box.contains(30.001, 30.0)) + assertFalse(box.contains(20.0, 19.999)) + assertFalse(box.contains(20.0, 40.001)) + assertFalse(box.contains(0.0, 0.0)) + } + + @Test + fun containsEdgeCasesAtPoles() { + // Box extending to the latitude clamp (±85). aroundPoint clamps at + // ±85 explicitly; init's bounds are -90..90 nominally. + val polar = Bbox(south = 80.0, west = -10.0, north = 85.0, east = 10.0) + assertTrue(polar.contains(85.0, 0.0)) + assertTrue(polar.contains(80.0, 0.0)) + assertFalse(polar.contains(85.001, 0.0)) + } + + @Test + fun containsEdgeCasesAtMeridian() { + // Box adjacent to the antimeridian — but strictly east of it, since + // crossing the antimeridian is rejected at construction. + val nearEdge = Bbox(south = 0.0, west = 175.0, north = 5.0, east = 180.0) + assertTrue(nearEdge.contains(2.0, 180.0)) + assertTrue(nearEdge.contains(2.0, 175.0)) + assertFalse(nearEdge.contains(2.0, -180.0)) // -180 != 180 in this model. + } + + // ----- width / height / area math ----- + + @Test + fun widthAndHeightForOneDegreeBoxAtEquator() { + // 1° at the equator ≈ 111.32 km in both axes. Tolerance ~1 km + // because haversine uses 6371.0088 km radius. + val box = Bbox(south = 0.0, west = 0.0, north = 1.0, east = 1.0) + assertEquals(111.0, box.widthKm(), 1.0) + assertEquals(111.0, box.heightKm(), 1.0) + // Area ≈ 12,300 km² for a degree square at the equator. + assertEquals(12_321.0, box.areaKm2(), 200.0) + } + + @Test + fun widthShrinksWithLatitudeFromCosine() { + // Same 1° lon span but farther from equator should be narrower in km. + val equator = Bbox(south = 0.0, west = 0.0, north = 1.0, east = 1.0) + val midLat = Bbox(south = 45.0, west = 0.0, north = 46.0, east = 1.0) + assertTrue(midLat.widthKm() < equator.widthKm()) + // cos(45°) ≈ 0.707; 1° at 45° ≈ 78 km. + assertEquals(78.0, midLat.widthKm(), 2.0) + // Height stays ~111 km regardless of latitude. + assertEquals(equator.heightKm(), midLat.heightKm(), 0.5) + } + + // ----- aroundPoint() ----- + + @Test + fun aroundPointBuildsApproximateSquareAtEquator() { + // 10×10 km box around the equator: should be ~0.09° on each side. + val box = Bbox.aroundPoint(0.0, 0.0, 10.0) + assertEquals(10.0, box.widthKm(), 0.5) + assertEquals(10.0, box.heightKm(), 0.5) + } + + @Test + fun aroundPointWidensLongitudeAtHighLatitudes() { + // 10×10 km at 60° latitude. Latitude span ≈ 0.09°; longitude span + // ≈ 0.09° / cos(60°) = 0.18°. + val box = Bbox.aroundPoint(60.0, 0.0, 10.0) + val latSpan = box.north - box.south + val lonSpan = box.east - box.west + // Longitude span should be roughly 2x latitude span at lat=60. + assertTrue( + "Expected lonSpan ≈ 2 × latSpan at lat=60°, got latSpan=$latSpan lonSpan=$lonSpan", + lonSpan > latSpan * 1.8 && lonSpan < latSpan * 2.2 + ) + // Physical width should still be ~10 km. + assertEquals(10.0, box.widthKm(), 0.5) + assertEquals(10.0, box.heightKm(), 0.5) + } + + @Test + fun aroundPointClampsLatitudeAtPoles() { + // Centred near the pole, 1000 km box. Latitude must clamp at ±85 + // per the production code (Web-Mercator-friendly). + val box = Bbox.aroundPoint(89.0, 0.0, 1000.0) + assertTrue(box.south >= -85.0) + assertTrue(box.north <= 85.0) + } + + @Test + fun aroundPointPreventsCosLatBlowup() { + // At 90° latitude cos = 0; the production code floors at 0.01 to + // keep the longitude division finite. The result is constructable + // (no infinity, no NaN) which is the contract under test here. + val box = Bbox.aroundPoint(90.0, 0.0, 10.0) + assertNotNull(box) + assertTrue(box.east.isFinite()) + assertTrue(box.west.isFinite()) + assertTrue(box.north.isFinite()) + assertTrue(box.south.isFinite()) + } + + @Test + fun toBoundsArrayRoundTripsConstructor() { + val box = Bbox(south = -10.0, west = -20.0, north = 30.0, east = 40.0) + val arr = box.toBoundsArray() + assertEquals(4, arr.size) + assertEquals(-10.0, arr[0], 0.0) + assertEquals(-20.0, arr[1], 0.0) + assertEquals(30.0, arr[2], 0.0) + assertEquals(40.0, arr[3], 0.0) + + // Reconstruct. + val reconstructed = Bbox(arr[0], arr[1], arr[2], arr[3]) + assertEquals(box.south, reconstructed.south, 0.0) + assertEquals(box.east, reconstructed.east, 0.0) + } + + // ----- haversine sanity ----- + + @Test + fun haversineKmKnownDistance() { + // SF (37.7749, -122.4194) to NYC (40.7128, -74.0060) ≈ 4129 km. + val km = haversineKm(37.7749, -122.4194, 40.7128, -74.0060) + assertEquals(4129.0, km, 5.0) + } + + @Test + fun haversineKmZeroForSamePoint() { + assertEquals(0.0, haversineKm(45.0, 90.0, 45.0, 90.0), 0.0001) + } + + // isReasonableRegionSize tests removed 2026-05-26 — zoom auto-cap by cell + // budget replaces the bbox-size gate; whole-world bboxes are now + // downloadable at low zoom levels. +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt new file mode 100644 index 0000000..bc7b3cb --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt @@ -0,0 +1,84 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RegionId.slugify] — the function that turns a user-typed region + * name into a cache-safe id. This is the half the old tests didn't cover (only the + * validator was tested). The NFC test builds its strings from code points so the + * composed-vs-decomposed distinction is unambiguous in source; the other accented + * cases use ordinary (precomposed) UTF-8 letters, and the script literals + * (CJK / Arabic / Cyrillic) are safe to embed verbatim. + */ +class RegionIdSlugifyTest { + + @Test + fun keeps_non_latin_letters() { + assertEquals("北京", RegionId.slugify("北京")) + assertEquals("القاهرة", RegionId.slugify("القاهرة")) + assertEquals("москва", RegionId.slugify("Москва")) // Cyrillic lowercased + } + + @Test + fun lowercases_accented_latin() { + assertEquals("córdoba", RegionId.slugify("Córdoba")) + } + + @Test + fun nfc_normalises_so_decomposed_and_composed_match() { + // Built from code points so composed vs decomposed is unambiguous in source. + val composed = "Caf" + Char(0x00E9) // precomposed é + val decomposed = "Cafe" + Char(0x0301) // e + combining acute + val expected = "caf" + Char(0x00E9) // lowercase precomposed é + assertEquals(expected, RegionId.slugify(composed)) + assertEquals(expected, RegionId.slugify(decomposed)) + assertEquals(RegionId.slugify(composed), RegionId.slugify(decomposed)) + } + + @Test + fun collapses_separators_punctuation_and_whitespace_to_hyphen() { + assertEquals("san-francisco", RegionId.slugify("San Francisco!")) + assertEquals("a-b", RegionId.slugify("a / b")) + assertEquals("foo-bar", RegionId.slugify("foo___bar")) + assertEquals("são-paulo", RegionId.slugify("São Paulo")) + } + + @Test + fun trims_leading_and_trailing_hyphens() { + assertEquals("hi", RegionId.slugify(" hi ")) + assertEquals("x", RegionId.slugify("--x--")) + } + + @Test + fun blank_when_no_usable_characters() { + assertEquals("", RegionId.slugify("!!!")) + assertEquals("", RegionId.slugify(" ")) + assertEquals("", RegionId.slugify("///")) + assertEquals("", RegionId.slugify("")) + } + + @Test + fun caps_length() { + val s = RegionId.slugify("a".repeat(100)) + assertEquals(RegionId.MAX_LEN, s.length) + assertTrue(RegionId.isValid(s)) + } + + @Test + fun output_is_always_blank_or_valid() { + val names = listOf( + "San Francisco", "北京 (Beijing)", "القاهرة!", "Café", + "São Paulo", " -- ", "123", "Zürich", "tokyo/東京", + "a".repeat(200), "", "!@#\$%", + ) + for (n in names) { + val s = RegionId.slugify(n) + assertTrue( + "slugify('$n') = '$s' must be blank or pass isValid", + s.isEmpty() || RegionId.isValid(s), + ) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt new file mode 100644 index 0000000..8d0e810 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt @@ -0,0 +1,65 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Coverage tests for [TileEstimate] — a pure value type. Exercises the data-class + * fields, the [TileEstimate.sizeMb] byte→MB conversion, and the + * [TileEstimate.displayString] formatting (tile count · MB · zoom range). + */ +class TileEstimateCoverageTest { + + @Test + fun `fields are carried verbatim`() { + val est = TileEstimate( + tileCount = 1234, + sizeBytesEstimate = 5_242_880, // 5 MB exactly + zoomMin = 6, + zoomMax = 14, + ) + assertEquals(1234L, est.tileCount) + assertEquals(5_242_880L, est.sizeBytesEstimate) + assertEquals(6, est.zoomMin) + assertEquals(14, est.zoomMax) + } + + @Test + fun `sizeMb converts bytes to mebibytes`() { + // 5 MiB exactly. + val est = TileEstimate(0, 5L * 1024 * 1024, 6, 14) + assertEquals(5.0, est.sizeMb(), 1e-9) + } + + @Test + fun `sizeMb of zero bytes is zero`() { + val est = TileEstimate(0, 0, 6, 14) + assertEquals(0.0, est.sizeMb(), 1e-9) + } + + @Test + fun `displayString renders count MB and zoom range`() { + // 2.5 MiB so the %.1f formatting shows a fractional value. + val bytes = (2.5 * 1024 * 1024).toLong() + val est = TileEstimate(42, bytes, 8, 13) + val s = est.displayString() + assertTrue("should include tile count: $s", s.contains("42 tiles")) + // Locale-robust: the %.1f decimal separator varies by default locale + // ("2.5" vs "2,5"); assert the digits + unit + the · separators instead. + assertTrue("should include the MB value and unit: $s", s.contains("2") && s.contains("5 MB")) + assertTrue("should include the middot separators: $s", s.contains("·")) + assertTrue("should include zoom range: $s", s.contains("zoom 8–13")) + } + + @Test + fun `data class equality and copy`() { + val a = TileEstimate(10, 100, 6, 12) + val b = TileEstimate(10, 100, 6, 12) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + val c = a.copy(tileCount = 11) + assertEquals(11L, c.tileCount) + assertEquals(a.sizeBytesEstimate, c.sizeBytesEstimate) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt new file mode 100644 index 0000000..55d857d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt @@ -0,0 +1,92 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Branch-coverage supplement for [ZoomCap] — drives the Web-Mercator tile-math + * clamps in [ZoomCap.cellsInBboxAtZoom] (x/y coerced into `[0, n-1]` at the map + * edges) and the [ZoomCap.pickZoomMax] decision branches at the budget boundary + * and at the zMin == MAX_ZOOM degenerate range. + */ +class ZoomCapCoverageTest { + + @Test + fun `world-edge bbox clamps tile x and y into range`() { + // east=180 / north=85.0511 push the raw tile index to exactly n, so the + // coerceIn(0, n-1) upper clamp fires; west=-180 / south=-85.0511 push the + // lower bound to 0. Result is the full tile grid at that zoom (n x n). + val world = Bbox(south = -85.0511, west = -180.0, north = 85.0511, east = 180.0) + val z = 4 + val n = 1L shl z + val cells = ZoomCap.cellsInBboxAtZoom(world, z) + // Full grid (n columns x n rows) after the edge clamps. + assertEquals(n * n, cells) + } + + @Test + fun `single-point-ish bbox yields exactly one cell at low zoom`() { + // A pinpoint bbox collapses x/y min == max → xCount=1, yCount=1. + // Use an INTERIOR point — (0,0) sits exactly on the z6 equator/prime- + // meridian tile boundary, so a bbox there legitimately straddles 2 y-tiles. + val tiny = Bbox(south = 23.0, west = 8.0, north = 23.0001, east = 8.0001) + assertEquals(1L, ZoomCap.cellsInBboxAtZoom(tiny, 6)) + } + + @Test + fun `pickZoomMax returns MAX_ZOOM when zMin equals MAX_ZOOM and under budget`() { + // zMin == MAX_ZOOM makes the for-loop body run exactly once. + val tiny = Bbox(south = 0.0, west = 0.0, north = 0.001, east = 0.001) + val capped = ZoomCap.pickZoomMax(tiny, zMin = ZoomCap.MAX_ZOOM) + assertEquals(ZoomCap.MAX_ZOOM, capped) + } + + @Test + fun `pickZoomMax picks an intermediate cap at a mid budget`() { + // A budget that's exceeded at z=14 but satisfied lower forces the loop to + // iterate down past the first candidate before returning. + val bbox = Bbox(south = -1.0, west = -1.0, north = 1.0, east = 1.0) + val capped = ZoomCap.pickZoomMax(bbox, cellBudget = 2_000) + assertTrue("expected a mid cap in [6,14]; got $capped", capped in 6..14) + // The chosen cap must actually satisfy the budget. + val cells = (6..capped).sumOf { ZoomCap.cellsInBboxAtZoom(bbox, it) } + assertTrue("chosen cap $capped must be under budget; cells=$cells", cells <= 2_000) + } + + @Test + fun `pickZoomMax with zMin above MAX_ZOOM skips the loop and returns zMin`() { + // zMin > MAX_ZOOM makes `MAX_ZOOM downTo zMin` an empty range, so the + // for-loop body never runs and the method falls straight through to the + // `return zMin` tail (the loop-exhaust branch). This is the branch the + // budget-boundary cases can't reach, because they always enter the loop. + val bbox = Bbox(south = 0.0, west = 0.0, north = 0.001, east = 0.001) + val capped = ZoomCap.pickZoomMax(bbox, zMin = ZoomCap.MAX_ZOOM + 1) + assertEquals(ZoomCap.MAX_ZOOM + 1, capped) + } + + @Test + fun `pickZoomMax accepts a cap that exactly equals the budget`() { + // Drive the `totalCells <= cellBudget` comparison at exact equality, so + // the boundary `==` arm (not just strictly-less) returns rather than + // continuing to iterate down. Compute the real cell total at z=zMin and + // pass it verbatim as the budget. + val bbox = Bbox(south = -1.0, west = -1.0, north = 1.0, east = 1.0) + val zMin = 6 + val exactBudget = ZoomCap.cellsInBboxAtZoom(bbox, zMin).toInt() + // At z=zMin the running total is exactly the budget → accept zMin (or + // higher if a larger zMax still fits, but never below zMin). + val capped = ZoomCap.pickZoomMax(bbox, zMin = zMin, cellBudget = exactBudget) + assertTrue("cap must be at least zMin when zMin exactly fits; got $capped", capped >= zMin) + val total = (zMin..capped).sumOf { ZoomCap.cellsInBboxAtZoom(bbox, it) } + assertTrue("chosen total $total must satisfy the exact budget $exactBudget", + total <= exactBudget) + } + + @Test + fun `constants expose the documented defaults`() { + assertEquals(100_000, ZoomCap.DEFAULT_CELL_BUDGET) + assertEquals(6, ZoomCap.MIN_ZOOM) + assertEquals(14, ZoomCap.MAX_ZOOM) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt new file mode 100644 index 0000000..0ac446a --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt @@ -0,0 +1,77 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-math tests for the bbox-picker's auto-cap heuristic. Each case + * spot-checks one expected zoom-cap for a bbox at a known scale, so + * regressions in either [ZoomCap.pickZoomMax] or [ZoomCap.cellsInBboxAtZoom] + * surface immediately. + */ +class ZoomCapTest { + + /** 1° × 1° city-sized bbox at lat 0 → small enough to stay at z=14. */ + @Test + fun pickZoomMax_smallBbox_keepsStreetDetail() { + val cityBbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + assertEquals(14, ZoomCap.pickZoomMax(cityBbox)) + } + + /** 20° × 20° default no-GPS bbox → caps at "town" detail (z=12). */ + @Test + fun pickZoomMax_default20deg_capsAtTown() { + val defaultBbox = Bbox(south = 5.0, west = -5.0, north = 25.0, east = 15.0) + assertEquals(12, ZoomCap.pickZoomMax(defaultBbox)) + } + + /** Whole-world bbox → caps low so the cell count stays under budget. */ + @Test + fun pickZoomMax_wholeWorld_capsRegionOrLower() { + val worldBbox = Bbox(south = -85.0, west = -180.0, north = 85.0, east = 180.0) + val capped = ZoomCap.pickZoomMax(worldBbox) + assertTrue("whole-world should cap at z=8 or lower; got z=$capped", capped <= 8) + } + + /** Below-budget cap (1 cell budget) → returns zMin. */ + @Test + fun pickZoomMax_tinyBudget_clampsToZMin() { + val anyBbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + assertEquals(ZoomCap.MIN_ZOOM, ZoomCap.pickZoomMax(anyBbox, cellBudget = 1)) + } + + /** Cell count monotonically non-decreases with zoom for a fixed bbox. */ + @Test + fun cellsInBboxAtZoom_monotonicallyIncreasesWithZoom() { + val bbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + var prev = 0L + for (z in 6..14) { + val cells = ZoomCap.cellsInBboxAtZoom(bbox, z) + assertTrue("z=$z cells=$cells should be ≥ z=${z - 1} cells=$prev", cells >= prev) + prev = cells + } + } + + /** Cell count multiplied by ~4 per zoom level for a square bbox at the equator. + * Use higher zooms (z=13 → z=14) so fence-post integer truncation isn't + * dominant — at low zooms the ratio drifts well below 4× because cell + * counts are tiny (e.g. 16 → 36 at z10→z11 = only 2.25×). */ + @Test + fun cellsInBboxAtZoom_approxQuadruplesPerZoom() { + val bbox = Bbox(south = -0.5, west = -0.5, north = 0.5, east = 0.5) + val z13 = ZoomCap.cellsInBboxAtZoom(bbox, 13) + val z14 = ZoomCap.cellsInBboxAtZoom(bbox, 14) + val ratio = z14.toDouble() / z13.toDouble() + assertTrue("expected ~4× growth z13→z14, got z13=$z13 z14=$z14 ratio=$ratio", + ratio in 3.5..4.5) + } + + /** Latitude clamping at ±85.0511° — north=90 doesn't blow up `tan(π/2)`. */ + @Test + fun cellsInBboxAtZoom_polarBboxDoesNotBlowUp() { + val polarBbox = Bbox(south = 80.0, west = -10.0, north = 90.0, east = 10.0) + val cells = ZoomCap.cellsInBboxAtZoom(polarBbox, 10) + assertTrue("polar bbox should still produce finite cell count; got $cells", cells >= 0) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt new file mode 100644 index 0000000..d56aaa0 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt @@ -0,0 +1,63 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Branch-coverage supplement for [AtomicFiles] — covers the no-parent-dir error + * branches of both [AtomicFiles.writeText] and [AtomicFiles.copy], and the + * "a stale `.tmp` already exists, delete it first" branch in each. The + * happy-path / cancellation behaviour is covered by `AtomicFilesTest`. + */ +class AtomicFilesCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `writeText throws when destination has no parent dir`() { + // A bare relative filename has no parent component → parentFile == null. + val noParent = File("atomic-files-no-parent.txt") + assertThrows(IllegalStateException::class.java) { + AtomicFiles.writeText(noParent, "x") + } + } + + @Test + fun `copy throws when destination has no parent dir`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val noParent = File("atomic-files-copy-no-parent.bin") + assertThrows(IllegalStateException::class.java) { + AtomicFiles.copy(src, noParent) + } + } + + @Test + fun `writeText deletes a stale tmp file before writing`() { + val dest = File(tmp.root, "out.txt") + // Pre-seed the sibling .tmp that a prior crashed write would have left. + val stale = File(tmp.root, "out.txt.tmp").apply { writeText("STALE") } + assertTrue(stale.exists()) + AtomicFiles.writeText(dest, "fresh") + assertEquals("fresh", dest.readText()) + // The stale tmp was deleted, then re-created+renamed, so none lingers. + assertFalse(File(tmp.root, "out.txt.tmp").exists()) + } + + @Test + fun `copy deletes a stale tmp file before writing`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "dest.bin") + val stale = File(tmp.root, "dest.bin.tmp").apply { writeText("STALE") } + assertTrue(stale.exists()) + AtomicFiles.copy(src, dest) + assertEquals("payload", dest.readText()) + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt new file mode 100644 index 0000000..9220720 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt @@ -0,0 +1,116 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [AtomicFiles] — the shared write / copy / overwrite / mkdir + * behaviour relied on by RegionDownloader, RegionInstaller, and ProjectMapEmitter. + */ +class AtomicFilesTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `writeText creates the file with the given content`() { + val dest = File(tmp.root, "out.txt") + AtomicFiles.writeText(dest, "hello") + assertTrue(dest.exists()) + assertEquals("hello", dest.readText()) + } + + @Test + fun `writeText overwrites an existing file`() { + val dest = File(tmp.root, "out.txt") + dest.writeText("old") + AtomicFiles.writeText(dest, "new") + assertEquals("new", dest.readText()) + } + + @Test + fun `writeText creates missing parent directories`() { + val dest = File(tmp.root, "a/b/c/deep.txt") + assertFalse(dest.parentFile!!.exists()) + AtomicFiles.writeText(dest, "deep") + assertTrue(dest.exists()) + assertEquals("deep", dest.readText()) + } + + @Test + fun `writeText leaves no tmp file behind on success`() { + val dest = File(tmp.root, "out.txt") + AtomicFiles.writeText(dest, "x") + assertFalse(File(tmp.root, "out.txt.tmp").exists()) + } + + @Test + fun `copy duplicates source content to a new destination`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "nested/dest.bin") + AtomicFiles.copy(src, dest) + assertTrue(dest.exists()) + assertEquals("payload", dest.readText()) + // Source is untouched. + assertEquals("payload", src.readText()) + } + + @Test + fun `copy overwrites an existing destination`() { + val src = File(tmp.root, "src.bin").apply { writeText("fresh") } + val dest = File(tmp.root, "dest.bin").apply { writeText("stale") } + AtomicFiles.copy(src, dest) + assertEquals("fresh", dest.readText()) + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + } + + @Test + fun `copy invokes onChunk at least once per copy`() { + val src = File(tmp.root, "src.bin").apply { writeText("payload") } + val dest = File(tmp.root, "dest.bin") + var calls = 0 + AtomicFiles.copy(src, dest) { calls++ } + assertTrue("onChunk should be invoked during the copy", calls >= 1) + assertEquals("payload", dest.readText()) + } + + @Test + fun `copy invokes onChunk multiple times for a multi-chunk source`() { + // Source larger than the 512 KB copy buffer forces more than one chunk, + // so onChunk (the cancellation checkpoint) fires multiple times. + val src = File(tmp.root, "big.bin").apply { writeBytes(ByteArray(1_500_000) { 7 }) } + val dest = File(tmp.root, "big-dest.bin") + var calls = 0 + AtomicFiles.copy(src, dest) { calls++ } + assertTrue("expected multiple chunk checkpoints, got $calls", calls >= 3) + assertEquals(1_500_000L, dest.length()) + } + + @Test + fun `copy that is cancelled via onChunk leaves no temp and does not touch destination`() { + val src = File(tmp.root, "big.bin").apply { writeBytes(ByteArray(1_500_000) { 1 }) } + val dest = File(tmp.root, "dest.bin").apply { writeText("ORIGINAL") } + var calls = 0 + val boom = try { + // Throw on the second checkpoint to simulate mid-copy cancellation. + AtomicFiles.copy(src, dest) { + calls++ + if (calls == 2) throw IllegalStateException("cancelled") + } + null + } catch (e: IllegalStateException) { + e + } + assertTrue("the throwing onChunk should propagate", boom != null) + // The partial temp must be cleaned up... + assertFalse(File(tmp.root, "dest.bin.tmp").exists()) + // ...and the pre-existing destination must be left intact (atomic move + // never ran). + assertEquals("ORIGINAL", dest.readText()) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt new file mode 100644 index 0000000..6677f5e --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt @@ -0,0 +1,50 @@ +package org.appdevforall.maps.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [formatByteSize]. The Region Manager cache footer and + * each region-row size label flow through this helper; a mis-formatted + * "0.0 MB" for a small-but-nonzero file was a real bug, so the unit-cap + * behaviour is load-bearing. + */ +class ByteSizeTest { + + @Test + fun smallByteCountsUseBytes() { + assertEquals("0 B", formatByteSize(0)) + assertEquals("8 B", formatByteSize(8)) + assertEquals("1023 B", formatByteSize(1023)) + } + + @Test + fun kilobyteRange() { + assertEquals("1.0 KB", formatByteSize(1024)) + assertEquals("9.8 KB", formatByteSize(10_000)) + // Just under 1 MB. + assertEquals("1023.9 KB", formatByteSize(1024L * 1024L - 100)) + } + + @Test + fun megabyteRange() { + assertEquals("1.0 MB", formatByteSize(1024L * 1024L)) + assertEquals("12.4 MB", formatByteSize(13_000_000)) + // Just under 1 GB. + assertEquals("1023.9 MB", formatByteSize(1024L * 1024L * 1024L - 100_000)) + } + + @Test + fun gigabyteRange() { + assertEquals("1.00 GB", formatByteSize(1024L * 1024L * 1024L)) + assertEquals("12.40 GB", formatByteSize(13_314_572_288L)) + } + + @Test + fun b8RegressionStubsDoNotRoundToZeroMb() { + // The B8 symptom: a 6-byte stub being labelled "0.0 MB". This + // helper must surface the actual byte count instead. + val stubBytes = 6L + assertEquals("6 B", formatByteSize(stubBytes)) + } +} From 44ce879a11b5f5a6a10adc1246dddba52a8f8d6d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(maps):=20slicer=20=E2=80=94=20PMTile?= =?UTF-8?q?s=20v3=20range-slicing=20of=20a=20bbox=20region=20(+=20unit=20t?= =?UTF-8?q?ests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolls what go-pmtiles' `pmtiles extract` does (bbox → minimal v3 archive), over HTTP range requests against the IIAB-hosted global archive, because we can't ship/shell-out to a per-arch Go binary on-device. The code is dense (Hilbert-curve tile-id ranges, directory walking, run-length entries) but isolated behind a small surface and heavily unit-tested — trust the tests. If it proves hard to maintain, it's swappable for the `pmtiles` Go CLI. Co-Authored-By: Claude Opus 4.8 --- .../org/appdevforall/maps/slicer/Hilbert.kt | 107 +++ .../maps/slicer/PmtilesDirectory.kt | 125 ++++ .../appdevforall/maps/slicer/PmtilesHeader.kt | 153 +++++ .../maps/slicer/PmtilesRegionSlicer.kt | 643 ++++++++++++++++++ .../org/appdevforall/maps/slicer/PmtilesV3.kt | 56 ++ .../org/appdevforall/maps/slicer/README.md | 76 +++ .../appdevforall/maps/slicer/RangeFetcher.kt | 204 ++++++ .../maps/slicer/SliceEstimateCache.kt | 58 ++ .../org/appdevforall/maps/slicer/TileEntry.kt | 23 + .../org/appdevforall/maps/slicer/Varint.kt | 40 ++ .../slicer/DavidmotenHilbertCompatTest.kt | 51 ++ .../maps/slicer/HilbertCoverageTest.kt | 70 ++ .../appdevforall/maps/slicer/HilbertTest.kt | 97 +++ .../slicer/HttpRangeByteCacheCoverageTest.kt | 92 +++ .../maps/slicer/NgoRegionValidationTest.kt | 197 ++++++ .../maps/slicer/OnDemandRegionSlicingTest.kt | 91 +++ .../slicer/OnlineDownloadThroughputTest.kt | 130 ++++ .../slicer/OnlineNgoRegionValidationTest.kt | 105 +++ .../slicer/PmtilesDirectoryCoverageTest.kt | 149 ++++ .../maps/slicer/PmtilesHeaderCoverageTest.kt | 184 +++++ .../maps/slicer/PmtilesHeaderTest.kt | 47 ++ .../slicer/PmtilesRegionSlicerCoalesceTest.kt | 143 ++++ .../slicer/PmtilesRegionSlicerCoverageTest.kt | 642 +++++++++++++++++ .../maps/slicer/PmtilesRegionSlicerTest.kt | 152 +++++ .../maps/slicer/PmtilesV3CoverageTest.kt | 41 ++ .../maps/slicer/RangeFetcherCoverageTest.kt | 240 +++++++ .../slicer/SliceEstimateCacheCoverageTest.kt | 85 +++ .../maps/slicer/VarintCoverageTest.kt | 53 ++ .../appdevforall/maps/slicer/VarintTest.kt | 44 ++ 29 files changed, 4098 insertions(+) create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesDirectory.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesHeader.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicer.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesV3.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/README.md create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/RangeFetcher.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/SliceEstimateCache.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/TileEntry.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/slicer/Varint.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt new file mode 100644 index 0000000..f220a0c --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt @@ -0,0 +1,107 @@ +package org.appdevforall.maps.slicer + +/** + * Hilbert-curve (z, x, y) ↔ tile-ID conversion for PMTiles v3. + * + * Per the spec: every (z, x, y) tile is mapped to a single 64-bit tile_id. + * The id is `accumulate(z) + hilbert_xy_to_d(2^z, x, y)`, where + * `accumulate(z) = ((1 << 2z) - 1) / 3` is the count of all tile slots at + * zooms 0..z-1. + * + * Reference algorithm: https://en.wikipedia.org/wiki/Hilbert_curve + * (Algorithm "Convert (x,y) to d") — adapted from the PMTiles Python + * reference implementation. + */ +internal object Hilbert { + + /** Tile ID space accumulator. Tiles at zooms 0..z-1 occupy ids `0..accumulate(z)-1`. */ + fun accumulate(z: Int): Long { + require(z in 0..26) { "zoom $z out of range [0, 26]" } + // ((1 << 2z) - 1) / 3 in 64-bit arithmetic + return ((1L shl (2 * z)) - 1L) / 3L + } + + /** Returns the (z, x, y) → tile-id mapping. */ + fun zxyToTileId(z: Int, x: Int, y: Int): Long { + require(z in 0..26) { "zoom $z out of range" } + val n = 1 shl z + require(x in 0..(n - 1) && y in 0..(n - 1)) { + "tile ($x, $y) out of range at zoom $z (max ${n - 1})" + } + return accumulate(z) + hilbertXyToD(n, x, y) + } + + /** Reverse mapping: tile-id → (z, x, y). Used for sanity tests. */ + fun tileIdToZxy(tileId: Long): Triple { + require(tileId >= 0) { "tileId $tileId must be nonnegative" } + // Find the zoom level whose accumulator bracket contains tileId. + var acc = 0L + var z = 0 + while (true) { + val nextAcc = accumulate(z + 1) + if (tileId < nextAcc) { + acc = accumulate(z) + break + } + z++ + require(z <= 26) { "tileId $tileId implies zoom > 26" } + } + val n = 1 shl z + val (x, y) = hilbertDToXy(n, tileId - acc) + return Triple(z, x, y) + } + + /** + * Map an (x, y) coordinate on an n×n grid (n = power of 2) to its index + * `d` along a Hilbert curve. From Wikipedia's reference pseudocode. + */ + private fun hilbertXyToD(n: Int, xIn: Int, yIn: Int): Long { + var x = xIn + var y = yIn + var d = 0L + var s = n shr 1 + while (s > 0) { + val rx = if ((x and s) > 0) 1 else 0 + val ry = if ((y and s) > 0) 1 else 0 + d += s.toLong() * s.toLong() * ((3 * rx) xor ry).toLong() + // rotate + if (ry == 0) { + if (rx == 1) { + x = s - 1 - x + y = s - 1 - y + } + val t = x + x = y + y = t + } + s = s shr 1 + } + return d + } + + /** Reverse Hilbert: index d on an n×n grid → (x, y). */ + private fun hilbertDToXy(n: Int, dIn: Long): Pair { + var d = dIn + var x = 0 + var y = 0 + var s = 1 + while (s < n) { + val rx = (1L and (d shr 1)).toInt() + val ry = (1L and (d xor rx.toLong())).toInt() + if (ry == 0) { + if (rx == 1) { + x = s - 1 - x + y = s - 1 - y + } + val t = x + x = y + y = t + } + x += s * rx + y += s * ry + d = d shr 2 + s = s shl 1 + } + return x to y + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesDirectory.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesDirectory.kt new file mode 100644 index 0000000..45aeb0c --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesDirectory.kt @@ -0,0 +1,125 @@ +package org.appdevforall.maps.slicer + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Parses & serializes PMTiles v3 directory blobs. + * + * Wire format (after `internal_compression` decompression): + * ``` + * varint N (number of entries) + * N × varint tile_id (delta-encoded from previous; first = absolute) + * N × varint run_length + * N × varint length + * N × varint offset (0 = appended directly after previous entry's + * (offset+length); nonzero = absolute - 1) + * ``` + * + * Each `Entry` represents a contiguous run of `runLength` tiles sharing the + * same bytes (`offset`, `length`), starting at `tileId`. For the slicer we + * mostly work with `runLength=1` entries. + */ +internal data class DirectoryEntry( + val tileId: Long, + val runLength: Long, + val length: Long, + /** Offset relative to `header.tileDataOffset`. */ + val offset: Long, +) { + /** True iff this entry is a leaf-directory pointer (per v3 spec: runLength == 0). */ + fun isLeafPointer(): Boolean = runLength == 0L +} + +internal object PmtilesDirectory { + + /** + * Parse a directory blob (already decompressed) from [buf] starting at + * current position, consuming exactly the entire directory. + */ + fun parse(buf: ByteBuffer): List { + val n = Varint.decode(buf).toInt() + if (n == 0) return emptyList() + val ids = LongArray(n) + val runs = LongArray(n) + val lens = LongArray(n) + val offs = LongArray(n) + + var lastId = 0L + for (i in 0 until n) { + val delta = Varint.decode(buf) + lastId += delta + ids[i] = lastId + } + for (i in 0 until n) runs[i] = Varint.decode(buf) + for (i in 0 until n) lens[i] = Varint.decode(buf) + for (i in 0 until n) { + val rawOff = Varint.decode(buf) + offs[i] = when { + rawOff == 0L && i > 0 -> offs[i - 1] + lens[i - 1] + rawOff == 0L -> 0L + else -> rawOff - 1L + } + } + + return List(n) { i -> DirectoryEntry(ids[i], runs[i], lens[i], offs[i]) } + } + + /** + * Serialize a list of entries to the raw v3 directory wire bytes + * (uncompressed). Caller is responsible for gzip-wrapping if needed. + * + * Entries MUST be sorted ascending by tileId; the spec requires it. + */ + fun serialize(entries: List): ByteArray { + val out = ByteArrayOutputStream() + Varint.encode(entries.size.toLong(), out) + var lastId = 0L + for (e in entries) { + val delta = e.tileId - lastId + require(delta >= 0) { "directory entries must be sorted by tileId" } + Varint.encode(delta, out) + lastId = e.tileId + } + for (e in entries) Varint.encode(e.runLength, out) + for (e in entries) Varint.encode(e.length, out) + for (i in entries.indices) { + val e = entries[i] + val expected = if (i > 0) entries[i - 1].offset + entries[i - 1].length else -1L + val raw = if (i > 0 && e.offset == expected) 0L else e.offset + 1L + Varint.encode(raw, out) + } + return out.toByteArray() + } + + /** Apply gzip if compression == GZIP, else return raw bytes. */ + fun maybeCompress(raw: ByteArray, compression: Byte): ByteArray = when (compression) { + PmtilesV3.COMPRESSION_NONE -> raw + PmtilesV3.COMPRESSION_GZIP -> { + val out = ByteArrayOutputStream() + GZIPOutputStream(out).use { it.write(raw) } + out.toByteArray() + } + else -> error("compression ${PmtilesV3.compressionName(compression)} not supported") + } + + /** Decompress per the given compression code. */ + fun maybeDecompress(compressed: ByteArray, compression: Byte): ByteArray = when (compression) { + PmtilesV3.COMPRESSION_NONE -> compressed + PmtilesV3.COMPRESSION_GZIP -> { + GZIPInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + } + else -> error("compression ${PmtilesV3.compressionName(compression)} not supported") + } + + /** Helper: parse a directory blob given raw possibly-compressed bytes. */ + fun parseCompressed(bytes: ByteArray, compression: Byte): List { + val raw = maybeDecompress(bytes, compression) + val buf = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN) + return parse(buf) + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesHeader.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesHeader.kt new file mode 100644 index 0000000..115c90e --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesHeader.kt @@ -0,0 +1,153 @@ +package org.appdevforall.maps.slicer + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * PMTiles v3 header (127 bytes, little-endian). + * + * Layout per spec: + * ``` + * 0 7 magic "PMTiles" + * 7 1 version (=3) + * 8 8 root_dir_offset + * 16 8 root_dir_bytes + * 24 8 json_metadata_offset + * 32 8 json_metadata_bytes + * 40 8 leaf_dirs_offset + * 48 8 leaf_dirs_bytes + * 56 8 tile_data_offset + * 64 8 tile_data_bytes + * 72 8 addressed_tiles_count + * 80 8 tile_entries_count + * 88 8 tile_contents_count + * 96 1 clustered (1 = clustered, 0 = not) + * 97 1 internal_compression + * 98 1 tile_compression + * 99 1 tile_type + * 100 1 min_zoom + * 101 1 max_zoom + * 102 4 min_lon_e7 (signed int32, longitude × 1e7) + * 106 4 min_lat_e7 + * 110 4 max_lon_e7 + * 114 4 max_lat_e7 + * 118 1 center_zoom + * 119 4 center_lon_e7 + * 123 4 center_lat_e7 + * ``` + */ +internal data class PmtilesHeader( + val version: Byte, + val rootDirOffset: Long, + val rootDirBytes: Long, + val jsonMetadataOffset: Long, + val jsonMetadataBytes: Long, + val leafDirsOffset: Long, + val leafDirsBytes: Long, + val tileDataOffset: Long, + val tileDataBytes: Long, + val addressedTilesCount: Long, + val tileEntriesCount: Long, + val tileContentsCount: Long, + val clustered: Byte, + val internalCompression: Byte, + val tileCompression: Byte, + val tileType: Byte, + val minZoom: Byte, + val maxZoom: Byte, + val minLonE7: Int, + val minLatE7: Int, + val maxLonE7: Int, + val maxLatE7: Int, + val centerZoom: Byte, + val centerLonE7: Int, + val centerLatE7: Int, +) { + + fun isClustered(): Boolean = clustered.toInt() == 1 + + fun minLon(): Double = minLonE7 / 1e7 + fun minLat(): Double = minLatE7 / 1e7 + fun maxLon(): Double = maxLonE7 / 1e7 + fun maxLat(): Double = maxLatE7 / 1e7 + + /** Serialize back to 127 bytes for writing a sliced PMTiles. */ + fun toByteArray(): ByteArray { + val buf = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(PmtilesV3.MAGIC) + buf.put(version) + buf.putLong(rootDirOffset) + buf.putLong(rootDirBytes) + buf.putLong(jsonMetadataOffset) + buf.putLong(jsonMetadataBytes) + buf.putLong(leafDirsOffset) + buf.putLong(leafDirsBytes) + buf.putLong(tileDataOffset) + buf.putLong(tileDataBytes) + buf.putLong(addressedTilesCount) + buf.putLong(tileEntriesCount) + buf.putLong(tileContentsCount) + buf.put(clustered) + buf.put(internalCompression) + buf.put(tileCompression) + buf.put(tileType) + buf.put(minZoom) + buf.put(maxZoom) + buf.putInt(minLonE7) + buf.putInt(minLatE7) + buf.putInt(maxLonE7) + buf.putInt(maxLatE7) + buf.put(centerZoom) + buf.putInt(centerLonE7) + buf.putInt(centerLatE7) + require(buf.position() == PmtilesV3.HEADER_BYTES) { + "header serializer wrote ${buf.position()} bytes, expected ${PmtilesV3.HEADER_BYTES}" + } + return buf.array() + } + + companion object { + /** Parse a 127-byte header from a buffer at its current position. */ + fun parse(buf: ByteBuffer): PmtilesHeader { + require(buf.remaining() >= PmtilesV3.HEADER_BYTES) { + "buffer too small for v3 header: ${buf.remaining()} bytes" + } + buf.order(ByteOrder.LITTLE_ENDIAN) + val magic = ByteArray(7).also { buf.get(it) } + require(magic.contentEquals(PmtilesV3.MAGIC)) { + "magic bytes do not match PMTiles: ${magic.joinToString { "%02x".format(it) }}" + } + val version = buf.get() + require(version == PmtilesV3.VERSION) { + "PMTiles version $version unsupported (want ${PmtilesV3.VERSION})" + } + return PmtilesHeader( + version = version, + rootDirOffset = buf.long, + rootDirBytes = buf.long, + jsonMetadataOffset = buf.long, + jsonMetadataBytes = buf.long, + leafDirsOffset = buf.long, + leafDirsBytes = buf.long, + tileDataOffset = buf.long, + tileDataBytes = buf.long, + addressedTilesCount = buf.long, + tileEntriesCount = buf.long, + tileContentsCount = buf.long, + clustered = buf.get(), + internalCompression = buf.get(), + tileCompression = buf.get(), + tileType = buf.get(), + minZoom = buf.get(), + maxZoom = buf.get(), + minLonE7 = buf.int, + minLatE7 = buf.int, + maxLonE7 = buf.int, + maxLatE7 = buf.int, + centerZoom = buf.get(), + centerLonE7 = buf.int, + centerLatE7 = buf.int, + ) + } + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicer.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicer.kt new file mode 100644 index 0000000..102382e --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicer.kt @@ -0,0 +1,643 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.davidmoten.hilbert.HilbertCurve +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.coroutineContext +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.tan + +/** + * PMTiles v3 region slicer — single source of truth for "which tiles cover a + * bbox", shared by the size estimator and the downloader. + * + * The IIAB exposes one giant offset-addressable PMTiles file; this produces a + * single per-region file with the same structure but only the region's data. + * + * The shared function is [tilesInRegion]. It: + * 1. Range-fetches the 127-byte v3 header from `globalPmtilesUrl`. + * 2. Range-fetches the root directory. + * 3. Recursively expands any leaf directories whose tile-id range overlaps the + * bbox's [zoomMin..zoomMax] tile-id ranges. + * 4. Filters directory entries to those whose (z, x, y) intersects the bbox. + * 5. Returns a flat list of [TileEntry], byte offsets absolute in the global file. + * + * **No tile bytes are fetched by `tilesInRegion`** — only header + directories. + * That keeps the size-estimate path cheap (sub-megabyte network) and lets the + * downloader reuse the same `List`. + */ +internal object PmtilesRegionSlicer { + + /** + * Single source of truth: tiles inside [bbox] across [zoomMin]..[zoomMax]. + * Reads PMTiles v3 header + directories via HTTP Range; does **not** + * fetch tile bytes. + */ + suspend fun tilesInRegion( + globalPmtilesUrl: String, + bbox: Bbox, + zoomMin: Int, + zoomMax: Int, + client: OkHttpClient = RangeFetcher.defaultClient(), + ): Result> = withContext(Dispatchers.IO) { + runCatching { + RangeFetcher.forUrl(globalPmtilesUrl, client).use { fetcher -> + tilesInRegionImpl(fetcher, bbox, zoomMin, zoomMax) + } + } + } + + /** + * Variant that operates on an open [RangeFetcher] — useful when the caller + * has already opened one for `downloadAndSlice` and wants to avoid a + * re-connection. + */ + internal suspend fun tilesInRegionImpl( + fetcher: RangeFetcher, + bbox: Bbox, + zoomMin: Int, + zoomMax: Int, + ): List { + require(zoomMin in 0..20) + require(zoomMax in zoomMin..20) + // 1. Header + coroutineContext.ensureActive() + val headerBytes = fetcher.readRange(0L, PmtilesV3.HEADER_BYTES) + val header = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + + // The user might pick zooms outside what the archive carries — clip. + val zMin = max(zoomMin, header.minZoom.toInt()) + val zMax = min(zoomMax, header.maxZoom.toInt()) + if (zMin > zMax) return emptyList() + + // 2. Root directory + coroutineContext.ensureActive() + val rootRaw = fetcher.readRange(header.rootDirOffset, header.rootDirBytes.toInt()) + val rootEntries = PmtilesDirectory.parseCompressed(rootRaw, header.internalCompression) + + // 3. Compute the tile-id ranges we care about, per zoom level. A leaf + // directory whose entry range overlaps any of these has tiles we need; + // otherwise we skip it (and its bytes). + // + // Uses davidmoten/hilbert-curve's SmallHilbertCurve.query() — + // perimeter-walk range decomposition (Lawder & King family) — to get + // tight ranges, which means fewer false-positive leaf fetches over HTTP + // than a single loose [lo, hi] per zoom. Same Hilbert ordering as our + // Hilbert function (verified by DavidmotenHilbertCompatTest). + val zoomToIdRanges: Map> = (zMin..zMax).associateWith { z -> + tileIdRangesForBbox(bbox, z) + } + + // 4. Walk root, recursing into leaves on overlap, collecting tiles. Pass + // ONE semaphore through the whole recursion so total in-flight HTTP + // fetches stay bounded at [PARALLEL_FETCHES]. Per-call semaphores would + // multiply at each recursion level (6^N at depth N — trees go 2-3 deep), + // filling the heap with pending Deferreds + leaf ByteArrays. + val tiles = mutableListOf() + val sem = Semaphore(PARALLEL_FETCHES) + walkEntries( + entries = rootEntries, + zoomToIdRanges = zoomToIdRanges, + header = header, + fetcher = fetcher, + bbox = bbox, + out = tiles, + sem = sem, + ) + return tiles + } + + private suspend fun walkEntries( + entries: List, + zoomToIdRanges: Map>, + header: PmtilesHeader, + fetcher: RangeFetcher, + bbox: Bbox, + out: MutableList, + sem: Semaphore, + ) { + // For each entry: if it's a leaf pointer (runLength==0), the leaf directory + // describes the tile-id range [e.tileId, nextSiblingTileId). Leaves are + // fetched in parallel (sequential routinely took minutes on cold-cache + // global archives); non-leaf tile entries are processed inline (CPU-cheap). + val sorted = entries.sortedBy { it.tileId } + data class LeafToFetch(val pointer: DirectoryEntry, val nextId: Long) + val leavesToFetch = mutableListOf() + for (i in sorted.indices) { + coroutineContext.ensureActive() + val e = sorted[i] + val nextId = if (i + 1 < sorted.size) sorted[i + 1].tileId else Long.MAX_VALUE + if (e.isLeafPointer()) { + val leafIdRange = e.tileId until nextId + val anyOverlap = zoomToIdRanges.values.any { ranges -> + ranges.any { it.overlaps(leafIdRange) } + } + if (!anyOverlap) continue + leavesToFetch += LeafToFetch(e, nextId) + } else { + // Not a leaf pointer, so runLength != 0 (isLeafPointer() IS + // runLength == 0L) — use it directly as the run count. + val runLen = e.runLength + for (k in 0 until runLen) { + val tid = e.tileId + k + val (z, x, y) = Hilbert.tileIdToZxy(tid) + val ranges = zoomToIdRanges[z] ?: continue + if (ranges.none { tid in it }) continue + if (!tileIntersectsBbox(z, x, y, bbox)) continue + out += TileEntry( + z = z, + x = x, + y = y, + tileId = tid, + byteOffset = header.tileDataOffset + e.offset, + byteLength = e.length, + ) + } + } + } + + if (leavesToFetch.isEmpty()) return + + // Fan out leaf fetches under the shared [sem] passed down from + // [tilesInRegionImpl]. Each leaf is a small range request (few KB) + // followed by a recursive walk; recursive walks reuse the SAME + // semaphore so total in-flight HTTP fetches stay bounded across + // the whole tree. + coroutineScope { + val perLeafResults = leavesToFetch.map { leaf -> + async(Dispatchers.IO) { + sem.withPermit { + coroutineContext.ensureActive() + val leafBytes = fetcher.readRange( + header.leafDirsOffset + leaf.pointer.offset, + leaf.pointer.length.toInt(), + ) + val leafEntries = PmtilesDirectory.parseCompressed( + leafBytes, header.internalCompression + ) + // Each leaf walks into its own private list so we can + // merge in a fixed order at the end, without locking + // `out` per-tile. The merge is bounded by total tiles + // matched, which is what we want anyway. + val localOut = mutableListOf() + walkEntries(leafEntries, zoomToIdRanges, header, fetcher, bbox, localOut, sem) + localOut + } + } + }.awaitAll() + for (local in perLeafResults) out += local + } + } + + /** + * Sum of `byteLength` across [tiles] = approximate sliced archive size + * (just the tile-data section; header + dir overhead is ~1-10 KB and + * negligible relative to tile MBs). + * + * Note: this **double-counts** tiles that share a content blob in the + * global archive. The true sliced archive size will be slightly smaller + * because dedup is preserved. For the wizard's "show MB estimate" use + * case this overestimate is acceptable and conservative (we won't + * surprise the user with a *bigger* download than estimated). + */ + fun estimateRegionBytes(tiles: List): Long { + return tiles.sumOf { it.byteLength } + ESTIMATE_OVERHEAD_BYTES + } + + /** + * Header + directory overhead estimate. 127 bytes header + ~5 KB worst-case + * gzipped root directory. Rounded up for safety. + */ + private const val ESTIMATE_OVERHEAD_BYTES = 8L * 1024L + + // ----- Fetch coalescing + parallelism knobs ----- + // + // Tuned for IIAB over typical home wifi (10-50 Mbps) and LAN (100+ Mbps). + // Internet RTT to iiab.switnet.org runs ~80-150ms; LAN RTT ~5-20ms. + // + // At "few MB/s" target throughput, a 64KB gap takes ~10-20ms to download + // vs. ~80-150ms RTT to make an extra request — so coalescing any gap + // below ~512KB is profitable on internet, ~50KB on LAN. 64KB is a safe + // mid-point that wins on both. + + /** Max byte gap between adjacent blobs that we merge into one fetch. */ + internal const val COALESCE_GAP_BYTES: Long = 64L * 1024L + + /** Max bytes per single HTTP range request. Caps memory + lets a single + * huge cluster (e.g., dense city at z14) still benefit from parallelism. */ + internal const val MAX_CHUNK_BYTES: Long = 4L * 1024L * 1024L + + /** Concurrent in-flight HTTP range requests during a download. */ + internal const val PARALLEL_FETCHES = 6 + + /** + * A contiguous fetch — covers one or more original blobs whose byte ranges + * are within [COALESCE_GAP_BYTES] of each other. + */ + internal data class FetchChunk( + val offset: Long, + val length: Long, + val blobs: List>, + ) + + /** + * Coalesce a list of unique blobs (sorted by [Pair.first] = byte offset) + * into fewer fetch chunks: any two blobs whose gap is ≤ [maxGap] bytes + * get merged into the same chunk. Returns chunks in ascending byte order. + */ + internal fun coalesceBlobs( + sortedBlobs: List>, + maxGap: Long, + ): List { + if (sortedBlobs.isEmpty()) return emptyList() + val out = mutableListOf() + var startOff = sortedBlobs[0].first + var endOff = startOff + sortedBlobs[0].second + val running = mutableListOf(sortedBlobs[0]) + for (i in 1 until sortedBlobs.size) { + val (o, len) = sortedBlobs[i] + val gap = o - endOff + if (gap <= maxGap) { + running += sortedBlobs[i] + endOff = maxOf(endOff, o + len) + } else { + out += FetchChunk(startOff, endOff - startOff, running.toList()) + running.clear() + running += sortedBlobs[i] + startOff = o + endOff = o + len + } + } + out += FetchChunk(startOff, endOff - startOff, running.toList()) + return out + } + + /** + * Split a [chunk] whose length exceeds [maxBytes] into multiple chunks of + * ≤ [maxBytes] each, distributing the contained blobs by their offset. + * No-op for chunks already within the bound. + */ + internal fun splitOversizedChunk( + chunk: FetchChunk, + maxBytes: Long, + ): List { + if (chunk.length <= maxBytes) return listOf(chunk) + val parts = mutableListOf() + var currentStart = chunk.offset + val sortedBlobs = chunk.blobs.sortedBy { it.first } + var idx = 0 + while (idx < sortedBlobs.size) { + val budgetEnd = currentStart + maxBytes + val partBlobs = mutableListOf>() + var partEnd = currentStart + while (idx < sortedBlobs.size) { + val (o, len) = sortedBlobs[idx] + val blobEnd = o + len + // If adding this blob would push the part beyond budgetEnd + // AND the part is non-empty, close out the part. If a single + // blob exceeds maxBytes by itself, take it anyway (one giant + // tile — atypical but possible). + if (partBlobs.isNotEmpty() && blobEnd - currentStart > maxBytes) break + partBlobs += sortedBlobs[idx] + partEnd = maxOf(partEnd, blobEnd) + idx++ + } + parts += FetchChunk(currentStart, partEnd - currentStart, partBlobs.toList()) + currentStart = partEnd + } + return parts + } + + /** + * Fetch every tile in [tiles] from [globalPmtilesUrl] and write a sliced + * v3 PMTiles archive to [targetFile]. + * + * Tile content dedup is preserved: tiles sharing `(byteOffset, byteLength)` + * in the source archive get a single content blob in the output, with + * multiple directory entries pointing at it. + * + * [onProgress] reports downloaded vs total **bytes of unique tile content** + * (so it tracks the actual network spend, not the inflated double-counted + * estimate). + */ + suspend fun downloadAndSlice( + tiles: List, + globalPmtilesUrl: String, + sourceHeader: PmtilesHeader, + bbox: Bbox, + zoomMin: Int, + zoomMax: Int, + targetFile: File, + onProgress: (downloaded: Long, total: Long) -> Unit, + client: OkHttpClient = RangeFetcher.defaultClient(), + ): Result = withContext(Dispatchers.IO) { + runCatching { + require(tiles.isNotEmpty()) { "no tiles to slice — empty bbox/zoom intersection?" } + // Group entries by (offset, length): each unique blob becomes one + // entry in the sliced archive. Preserve insertion order; multiple + // tile_ids will be grouped into a directory run later. + val blobByKey = linkedMapOf, MutableList>() + for (t in tiles) { + val key = t.byteOffset to t.byteLength + blobByKey.getOrPut(key) { mutableListOf() } += t + } + + val totalBytes = blobByKey.keys.sumOf { it.second } + onProgress(0L, totalBytes) + + // Build the sliced directory entries in tile-id order. For each + // blob: one entry per tile_id, but if a blob's tile_ids are + // contiguous on the Hilbert curve, collapse into one run. + val orderedTiles = tiles.sortedBy { it.tileId } + val outEntries = mutableListOf() + val blobLocalOffset = mutableMapOf, Long>() // key -> offset in sliced tile-data + var nextLocalOffset = 0L + + // First pass: assign local offsets in the same order tile-ids appear. + for (t in orderedTiles) { + val key = t.byteOffset to t.byteLength + if (key !in blobLocalOffset) { + blobLocalOffset[key] = nextLocalOffset + nextLocalOffset += t.byteLength + } + } + + // Second pass: build directory entries with run-length compression. + run { + var i = 0 + while (i < orderedTiles.size) { + val t = orderedTiles[i] + val key = t.byteOffset to t.byteLength + var runEnd = i + // Extend run as long as: contiguous tile_id and same key. + while (runEnd + 1 < orderedTiles.size && + orderedTiles[runEnd + 1].tileId == orderedTiles[runEnd].tileId + 1 && + (orderedTiles[runEnd + 1].byteOffset to orderedTiles[runEnd + 1].byteLength) == key + ) { + runEnd++ + } + val runLen = (runEnd - i + 1).toLong() + outEntries += DirectoryEntry( + tileId = t.tileId, + runLength = runLen, + length = t.byteLength, + offset = blobLocalOffset[key]!!, + ) + i = runEnd + 1 + } + } + + // Serialize directory. + val dirRaw = PmtilesDirectory.serialize(outEntries) + val dirCompressed = PmtilesDirectory.maybeCompress(dirRaw, sourceHeader.internalCompression) + + // Compute the new header offsets: + // [0..127) header + // [127..rd_end) root directory (we use no leaves — small slices fit in root) + // [rd_end..meta) metadata (copied from source — MapLibre needs + // `vector_layers` etc. to render anything) + // [meta..td) empty leaf-dirs section + // [td..end) tile data + // + // The metadata block is copied verbatim from the source. MapLibre reads + // `vector_layers` from it to learn which source-layers exist (water, + // transportation, place, …) and their per-layer min/max zooms. Without + // it, every layer referencing a `source-layer` silently has no data — + // the user sees the basemap with an empty overlay. + // + // One-shot fetcher for this tiny read; the main parallel fetcher below + // opens its own scope. + val metadataCompressed = RangeFetcher.forUrl(globalPmtilesUrl, client).use { metaFetcher -> + metaFetcher.readRange( + sourceHeader.jsonMetadataOffset, + sourceHeader.jsonMetadataBytes.toInt(), + ) + } + val headerEnd = PmtilesV3.HEADER_BYTES.toLong() + val rootDirOffset = headerEnd + val rootDirBytes = dirCompressed.size.toLong() + val metaOffset = rootDirOffset + rootDirBytes + val metaBytes = metadataCompressed.size.toLong() + val leafOffset = metaOffset + metaBytes + val leafBytes = 0L + val tileDataOffset = leafOffset + leafBytes + val tileDataBytes = nextLocalOffset + + // Compute new bbox from the sliced tiles' coverage. + val tileMinLon = tiles.minOf { tileToLon(it.x, it.z) } + val tileMaxLon = tiles.maxOf { tileToLon(it.x + 1, it.z) } + val tileMaxLat = tiles.maxOf { tileToLat(it.y, it.z) } + val tileMinLat = tiles.minOf { tileToLat(it.y + 1, it.z) } + + val newHeader = sourceHeader.copy( + rootDirOffset = rootDirOffset, + rootDirBytes = rootDirBytes, + jsonMetadataOffset = metaOffset, + jsonMetadataBytes = metaBytes, + leafDirsOffset = leafOffset, + leafDirsBytes = leafBytes, + tileDataOffset = tileDataOffset, + tileDataBytes = tileDataBytes, + addressedTilesCount = orderedTiles.size.toLong(), + tileEntriesCount = outEntries.size.toLong(), + tileContentsCount = blobLocalOffset.size.toLong(), + clustered = 1, + minZoom = zoomMin.toByte().coerceAtLeast(sourceHeader.minZoom), + maxZoom = zoomMax.toByte().coerceAtMost(sourceHeader.maxZoom), + minLonE7 = (tileMinLon * 1e7).toInt(), + minLatE7 = (tileMinLat * 1e7).toInt(), + maxLonE7 = (tileMaxLon * 1e7).toInt(), + maxLatE7 = (tileMaxLat * 1e7).toInt(), + centerZoom = ((zoomMin + zoomMax) / 2).toByte(), + centerLonE7 = (((tileMinLon + tileMaxLon) / 2.0) * 1e7).toInt(), + centerLatE7 = (((tileMinLat + tileMaxLat) / 2.0) * 1e7).toInt(), + ) + + // Write to a .partial then atomic move. + val tmp = File(targetFile.parentFile, targetFile.name + ".partial") + if (tmp.exists()) tmp.delete() + + RandomAccessFile(tmp, "rw").use { raf -> + raf.setLength(tileDataOffset + tileDataBytes) + raf.write(newHeader.toByteArray()) + raf.write(dirCompressed) + raf.write(metadataCompressed) + // leaf section empty — nothing to write + + // Fetch + write each unique blob. + // + // PMTiles tile data is Hilbert-clustered, so the unique blobs + // for a region cluster tightly in byte space — adjacent blobs + // are typically a few bytes apart. Doing one HTTP range + // request per blob pays a full RTT (~100ms on the public + // internet, ~10-20ms LAN) for what's often only a few KB of + // data — that's what cratered throughput on the original + // implementation. + // + // Two optimizations here: + // + // 1. Coalesce adjacent blobs (gap ≤ COALESCE_GAP_BYTES) into + // a single "fetch chunk." A blob's gap-cost-equivalent + // download time is roughly the per-request RTT, so any + // gap smaller than that breaks even when fetched as part + // of a larger request. + // + // 2. Parallel-fetch chunks via a Semaphore-capped pool. The + // OkHttp dispatcher's maxRequestsPerHost (raised in + // RegionDownloader) caps the real concurrency; we use a + // coroutine Semaphore to bound async dispatch as well so + // the heap doesn't fill with pending Deferreds. + // + // Chunks larger than MAX_CHUNK_BYTES are split to bound + // per-fetch memory and let a single huge cluster still + // benefit from parallelism. + val orderedBlobs = blobLocalOffset.keys.sortedBy { it.first } + val chunks = coalesceBlobs(orderedBlobs, COALESCE_GAP_BYTES) + .flatMap { splitOversizedChunk(it, MAX_CHUNK_BYTES) } + val downloaded = AtomicLong(0L) + RangeFetcher.forUrl(globalPmtilesUrl, client).use { fetcher -> + coroutineScope { + val sem = Semaphore(PARALLEL_FETCHES) + chunks.map { chunk -> + async(Dispatchers.IO) { + sem.withPermit { + coroutineContext.ensureActive() + val data = fetcher.readRange( + chunk.offset, + chunk.length.toInt(), + ) + // Write each contained blob to its position + // in the sliced tile-data section. The + // RandomAccessFile isn't thread-safe across + // seek+write, so synchronize on the raf + // for the actual disk write. Time spent + // here is microseconds per chunk; the + // bottleneck is the network. + synchronized(raf) { + for (blob in chunk.blobs) { + val (blobOff, blobLen) = blob + val srcOff = (blobOff - chunk.offset).toInt() + val localOff = blobLocalOffset[blob]!! + raf.seek(tileDataOffset + localOff) + raf.write( + data, + srcOff, + blobLen.toInt(), + ) + } + } + val chunkBlobBytes = chunk.blobs.sumOf { it.second } + val newDownloaded = downloaded.addAndGet(chunkBlobBytes) + onProgress(newDownloaded, totalBytes) + } + } + }.awaitAll() + } + } + } + // Atomic move into place. + if (targetFile.exists()) targetFile.delete() + require(tmp.renameTo(targetFile)) { + "Couldn't rename ${tmp.absolutePath} → ${targetFile.absolutePath}" + } + } + } + + // ---------- Geo helpers ---------- + + /** + * The set of Hilbert tile-id ranges that exactly covers every (z, x, y) + * tile inside [bbox] at zoom [z], expressed as a list of merged + * `[lo, hi]` ranges (PMTiles tile-id space, i.e. including the zoom-base + * accumulator). + * + * Backed by davidmoten/hilbert-curve's [SmallHilbertCurve.query], which + * implements the Skilling 2004 perimeter-walk range-decomposition + * algorithm (Lawder & King family). Returns `O(perimeter × log scale)` + * tight ranges instead of one loose `O(area)` range — meaning the slicer's + * leaf-overlap pre-filter actually filters, instead of marking ~30% of + * leaves as "potentially overlapping" for a 20° bbox. + * + * Verified to use the SAME Hilbert ordering as our [Hilbert.zxyToTileId] + * via DavidmotenHilbertCompatTest — so the returned ranges, after + * adding the zoom base accumulator, are exactly leaf-id-comparable. + */ + internal fun tileIdRangesForBbox(bbox: Bbox, z: Int): List { + val n = 1 shl z + val xMin = lonToTileX(bbox.west, z).coerceIn(0, n - 1) + val xMax = lonToTileX(bbox.east, z).coerceIn(0, n - 1) + val yMin = latToTileY(bbox.north, z).coerceIn(0, n - 1) + val yMax = latToTileY(bbox.south, z).coerceIn(0, n - 1) + if (xMin > xMax || yMin > yMax) return emptyList() + + // davidmoten's bits-per-dimension equals z (each dim takes z bits + // in a 2^z grid). 2D curve. + val curve = HilbertCurve.small().bits(z).dimensions(2) + val ranges = curve.query( + longArrayOf(xMin.toLong(), yMin.toLong()), + longArrayOf(xMax.toLong(), yMax.toLong()), + ) + val base = Hilbert.accumulate(z) + // Add the zoom base so the ranges live in PMTiles global tile-id + // space (matches the ids encoded into leaf directories). + return ranges.toList().map { (it.low() + base)..(it.high() + base) } + } + + private fun lonToTileX(lon: Double, z: Int): Int { + val n = 2.0.pow(z) + return floor((lon + 180.0) / 360.0 * n).toInt() + } + + private fun latToTileY(lat: Double, z: Int): Int { + val n = 2.0.pow(z) + val latRad = Math.toRadians(lat.coerceIn(-85.0511, 85.0511)) + return floor((1 - ln(tan(latRad) + 1.0 / cos(latRad)) / Math.PI) / 2.0 * n).toInt() + } + + private fun tileToLon(x: Int, z: Int): Double { + val n = 2.0.pow(z) + return x / n * 360.0 - 180.0 + } + + private fun tileToLat(y: Int, z: Int): Double { + val n = 2.0.pow(z) + val latRad = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * y / n))) + return Math.toDegrees(latRad) + } + + /** True iff the (z, x, y) slippy-map tile overlaps [bbox]. */ + private fun tileIntersectsBbox(z: Int, x: Int, y: Int, bbox: Bbox): Boolean { + val tileWest = tileToLon(x, z) + val tileEast = tileToLon(x + 1, z) + val tileNorth = tileToLat(y, z) + val tileSouth = tileToLat(y + 1, z) + return tileEast > bbox.west && tileWest < bbox.east && + tileNorth > bbox.south && tileSouth < bbox.north + } +} + +private fun LongRange.overlaps(other: LongRange): Boolean { + if (this == LongRange.EMPTY || other == LongRange.EMPTY) return false + return this.first <= other.last && other.first <= this.last +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesV3.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesV3.kt new file mode 100644 index 0000000..d18e479 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesV3.kt @@ -0,0 +1,56 @@ +package org.appdevforall.maps.slicer + +/** + * Constants and small helpers for the PMTiles v3 archive format. + * + * Reference: https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md + * + * Why we have our own reader (Path B): the existing community Java lib + * `ch.poole.geo.pmtiles-reader:Reader:0.3.7` keeps directory entries + * (`ids`, `lengths`, `offsets`) as package-private fields and only exposes + * a per-tile `getTile(z, x, y)` API. For region slicing we need the *whole* + * directory listing so we can: + * - estimate compressed bytes from the offsets+lengths (no tile fetch needed) + * - download a contiguous tile range and write a sliced PMTiles output + * Driving `getTile()` per (z,x,y) inside a region would issue one or more + * HTTP range requests per tile and discard all the directory data after. + * A from-scratch v3 reader is ~400 lines and gives us exactly what we need. + */ +internal object PmtilesV3 { + const val HEADER_BYTES = 127 + val MAGIC: ByteArray = byteArrayOf('P'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), + 'i'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(), 's'.code.toByte()) + const val VERSION: Byte = 3 + + // Compression codes + const val COMPRESSION_UNKNOWN: Byte = 0 + const val COMPRESSION_NONE: Byte = 1 + const val COMPRESSION_GZIP: Byte = 2 + const val COMPRESSION_BROTLI: Byte = 3 + const val COMPRESSION_ZSTD: Byte = 4 + + // Tile types + const val TYPE_UNKNOWN: Byte = 0 + const val TYPE_MVT: Byte = 1 + const val TYPE_PNG: Byte = 2 + const val TYPE_JPEG: Byte = 3 + const val TYPE_WEBP: Byte = 4 + const val TYPE_AVIF: Byte = 5 + + fun compressionName(b: Byte): String = when (b) { + COMPRESSION_NONE -> "none" + COMPRESSION_GZIP -> "gzip" + COMPRESSION_BROTLI -> "brotli" + COMPRESSION_ZSTD -> "zstd" + else -> "unknown($b)" + } + + fun tileTypeName(b: Byte): String = when (b) { + TYPE_MVT -> "mvt" + TYPE_PNG -> "png" + TYPE_JPEG -> "jpeg" + TYPE_WEBP -> "webp" + TYPE_AVIF -> "avif" + else -> "unknown($b)" + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/README.md b/maps/src/main/kotlin/org/appdevforall/maps/slicer/README.md new file mode 100644 index 0000000..189c258 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/README.md @@ -0,0 +1,76 @@ +# PMTiles region slicer + +This package extracts the tiles for a single user-chosen region out of a large +remote [PMTiles](https://github.com/protomaps/PMTiles) v3 archive — **without +downloading the whole archive** — and writes a small, self-contained PMTiles +file containing just that region. + +## Why this exists + +Code on the Go targets low-end Android phones on limited or no internet. The +upstream OpenStreetMap vector tiles (and Natural Earth basemap) ship +as **multi-gigabyte global PMTiles archives**. Downloading a whole planet file +to put a city on a phone is a non-starter for the mission's bandwidth budget. + +PMTiles is designed for exactly this: it's a single file whose internal +directory lets a client compute the byte ranges of just the tiles it wants and +fetch them with **HTTP range requests**. So instead of `GET planet.pmtiles` +(GBs), the slicer does many small `Range:` reads of only the bbox's tiles +(typically tens of MB), then repackages them into a fresh PMTiles archive the +generated app can read offline. + +This is the single source of truth for "which tiles cover a bbox" — both the +bbox picker's **live size estimate** and the actual **download** call the same +code, so the estimate matches the download. + +## How a slice works + +1. **Read the header** (`PmtilesHeader`) — a 127-byte fixed block giving the + directory + tile-data offsets and the zoom range. +2. **Walk the directory** (`PmtilesDirectory` + `Varint`) — the directory is a + varint-encoded index of `tile_id → (offset, length)` entries, Hilbert-ordered. +3. **Map the bbox to tile IDs** (`Hilbert`) — PMTiles orders tiles along a + Hilbert curve; the slicer converts the bbox's `(z, x, y)` tiles to the + contiguous-ish ID ranges to look up, so it touches only directory leaves that + can overlap the region (not the whole index). +4. **Collect intersecting tiles** (`TileEntry`) — the entries whose tiles fall + inside the bbox, with their absolute byte offsets in the source file. +5. **Fetch only those bytes** (`RangeFetcher` / `HttpRangeByteCache`) — HTTP + range reads against the source URL (LAN IIAB or internet), with header + + directory reads cached so the estimate and the download don't re-fetch them. +6. **Write a new archive** (`PmtilesV3` + `PmtilesRegionSlicer`) — a valid + PMTiles v3 file (header + rebuilt directory + packed tile data) containing + only the region. The generated app serves it via a loopback HTTP server + (`pmtiles://http://127.0.0.1:…`). + +The **size estimate** runs steps 1–4 only (no tile-byte download) and sums the +entry lengths; results are memoized in `SliceEstimateCache` keyed by +`(sourceUrl, bbox, zoom range)` so dragging the bbox doesn't re-slice each frame. + +## What's implemented + +| File | Responsibility | +|---|---| +| `PmtilesRegionSlicer.kt` | Orchestrator: bbox → intersecting tiles → fetch → write region archive. Also `tilesInRegion` for the estimate. | +| `PmtilesHeader.kt` | Parse/serialize the 127-byte PMTiles v3 header. | +| `PmtilesDirectory.kt` | Parse/serialize the varint directory blobs (`tile_id → offset/length`). | +| `Hilbert.kt` | `(z, x, y) ↔ tile_id` Hilbert-curve conversion per the v3 spec. | +| `Varint.kt` | Protobuf-style unsigned varint encode/decode used by the directory. | +| `TileEntry.kt` | One region-intersecting tile: id, absolute byte offset, length, run length. | +| `PmtilesV3.kt` | Format constants + small helpers; spec reference. | +| `RangeFetcher.kt` | `read a byte range` abstraction (`HttpRangeFetcher`) + `HttpRangeByteCache` for header/directory reads. | +| `SliceEstimateCache.kt` | Process-wide memoization of slice estimates for the bbox picker. | + +## Key design decisions + +- **Tight Hilbert range pre-filter + zoom auto-cap** (2026-05-26): the estimate + was slow (≥60 s) for large bboxes. Two fixes: walk tight perimeter Hilbert + ranges per zoom (validated against our own `Hilbert` via the davidmoten + hilbert-curve lib's `DavidmotenHilbertCompatTest`), and auto-cap the max zoom + so total cells ≤ 100 k (a whole-world bbox drops to ~z8). Zoom is the lever + that scales both slicer work and download size, so capping it makes any bbox + downloadable at *some* detail level — replacing the old "region too large" + rejection. A 1 GB hard byte cap remains the final guardrail. +- **Slice, don't bitmap.** We use Hilbert range decomposition rather than + porting `go-pmtiles`'s bitmap approach — same correctness for our + leaf-overlap query, ~5× less code, KB instead of MB of peak memory. diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/RangeFetcher.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/RangeFetcher.kt new file mode 100644 index 0000000..085059d --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/RangeFetcher.kt @@ -0,0 +1,204 @@ +package org.appdevforall.maps.slicer + +import android.net.TrafficStats +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.Closeable +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile + +/** + * Abstraction over "read a byte range from a PMTiles archive". Implementations: + * - [HttpRangeFetcher] — for `http://` / `https://` URLs (LAN or internet) + * - [FileRangeFetcher] — for `file://` URLs (debug / unit tests against the + * bundled Natural Earth pmtiles) + * + * Both honor cancellation via the caller's coroutine context — the actual + * cooperation point is between fetched chunks at the call-site. + */ +internal interface RangeFetcher : Closeable { + /** Read [length] bytes starting at [offset] of the underlying source. */ + @Throws(IOException::class) + fun readRange(offset: Long, length: Int): ByteArray + + override fun close() = Unit + + companion object { + /** + * Build the right fetcher for [url]. Supports `http://`, `https://`, + * and `file://` (latter is convenient for tests + the bundled NE + * archive). + */ + fun forUrl(url: String, client: OkHttpClient = defaultClient()): RangeFetcher = when { + url.startsWith("http://") || url.startsWith("https://") -> + HttpRangeFetcher(url, client) + url.startsWith("file://") -> + FileRangeFetcher(File(url.removePrefix("file://"))) + else -> + throw IllegalArgumentException("Unsupported URL scheme: $url") + } + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder().build() + } +} + +/** + * Process-wide LRU cache for HTTP range reads, keyed by `(url, offset, length)`. + * + * Why: the slicer's `tilesInRegion` walk re-fetches the same header + root + * directory + most of the same leaves every time the user adjusts the bbox. + * Without this cache, each adjustment re-runs the full directory walk from + * scratch over the network. With it, only the *new* leaves a pan/zoom uncovers + * actually hit the network — the rest are instant in-memory copies. + * + * Bounded at 8 MiB total: even a global PMTiles archive's header + root + all + * leaves combined is well under that, and the cache is shared across slicer + * runs in the same process so the second-and-later runs typically hit + * everything they need without a single network read. + * + * Tile-byte reads should NOT go through this fetcher path — they happen via + * the download pipeline, which has its own coalescing logic. If they ever did + * land here, LRU eviction caps the damage at 8 MiB. + */ +internal object HttpRangeByteCache { + private const val MAX_BYTES = 8L * 1024 * 1024 + private var totalBytes = 0L + + // accessOrder=true → get() promotes to most-recently-used so LRU eviction + // throws out the truly-cold entries. + private val entries = object : LinkedHashMap(64, 0.75f, true) {} + + @Synchronized + fun get(url: String, offset: Long, length: Int): ByteArray? { + return entries["$url|$offset|$length"] + } + + @Synchronized + fun put(url: String, offset: Long, length: Int, bytes: ByteArray) { + val key = "$url|$offset|$length" + if (entries.containsKey(key)) return + entries[key] = bytes + totalBytes += bytes.size.toLong() + while (totalBytes > MAX_BYTES && entries.isNotEmpty()) { + val it = entries.entries.iterator() + val first = it.next() + it.remove() + totalBytes -= first.value.size.toLong() + } + } + + /** + * Drop every entry. Call before a download starts — the cache is intended + * for the bbox-picker's rapid-pan estimate loop, where we WANT stale-OK + * reads. A download is a different contract: the produced .pmtiles must + * match the upstream archive's *current* bytes, so we re-read everything + * fresh. Without this, an upstream mid-session swap of the archive at the + * same URL (the weekly OSM updates IIAB tracks) would produce a header from + * version N glued to leaf bytes from version N+1 — silently broken. + */ + @Synchronized + fun clear() { + entries.clear() + totalBytes = 0L + } +} + +internal class HttpRangeFetcher( + private val url: String, + private val client: OkHttpClient, +) : RangeFetcher { + + private companion object { + /** + * Cap on a 200-OK (Range-ignored) response body. A compliant server + * answers with 206 + only the requested range; a 200 means it sent the + * whole archive, which for OSM PMTiles can be multiple GB — + * buffering that to slice locally OOMs the phone. Larger-than-this or + * unknown-length bodies are refused rather than buffered. + */ + const val MAX_200_FALLBACK_BYTES = 32L * 1024L * 1024L + + /** + * Socket traffic-stats tag for range fetches. Tagging the thread before + * the okhttp call keeps StrictMode's UntaggedSocketViolation quiet and + * lets the platform attribute this traffic to the slicer. + */ + private const val THREAD_STATS_TAG = 0x4D41_5053 // "MAPS" + } + + override fun readRange(offset: Long, length: Int): ByteArray { + require(offset >= 0) { "offset $offset must be nonneg" } + require(length > 0) { "length $length must be positive" } + HttpRangeByteCache.get(url, offset, length)?.let { return it } + val end = offset + length - 1 + val req = Request.Builder() + .url(url) + .header("Range", "bytes=$offset-$end") + .build() + TrafficStats.setThreadStatsTag(THREAD_STATS_TAG) + try { + client.newCall(req).execute().use { resp -> + // 206 is the spec-compliant range response; 200 means the server + // ignored Range and sent the whole file (which we still slice + // locally rather than blow up the call). + when (resp.code) { + 206 -> { + val body = resp.body ?: throw IOException("No body for range $offset-$end of $url") + val bytes = body.bytes() + require(bytes.size == length || bytes.size == (end - offset + 1).toInt()) { + "Range response returned ${bytes.size} bytes, expected $length" + } + HttpRangeByteCache.put(url, offset, length, bytes) + return bytes + } + 200 -> { + val body = resp.body ?: throw IOException("No body for $url") + // The server ignored Range and will send the whole file. For a + // multi-GB PMTiles archive, buffering it into memory to slice + // locally is an instant OOM on a 2-4 GB phone. Refuse loudly when + // the body is unknown-length or larger than the cap; the slicer + // treats a fetch failure as a download error, so failing here is safe. + val declared = body.contentLength() + if (declared < 0 || declared > MAX_200_FALLBACK_BYTES) { + throw IOException( + "Server ignored Range and returned $declared bytes for $url " + + "(cap $MAX_200_FALLBACK_BYTES) — refusing to buffer whole archive", + ) + } + val whole = body.bytes() + require(offset.toInt().toLong() == offset && (offset.toInt() + length) <= whole.size) { + "200-OK fallback can't satisfy range $offset-$end of size ${whole.size}" + } + val slice = whole.copyOfRange(offset.toInt(), offset.toInt() + length) + HttpRangeByteCache.put(url, offset, length, slice) + return slice + } + else -> throw IOException("HTTP ${resp.code} for $url range $offset-$end") + } + } + } finally { + TrafficStats.clearThreadStatsTag() + } + } +} + +internal class FileRangeFetcher(file: File) : RangeFetcher { + private val raf = RandomAccessFile(file, "r") + + override fun readRange(offset: Long, length: Int): ByteArray { + val buf = ByteArray(length) + raf.seek(offset) + var read = 0 + while (read < length) { + val n = raf.read(buf, read, length - read) + if (n < 0) throw IOException("EOF after $read of $length bytes at offset $offset") + read += n + } + return buf + } + + override fun close() { + raf.close() + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/SliceEstimateCache.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/SliceEstimateCache.kt new file mode 100644 index 0000000..f82df51 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/SliceEstimateCache.kt @@ -0,0 +1,58 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox + +/** + * Process-wide cache for slicer estimates keyed by `(sourceUrl, bbox, zoom range)`. + * + * Step 2's bbox picker hits this on every debounced drag-end so the user + * sees real bytes rather than a synthetic `tileCount × 50 KB`. Step 3 reads + * the same cache entry on entry — the spec calls for one cached result + * shared across both surfaces, not two independent slicer walks. + * + * The cache key is the **rounded** bbox + zoom range, so tiny mouse jitter + * doesn't cause a re-walk. Rounding is to 4 decimal places (~11 m at the + * equator), which is well below tile-edge precision at z14 (~2.4 m / tile + * pixel at z19, ~76 m at z14). + * + * Eviction: LRU bound of 16 entries — enough for ~3-4 regions of bbox + * exploration before the oldest entry falls out. + */ +internal object SliceEstimateCache { + + private const val MAX_ENTRIES = 16 + private const val ROUND_DECIMALS = 4 + + /** Cached value: tile entries (for the downloader to read directly later). */ + private val cache = object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean = + size > MAX_ENTRIES + } + + @Synchronized + fun get(sourceUrl: String, bbox: Bbox, zoomMin: Int, zoomMax: Int): List? = + cache[Key(sourceUrl, bbox.rounded(), zoomMin, zoomMax)] + + @Synchronized + fun put(sourceUrl: String, bbox: Bbox, zoomMin: Int, zoomMax: Int, value: List) { + cache[Key(sourceUrl, bbox.rounded(), zoomMin, zoomMax)] = value + } + + @Synchronized + fun clear() { + cache.clear() + } + + private fun Bbox.rounded(): RoundedBbox { + val scale = Math.pow(10.0, ROUND_DECIMALS.toDouble()) + return RoundedBbox( + Math.round(south * scale) / scale, + Math.round(west * scale) / scale, + Math.round(north * scale) / scale, + Math.round(east * scale) / scale, + ) + } + + private data class RoundedBbox(val s: Double, val w: Double, val n: Double, val e: Double) + private data class Key(val url: String, val box: RoundedBbox, val zMin: Int, val zMax: Int) +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/TileEntry.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/TileEntry.kt new file mode 100644 index 0000000..58df803 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/TileEntry.kt @@ -0,0 +1,23 @@ +package org.appdevforall.maps.slicer + +/** + * One tile within a global PMTiles archive that intersects a region. + * + * `byteOffset` is **absolute within the global file** — it is `header.tileDataOffset + * + dirEntry.offset`. `byteLength` is the on-the-wire compressed size (pmtiles + * stores already-compressed tile bytes, so this is what an HTTP Range will fetch). + * + * Note: multiple [TileEntry]s with different `(z, x, y)` may share the same + * `(byteOffset, byteLength)`. The PMTiles v3 format does content-level dedup of + * tile bytes — e.g. blank ocean tiles all point at one blob. The slicer must + * preserve this when writing a sliced archive (write each unique tile-content + * once, share offsets in the sliced directory). + */ +internal data class TileEntry( + val z: Int, + val x: Int, + val y: Int, + val tileId: Long, + val byteOffset: Long, + val byteLength: Long, +) diff --git a/maps/src/main/kotlin/org/appdevforall/maps/slicer/Varint.kt b/maps/src/main/kotlin/org/appdevforall/maps/slicer/Varint.kt new file mode 100644 index 0000000..d194bdd --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/slicer/Varint.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.slicer + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +/** + * Protobuf-style unsigned varints, used by PMTiles v3 directories. + * + * Wire format (per protobuf): little-endian base-128. The MSB of each byte + * indicates "more bytes follow". A 64-bit varint takes at most 10 bytes. + */ +internal object Varint { + + /** Decode a single varint from [buf] starting at its current position. */ + fun decode(buf: ByteBuffer): Long { + var result = 0L + var shift = 0 + while (true) { + require(shift < 64) { "varint > 64 bits" } + val b = buf.get().toInt() and 0xff + result = result or ((b and 0x7f).toLong() shl shift) + if ((b and 0x80) == 0) return result + shift += 7 + } + } + + /** Encode [value] as a varint to [out]. */ + fun encode(value: Long, out: ByteArrayOutputStream) { + require(value >= 0) { "negative varint $value not supported by spec" } + var v = value + while ((v and 0x7fL.inv()) != 0L) { + out.write(((v and 0x7fL) or 0x80L).toInt()) + v = v ushr 7 + } + out.write((v and 0x7fL).toInt()) + } + + /** Max bytes needed to encode any uint64 varint. */ + const val MAX_BYTES = 10 +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt new file mode 100644 index 0000000..f9e55aa --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt @@ -0,0 +1,51 @@ +package org.appdevforall.maps.slicer + +import org.davidmoten.hilbert.HilbertCurve +import org.davidmoten.hilbert.SmallHilbertCurve +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Verifies that davidmoten/hilbert-curve produces the SAME Hilbert index for + * (x, y) as our [Hilbert.zxyToTileId] minus the zoom-base accumulator. + * + * Why this matters: PmtilesRegionSlicer uses davidmoten's + * `SmallHilbertCurve.query()` to compute tight tile-id ranges for a bbox. If + * davidmoten's Hilbert ordering disagrees with ours, the resulting ranges + * silently *miss* tiles (false negatives), and the slicer would return + * incomplete results. + * + * Both implementations claim to follow the standard "convert (x,y) to d" + * Hilbert curve. This test verifies that empirically across the (z, x, y) + * space we care about. + */ +class DavidmotenHilbertCompatTest { + + @Test + fun davidmotenIndex_matches_pmtilesHilbert_xyToD_atAllZooms() { + for (z in 1..18) { + val side = (1L shl z).toInt() + val curve: SmallHilbertCurve = HilbertCurve.small().bits(z).dimensions(2) + // Sample the full 4 corners + center + a handful of interior points. + val samples = listOf( + 0 to 0, + side - 1 to 0, + 0 to side - 1, + side - 1 to side - 1, + side / 2 to side / 2, + side / 3 to (2 * side / 5), + (3 * side / 7) to (side / 11), + ).filter { (x, y) -> x in 0 until side && y in 0 until side } + + for ((x, y) in samples) { + val ours = Hilbert.zxyToTileId(z, x, y) - Hilbert.accumulate(z) + val theirs = curve.index(x.toLong(), y.toLong()) + assertEquals( + "Hilbert mismatch at z=$z (x=$x, y=$y): ours=$ours, davidmoten=$theirs", + ours, + theirs, + ) + } + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt new file mode 100644 index 0000000..b545425 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt @@ -0,0 +1,70 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Branch coverage for [Hilbert] guard arms not reached by the roundtrip test: + * out-of-range zoom in `accumulate`, out-of-range zoom/coords in `zxyToTileId`, + * negative `tileId` in `tileIdToZxy`, plus all four quadrant rotations of + * `hilbertXyToD` / `hilbertDToXy` (rx/ry combinations) via an explicit z=2 sweep. + */ +class HilbertCoverageTest { + + @Test + fun accumulate_rejects_negative_zoom() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.accumulate(-1) } + } + + @Test + fun accumulate_rejects_zoom_above_26() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.accumulate(27) } + } + + @Test + fun zxyToTileId_rejects_zoom_out_of_range() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(27, 0, 0) } + } + + @Test + fun zxyToTileId_rejects_x_out_of_range() { + // At z=1, n=2, valid x/y in 0..1; x=2 is out of range. + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(1, 2, 0) } + } + + @Test + fun zxyToTileId_rejects_y_out_of_range() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.zxyToTileId(1, 0, 2) } + } + + @Test + fun tileIdToZxy_rejects_negative() { + assertThrows(IllegalArgumentException::class.java) { Hilbert.tileIdToZxy(-1L) } + } + + @Test + fun z2_full_sweep_roundtrips_all_quadrants() { + // A complete 4x4 sweep drives every rx/ry rotation branch in both the + // forward (xy->d) and reverse (d->xy) Hilbert routines. + for (x in 0..3) { + for (y in 0..3) { + val id = Hilbert.zxyToTileId(2, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals(2, z2) + assertEquals(x, x2) + assertEquals(y, y2) + } + } + } + + @Test + fun tileIdToZxy_finds_zoom_for_higher_levels() { + // Drives the zoom-search while loop past the z==0 case multiple times. + val id = Hilbert.zxyToTileId(5, 17, 9) + val (z, x, y) = Hilbert.tileIdToZxy(id) + assertEquals(5, z) + assertEquals(17, x) + assertEquals(9, y) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt new file mode 100644 index 0000000..3388547 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt @@ -0,0 +1,97 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HilbertTest { + + @Test + fun accumulate_zero_is_zero() { + assertEquals(0L, Hilbert.accumulate(0)) + } + + @Test + fun accumulate_z1_is_one() { + // 1 tile at z0 + assertEquals(1L, Hilbert.accumulate(1)) + } + + @Test + fun accumulate_z2_is_five() { + // 1 (z0) + 4 (z1) = 5 + assertEquals(5L, Hilbert.accumulate(2)) + } + + @Test + fun accumulate_z3_is_twenty_one() { + // 1 + 4 + 16 = 21 + assertEquals(21L, Hilbert.accumulate(3)) + } + + /** + * Spot-checks against the PMTiles Python reference impl. The tile_id values + * below come from running `pmtiles.tile.zxy_to_tileid(z, x, y)` in the + * Python pmtiles package; cross-checked because Hilbert variants differ + * slightly between libraries. + */ + @Test + fun zxyToTileId_z0_0_0_is_0() { + assertEquals(0L, Hilbert.zxyToTileId(0, 0, 0)) + } + + @Test + fun zxyToTileId_z1_corners() { + // PMTiles Hilbert at z=1 maps the 4 corners to ids 1..4. + // Expected from reference: (0,0)=1, (0,1)=2, (1,1)=3, (1,0)=4 + assertEquals(1L, Hilbert.zxyToTileId(1, 0, 0)) + assertEquals(2L, Hilbert.zxyToTileId(1, 0, 1)) + assertEquals(3L, Hilbert.zxyToTileId(1, 1, 1)) + assertEquals(4L, Hilbert.zxyToTileId(1, 1, 0)) + } + + @Test + fun zxyToTileId_z2_starts_after_accumulate() { + // All z=2 ids in [5, 5+16) = [5, 21) + for (x in 0..3) { + for (y in 0..3) { + val id = Hilbert.zxyToTileId(2, x, y) + assert(id in 5..20) { "z2 ($x,$y) -> $id, expected in [5,20]" } + } + } + } + + @Test + fun zxy_roundtrips() { + for (z in 0..6) { + val n = 1 shl z + for (x in 0 until n) { + for (y in 0 until n) { + val id = Hilbert.zxyToTileId(z, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals("z mismatch at ($z,$x,$y) -> $id", z, z2) + assertEquals("x mismatch at ($z,$x,$y) -> $id", x, x2) + assertEquals("y mismatch at ($z,$x,$y) -> $id", y, y2) + } + } + } + } + + @Test + fun zxy_roundtrips_z14_random_samples() { + // z=14: 268M tiles — sample a few. + val samples = listOf( + Triple(14, 0, 0), + Triple(14, 1234, 5678), + Triple(14, 16383, 16383), + Triple(14, 8192, 8192), + Triple(14, 100, 16000), + ) + for ((z, x, y) in samples) { + val id = Hilbert.zxyToTileId(z, x, y) + val (z2, x2, y2) = Hilbert.tileIdToZxy(id) + assertEquals(z, z2) + assertEquals(x, x2) + assertEquals(y, y2) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt new file mode 100644 index 0000000..57a5bb6 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt @@ -0,0 +1,92 @@ +package org.appdevforall.maps.slicer + +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Covers [HttpRangeByteCache] — the process-wide in-memory LRU keyed by + * `(url, offset, length)`. Exercises get/put round-trip, the put-of-existing-key + * no-op, LRU eviction at the 8 MiB bound, and clear(). No network needed; the + * cache is a pure in-memory object. + * + * The cache is a process-wide singleton, so each test clears it in @Before and + * @After to stay deterministic regardless of run order. + */ +class HttpRangeByteCacheCoverageTest { + + @Before fun reset() = HttpRangeByteCache.clear() + + @After fun tearDown() = HttpRangeByteCache.clear() + + @Test + fun get_returns_null_when_absent() { + assertNull(HttpRangeByteCache.get("u", 0L, 16)) + } + + @Test + fun put_then_get_round_trips() { + val bytes = byteArrayOf(1, 2, 3, 4) + HttpRangeByteCache.put("u", 10L, 4, bytes) + assertArrayEquals(bytes, HttpRangeByteCache.get("u", 10L, 4)) + } + + @Test + fun key_distinguishes_url_offset_and_length() { + HttpRangeByteCache.put("a", 0L, 4, byteArrayOf(1)) + // Different url, offset, or length → distinct keys (all absent). + assertNull(HttpRangeByteCache.get("b", 0L, 4)) + assertNull(HttpRangeByteCache.get("a", 1L, 4)) + assertNull(HttpRangeByteCache.get("a", 0L, 5)) + } + + @Test + fun put_of_existing_key_is_a_no_op() { + val first = byteArrayOf(9, 9, 9) + HttpRangeByteCache.put("u", 0L, 3, first) + // Second put with the same key must NOT replace the stored value. + HttpRangeByteCache.put("u", 0L, 3, byteArrayOf(0, 0, 0)) + assertArrayEquals(first, HttpRangeByteCache.get("u", 0L, 3)) + } + + @Test + fun clear_drops_all_entries() { + HttpRangeByteCache.put("u", 0L, 2, byteArrayOf(1, 2)) + HttpRangeByteCache.clear() + assertNull(HttpRangeByteCache.get("u", 0L, 2)) + } + + @Test + fun eviction_kicks_out_cold_entries_past_8_mib() { + // MAX_BYTES is 8 MiB. Put nine 1 MiB entries: total 9 MiB forces eviction + // of the least-recently-used entries until total <= 8 MiB. + val oneMib = 1024 * 1024 + for (i in 0 until 9) { + HttpRangeByteCache.put("u", i.toLong(), oneMib, ByteArray(oneMib) { i.toByte() }) + } + // The first-inserted (coldest) entry should have been evicted. + assertNull(HttpRangeByteCache.get("u", 0L, oneMib)) + // The most-recently-inserted entry should still be present. + assertEquals(oneMib, HttpRangeByteCache.get("u", 8L, oneMib)?.size) + } + + @Test + fun get_promotes_to_most_recently_used_so_it_survives_eviction() { + val oneMib = 1024 * 1024 + // Seed entry 0, then fill toward the bound while periodically touching 0. + HttpRangeByteCache.put("u", 0L, oneMib, ByteArray(oneMib)) + for (i in 1 until 8) { + HttpRangeByteCache.put("u", i.toLong(), oneMib, ByteArray(oneMib)) + // Touch entry 0 to keep it hot (accessOrder=true promotes it). + HttpRangeByteCache.get("u", 0L, oneMib) + } + // One more insert pushes total past 8 MiB; entry 0, kept hot, should survive + // while a colder mid entry gets evicted. + HttpRangeByteCache.put("u", 8L, oneMib, ByteArray(oneMib)) + assertEquals(oneMib, HttpRangeByteCache.get("u", 0L, oneMib)?.size) + assertNull(HttpRangeByteCache.get("u", 1L, oneMib)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt new file mode 100644 index 0000000..e52d414 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt @@ -0,0 +1,197 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import kotlin.math.pow +import kotlin.system.measureTimeMillis + +/** + * Validation suite for the six representative NGO regions. + * + * **What this test does** (offline / always-on): + * - Computes `tilesInRegion(...)` for each region's bbox against the + * bundled Natural Earth z0-z4 archive. NE is global, so for any region + * we expect exactly the number of (z,x,y) tiles whose XY-rectangle + * overlaps the bbox at zooms 0..4. + * - Sanity-checks via the published-formula expected tile count: for + * each zoom z, `tile_count = (xMax-xMin+1) * (yMax-yMin+1)`. + * - Records slicer latency. + * + * **What this test does NOT do** (offline limitation): hit the real OSM + * archive on `iiab.switnet.org`. That archive is multi-hundred-MB; the + * test suite has to stay self-contained. The catalog estimate column + * ("~100-150 MB", etc.) refers to that real archive, not NE z0-z4. + * + * To validate against the **real catalog estimates**, run the + * `Maps:mapsOnlineSlicerSmoke` JUnit category test manually with network + * access — it's defined in [OnlineNgoRegionValidationTest]. + */ +class NgoRegionValidationTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + /** + * Six representative NGO regions. Bbox is (south, west, north, east). + */ + private val ngoRegions = listOf( + NgoRegion("coxs-bazar", "Cox's Bazar refugee camps, Bangladesh", + Bbox(21.10, 92.10, 21.30, 92.25)), + NgoRegion("kakuma", "Kakuma refugee camp, Kenya (Turkana)", + Bbox(3.65, 34.80, 3.85, 35.00)), + NgoRegion("goma", "Goma + Lake Kivu shore, DRC (North Kivu)", + Bbox(-1.80, 28.85, -1.50, 29.30)), + NgoRegion("port-au-prince","Port-au-Prince metro, Haiti", + Bbox(18.45, -72.45, 18.65, -72.20)), + NgoRegion("kathmandu", "Kathmandu Valley + districts, Nepal", + Bbox(27.55, 85.20, 27.85, 85.55)), + NgoRegion("maiduguri", "Maiduguri + Borno IDP camps, Nigeria", + Bbox(11.75, 13.05, 11.95, 13.30)), + ) + + private data class NgoRegion(val id: String, val displayName: String, val bbox: Bbox) + + @Test + fun slicer_runs_for_all_six_ngo_regions_offline() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + + println() + println("=== NGO region slicer validation (NE z0-z4 stand-in) ===") + println("%-18s %12s %10s %10s %s".format("region", "tiles", "bytes_kb", "latency_ms", "slippy_math")) + println("-".repeat(80)) + + for (region in ngoRegions) { + val expectedZxyCount = expectedSlippyTileCount(region.bbox, zoomMin = 0, zoomMax = 4) + val tiles: List + val latency = measureTimeMillis { + tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + } + val totalBytes = tiles.sumOf { it.byteLength } + println( + "%-18s %12d %10d %10d expected_slippy=%d".format( + region.id, + tiles.size, + totalBytes / 1024, + latency, + expectedZxyCount, + ) + ) + // NE z0-z4 doesn't guarantee a tile entry for every slippy tile + // (sea-only tiles may be absent), so we assert "we don't over- + // collect" — the slicer's tile count <= the expected slippy + // rectangle count, never above. + assert(tiles.size <= expectedZxyCount) { + "${region.id}: slicer returned ${tiles.size} tiles, expected at most $expectedZxyCount" + } + // Sanity: at least *some* tiles in every region (every NGO region + // has land coverage in NE). + assert(tiles.isNotEmpty()) { + "${region.id}: slicer returned zero tiles — bbox / Hilbert math wrong?" + } + // Latency budget on a local file: should be < 1s even worst-case. + assert(latency < 5000) { + "${region.id}: slicer took ${latency}ms — too slow" + } + } + println("-".repeat(80)) + } + + @Test + fun estimate_is_consistent_with_tile_byte_sum() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + for (region in ngoRegions) { + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + val estimate = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val sum = tiles.sumOf { it.byteLength } + // estimateRegionBytes adds a fixed 8KB overhead. + assert(estimate == sum + 8L * 1024L) { + "${region.id}: estimate $estimate != sum $sum + 8KB" + } + } + } + + @Test + fun downloadAndSlice_works_for_coxs_bazar_against_NE() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + val region = ngoRegions.first { it.id == "coxs-bazar" } + + // Read source header (parse from local file) + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val sourceHeader = PmtilesHeader.parse( + java.nio.ByteBuffer.wrap(headerBytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) + ) + + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assert(tiles.isNotEmpty()) { "Cox's Bazar should have at least 1 tile" } + + val tmp = File.createTempFile("coxs-bazar-", ".pmtiles") + tmp.deleteOnExit() + + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = sourceUrl, + sourceHeader = sourceHeader, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + + assert(tmp.exists() && tmp.length() > PmtilesV3.HEADER_BYTES) { + "sliced file should be created and non-trivial" + } + + // Re-slice the output — should yield the same tile count. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = region.bbox, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assert(resliced.size == tiles.size) { + "re-sliced count ${resliced.size} != original ${tiles.size}" + } + } + + /** + * Slippy-map expected count: `(xMax-xMin+1) × (yMax-yMin+1)` per zoom. + * Independent of any PMTiles content — pure math from the spec. + */ + private fun expectedSlippyTileCount(bbox: Bbox, zoomMin: Int, zoomMax: Int): Int { + var total = 0 + for (z in zoomMin..zoomMax) { + val n = 2.0.pow(z).toInt() + val xMin = Math.floor((bbox.west + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val xMax = Math.floor((bbox.east + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) + val latRadN = Math.toRadians(bbox.north.coerceIn(-85.0511, 85.0511)) + val latRadS = Math.toRadians(bbox.south.coerceIn(-85.0511, 85.0511)) + val yMin = Math.floor((1 - Math.log(Math.tan(latRadN) + 1.0 / Math.cos(latRadN)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + val yMax = Math.floor((1 - Math.log(Math.tan(latRadS) + 1.0 / Math.cos(latRadS)) / Math.PI) / 2.0 * n) + .toInt().coerceIn(0, n - 1) + total += (xMax - xMin + 1) * (yMax - yMin + 1) + } + return total + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt new file mode 100644 index 0000000..edfb1c2 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt @@ -0,0 +1,91 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Manually-invoked test that slices a real region from + * `iiab.switnet.org` to a file on disk. Used to seed a real + * `tiles.pmtiles` for end-to-end M8+M9 validation of the apply path. + * + * Opt in via: + * `./gradlew :testDebugUnitTest \ + * --tests 'org.appdevforall.maps.slicer.OnDemandRegionSlicingTest' \ + * -DrunOnlineSlicerTests=true \ + * -DsliceRegionId=coxs-bazar-test \ + * -DsliceOutputDir=/tmp/m9-validate/sliced \ + * -DsliceZoomMax=12` + * + * Produces `//tiles.pmtiles`. The bbox is fixed to + * Cox's Bazar's hot-zone (z 6-12 by default for fast iteration). + */ +class OnDemandRegionSlicingTest { + + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" + + @Test + fun slice_cox_bazar_to_disk() = runBlocking { + assumeTrue( + "Online slicer tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true", + ) + val regionId = System.getProperty("sliceRegionId") ?: "coxs-bazar-real" + val outputDir = File( + System.getProperty("sliceOutputDir") ?: "/tmp/m9-validate/sliced", + ).apply { mkdirs() } + val zoomMax = (System.getProperty("sliceZoomMax") ?: "12").toInt() + + val regionDir = File(outputDir, regionId).apply { mkdirs() } + val target = File(regionDir, "tiles.pmtiles") + // Cox's Bazar refugee camps, small bbox. + val bbox = Bbox(21.10, 92.10, 21.30, 92.25) + val zoomMin = 6 + + println("Slicing $regionId from $osmVectorUrl → $target (z $zoomMin-$zoomMax)") + // Open a single fetcher; read the header from it ourselves so the + // downloadAndSlice call has the right PmtilesHeader instance. + val fetcher = RangeFetcher.forUrl(osmVectorUrl) + val header = fetcher.use { f -> + val headerBytes = f.readRange(0L, PmtilesV3.HEADER_BYTES) + PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + } + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + ).getOrThrow() + println("Total tiles to fetch: ${tiles.size}") + + val result = PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = osmVectorUrl, + sourceHeader = header, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + targetFile = target, + onProgress = { downloaded, total -> + val pct = if (total > 0) downloaded * 100 / total else 0 + if (downloaded == 0L || downloaded == total) { + println(" progress: $downloaded / $total ($pct%)") + } + }, + ) + result.getOrThrow() + println("DONE: ${target.absolutePath} (${target.length()} bytes)") + // Don't assert size — Cox's Bazar at z 6-12 is much smaller than full + // 6-14 catalog estimate. + assert(target.length() > 1024L) { + "Expected sliced file >1 KB; got ${target.length()}" + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt new file mode 100644 index 0000000..88c863c --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt @@ -0,0 +1,130 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.system.measureTimeMillis + +/** + * End-to-end throughput verification against the real IIAB OSM PMTiles archive. + * + * Runs an actual `downloadAndSlice` of a small Cox's Bazar slice (z6-z12 ≈ + * a few MB) and asserts: + * - the resulting file is a valid PMTiles v3 archive, + * - re-slicing it returns the same tile count (round-trip correctness), + * - throughput is at least 1 MB/s (Bryan's "few MB/s" target floor). + * + * **Opt-in**: only runs when `-DrunOnlineSlicerTests=true` is passed. CI + * shouldn't hit iiab.switnet.org by default. + * + * If throughput is below the floor: + * - check the network path to iiab.switnet.org (a slow public internet hop + * genuinely caps us); + * - re-check the coalescing constants in PmtilesRegionSlicer + * (COALESCE_GAP_BYTES, MAX_CHUNK_BYTES, PARALLEL_FETCHES); + * - confirm OkHttpClient's dispatcher/connectionPool tuning in + * RegionDownloader hasn't regressed. + */ +class OnlineDownloadThroughputTest { + + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" + + @Test + fun coxs_bazar_z6_z14_downloads_at_or_above_throughput_floor() = runBlocking { + assumeTrue( + "Online tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true" + ) + + // Cox's Bazar z6-z14 is the smallest "realistic" region + // (catalog estimate 100-150 MB). Big enough for + // bandwidth to dominate the fixed HTTP-setup cost so the throughput + // number is meaningful. + val bbox = Bbox(21.10, 92.10, 21.30, 92.25) + val zMin = 6 + val zMax = 14 + + // Stage 1: discover tiles + read source header. Not on the throughput + // clock — this is a fixed-cost setup. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + ).getOrThrow() + assertTrue("expected tiles for Cox's Bazar z$zMin-z$zMax", tiles.isNotEmpty()) + val totalTileBytes = tiles.sumOf { it.byteLength } + // Diagnostic: how many fetch chunks does coalescing produce? Big tile + // count + small chunk count means coalescing collapsed a lot of + // adjacent ranges into single requests, which is the win we want. + val uniqueBlobs = tiles + .map { it.byteOffset to it.byteLength } + .distinct() + .sortedBy { it.first } + val chunks = PmtilesRegionSlicer.coalesceBlobs( + uniqueBlobs, + PmtilesRegionSlicer.COALESCE_GAP_BYTES, + ).flatMap { PmtilesRegionSlicer.splitOversizedChunk(it, PmtilesRegionSlicer.MAX_CHUNK_BYTES) } + println("[throughput-test] Cox's Bazar z$zMin-z$zMax: ${tiles.size} tiles, " + + "${uniqueBlobs.size} unique blobs, ${chunks.size} fetch chunks, " + + "${totalTileBytes / 1024L} KB total") + + // Fetch the source header once (downloadAndSlice needs it as input). + val headerBytes = run { + val fetcher = RangeFetcher.forUrl(osmVectorUrl) + try { fetcher.readRange(0L, PmtilesV3.HEADER_BYTES) } finally { fetcher.close() } + } + val sourceHeader = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + + // Stage 2: timed download. + val tmp = File.createTempFile("throughput-test-", ".pmtiles") + tmp.deleteOnExit() + val downloadMs = measureTimeMillis { + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = osmVectorUrl, + sourceHeader = sourceHeader, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + } + val slicedBytes = tmp.length() + val throughputMbPerSec = (slicedBytes.toDouble() / (1024.0 * 1024.0)) / + (downloadMs.toDouble() / 1000.0) + + println("[throughput-test] downloaded ${slicedBytes / 1024L} KB in ${downloadMs}ms = %.2f MB/s" + .format(throughputMbPerSec)) + + // Correctness: round-trip the sliced file. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + ).getOrThrow() + assertTrue( + "round-trip tile count: sliced ${tiles.size}, resliced ${resliced.size}", + resliced.size == tiles.size, + ) + + // Throughput floor: 1 MB/s. Bryan's target is "few MB/s"; 1 MB/s is + // the floor we should easily clear on any reasonable internet path + // to iiab.switnet.org once the parallel slicer is in place. + assertTrue( + "throughput %.2f MB/s is below 1 MB/s floor for Cox's Bazar z6-z12" + .format(throughputMbPerSec), + throughputMbPerSec >= 1.0, + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt new file mode 100644 index 0000000..c730dc5 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt @@ -0,0 +1,105 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import kotlin.system.measureTimeMillis + +/** + * Validates the slicer against the **real** OSM PMTiles archive at + * `https://iiab.switnet.org/maps/2/`. Compares the slicer's byte-sum + * estimate against the per-region catalog estimates. + * + * **Opt-in**: this test only runs when invoked with + * `-DrunOnlineSlicerTests=true` + * + * Reason: the test hits the public iiab.switnet.org server with several + * range requests per region, which: + * 1. Requires network connectivity (not always available in CI), + * 2. Costs ~100 KB-1 MB per region in directory bytes, + * 3. Could rate-limit if hammered. + * + * For Cox's Bazar (smallest region) the slicer should return ~5-30 MB + * of tile bytes at z 6-14. Kathmandu (largest) ~180-280 MB. Catalog + * estimates have a documented ±50% tolerance. + */ +class OnlineNgoRegionValidationTest { + + /** + * Public IIAB OSM archive (OpenStreetMap / OpenMapTiles vector). + * + * 2026-04-01 = fallback date used by RegionDownloader; if iiab.switnet.org + * has rotated to a newer dated file, this test will return 404 and we'll + * need to refresh the constant. + */ + private val osmVectorUrl = + "https://iiab.switnet.org/maps/2/openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" + + /** Catalog estimates (low-MB, high-MB), z0-z14 vector. */ + private val ngoRegions = listOf( + OnlineRegion("coxs-bazar", Bbox(21.10, 92.10, 21.30, 92.25), 100, 150), + OnlineRegion("kakuma", Bbox(3.65, 34.80, 3.85, 35.00), 120, 180), + OnlineRegion("goma", Bbox(-1.80, 28.85, -1.50, 29.30), 250, 350), + OnlineRegion("port-au-prince",Bbox(18.45, -72.45, 18.65, -72.20), 200, 300), + OnlineRegion("kathmandu", Bbox(27.55, 85.20, 27.85, 85.55), 180, 280), + OnlineRegion("maiduguri", Bbox(11.75, 13.05, 11.95, 13.30), 150, 250), + ) + + private data class OnlineRegion( + val id: String, + val bbox: Bbox, + val expectedLowMb: Int, + val expectedHighMb: Int, + ) + + @Test + fun slicer_estimates_match_catalog_for_all_six_regions() = runBlocking { + assumeTrue( + "Online slicer tests are opt-in. Pass -DrunOnlineSlicerTests=true to enable.", + System.getProperty("runOnlineSlicerTests") == "true" + ) + + println() + println("=== Online NGO region slicer validation (iiab.switnet.org OSM) ===") + println("URL: $osmVectorUrl") + println("%-18s %10s %12s %16s %s".format("region", "tiles", "est_MB", "catalog_MB", "verdict")) + println("-".repeat(80)) + + for (region in ngoRegions) { + val tiles: List + val latencyMs = measureTimeMillis { + tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = osmVectorUrl, + bbox = region.bbox, + zoomMin = 6, + zoomMax = 14, + ).getOrThrow() + } + val estimateBytes = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val estimateMb = estimateBytes / (1024.0 * 1024.0) + + // ±50% tolerance per the plan ("Flag deltas > 50% in either + // direction"). Catalog has a low-high range already; we widen by + // 50% on each side as the merge of catalog spread and slicer + // estimation error. + val lowBound = region.expectedLowMb * 0.5 + val highBound = region.expectedHighMb * 1.5 + val withinTolerance = estimateMb in lowBound..highBound + val verdict = if (withinTolerance) "OK" else "OUT-OF-BAND" + + println( + "%-18s %10d %12.1f %4d-%-4d MB %s (%dms)".format( + region.id, + tiles.size, + estimateMb, + region.expectedLowMb, + region.expectedHighMb, + verdict, + latencyMs, + ) + ) + } + println("-".repeat(80)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt new file mode 100644 index 0000000..364a5d3 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt @@ -0,0 +1,149 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [PmtilesDirectory] (parse/serialize/compress) and + * [DirectoryEntry.isLeafPointer]. Exercises the offset-delta branches + * (rawOff == 0 at i==0, rawOff == 0 at i>0, and the explicit-offset arm), + * the empty-directory short-circuit, the serialize sort-order guard, and + * the compression `when` arms including the unsupported-codec `else`. + */ +class PmtilesDirectoryCoverageTest { + + private fun rawBuf(bytes: ByteArray): ByteBuffer = + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + + @Test + fun parse_empty_directory_returns_empty_list() { + val out = ByteArrayOutputStream() + Varint.encode(0L, out) // n = 0 + assertTrue(PmtilesDirectory.parse(rawBuf(out.toByteArray())).isEmpty()) + } + + @Test + fun serialize_then_parse_roundtrips_contiguous_entries() { + // Two contiguous entries: entry[1].offset == entry[0].offset + entry[0].length, + // which serialize encodes as the implicit "0" offset, and parse decodes back + // through the `rawOff == 0L && i > 0` arm. + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 200L, offset = 100L), + ) + val bytes = PmtilesDirectory.serialize(entries) + val parsed = PmtilesDirectory.parse(rawBuf(bytes)) + assertEquals(entries, parsed) + } + + @Test + fun serialize_then_parse_roundtrips_noncontiguous_entries() { + // entry[1].offset != entry[0].offset + length -> explicit (offset+1) encoding, + // exercising the `else -> rawOff - 1L` parse arm and the serialize else arm. + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 7L, runLength = 1L, length = 50L, offset = 500L), + ) + val bytes = PmtilesDirectory.serialize(entries) + val parsed = PmtilesDirectory.parse(rawBuf(bytes)) + assertEquals(entries, parsed) + } + + @Test + fun parse_first_entry_raw_zero_offset_decodes_to_zero() { + // Hand-built single-entry directory whose raw offset varint is literally 0 + // at i == 0 -> exercises the parse `rawOff == 0L -> 0L` (i==0) arm directly, + // which serialize never emits (it always writes offset+1 for the first entry). + val out = ByteArrayOutputStream() + Varint.encode(1L, out) // n = 1 + Varint.encode(3L, out) // ids[0] delta = 3 -> tileId 3 + Varint.encode(1L, out) // runs[0] = 1 + Varint.encode(10L, out) // lens[0] = 10 + Varint.encode(0L, out) // raw offset = 0 at i==0 + val parsed = PmtilesDirectory.parse(rawBuf(out.toByteArray())) + assertEquals(1, parsed.size) + assertEquals(3L, parsed.single().tileId) + assertEquals(0L, parsed.single().offset) + } + + @Test + fun serialize_rejects_unsorted_entries() { + val entries = listOf( + DirectoryEntry(tileId = 10L, runLength = 1L, length = 10L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 10L, offset = 10L), + ) + assertThrows(IllegalArgumentException::class.java) { + PmtilesDirectory.serialize(entries) + } + } + + @Test + fun maybeCompress_none_is_identity() { + val raw = byteArrayOf(1, 2, 3, 4) + assertEquals(raw.toList(), PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_NONE).toList()) + } + + @Test + fun maybeCompress_then_decompress_gzip_roundtrips() { + val raw = ByteArray(256) { (it % 7).toByte() } + val gz = PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_GZIP) + val back = PmtilesDirectory.maybeDecompress(gz, PmtilesV3.COMPRESSION_GZIP) + assertEquals(raw.toList(), back.toList()) + } + + @Test + fun maybeDecompress_none_is_identity() { + val raw = byteArrayOf(9, 8, 7) + assertEquals(raw.toList(), PmtilesDirectory.maybeDecompress(raw, PmtilesV3.COMPRESSION_NONE).toList()) + } + + @Test + fun maybeCompress_rejects_unsupported_codec() { + assertThrows(IllegalStateException::class.java) { + PmtilesDirectory.maybeCompress(byteArrayOf(1), PmtilesV3.COMPRESSION_BROTLI) + } + } + + @Test + fun maybeDecompress_rejects_unsupported_codec() { + assertThrows(IllegalStateException::class.java) { + PmtilesDirectory.maybeDecompress(byteArrayOf(1), PmtilesV3.COMPRESSION_ZSTD) + } + } + + @Test + fun parseCompressed_handles_gzip_blob() { + val entries = listOf( + DirectoryEntry(tileId = 0L, runLength = 1L, length = 100L, offset = 0L), + DirectoryEntry(tileId = 5L, runLength = 1L, length = 200L, offset = 100L), + ) + val raw = PmtilesDirectory.serialize(entries) + val gz = PmtilesDirectory.maybeCompress(raw, PmtilesV3.COMPRESSION_GZIP) + assertEquals(entries, PmtilesDirectory.parseCompressed(gz, PmtilesV3.COMPRESSION_GZIP)) + } + + @Test + fun parseCompressed_handles_uncompressed_blob() { + val entries = listOf( + DirectoryEntry(tileId = 1L, runLength = 1L, length = 10L, offset = 0L), + ) + val raw = PmtilesDirectory.serialize(entries) + assertEquals(entries, PmtilesDirectory.parseCompressed(raw, PmtilesV3.COMPRESSION_NONE)) + } + + @Test + fun directoryEntry_isLeafPointer_true_when_runLength_zero() { + assertTrue(DirectoryEntry(tileId = 0L, runLength = 0L, length = 0L, offset = 0L).isLeafPointer()) + } + + @Test + fun directoryEntry_isLeafPointer_false_when_runLength_nonzero() { + assertFalse(DirectoryEntry(tileId = 0L, runLength = 1L, length = 0L, offset = 0L).isLeafPointer()) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt new file mode 100644 index 0000000..263160d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt @@ -0,0 +1,184 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [PmtilesHeader] and its companion `parse`: + * the `require` guards (too-small buffer, bad magic, bad version) plus the + * accessor branches (`isClustered` true/false, lon/lat conversions). Builds + * 127-byte header buffers by hand rather than relying on the bundled archive. + */ +class PmtilesHeaderCoverageTest { + + /** Construct a synthetic, well-formed 127-byte v3 header buffer. */ + private fun headerBytes( + version: Byte = PmtilesV3.VERSION, + magic: ByteArray = PmtilesV3.MAGIC, + clustered: Byte = 1, + minLonE7: Int = -10_000_000, + minLatE7: Int = 5_000_000, + maxLonE7: Int = 20_000_000, + maxLatE7: Int = 30_000_000, + ): ByteBuffer { + val buf = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(magic) + buf.put(version) + buf.putLong(127L) // rootDirOffset + buf.putLong(10L) // rootDirBytes + buf.putLong(200L) // jsonMetadataOffset + buf.putLong(20L) // jsonMetadataBytes + buf.putLong(300L) // leafDirsOffset + buf.putLong(30L) // leafDirsBytes + buf.putLong(400L) // tileDataOffset + buf.putLong(40L) // tileDataBytes + buf.putLong(50L) // addressedTilesCount + buf.putLong(60L) // tileEntriesCount + buf.putLong(70L) // tileContentsCount + buf.put(clustered) + buf.put(PmtilesV3.COMPRESSION_GZIP) // internalCompression + buf.put(PmtilesV3.COMPRESSION_NONE) // tileCompression + buf.put(PmtilesV3.TYPE_MVT) // tileType + buf.put(0) // minZoom + buf.put(4) // maxZoom + buf.putInt(minLonE7) + buf.putInt(minLatE7) + buf.putInt(maxLonE7) + buf.putInt(maxLatE7) + buf.put(2) // centerZoom + buf.putInt(1_000_000) // centerLonE7 + buf.putInt(2_000_000) // centerLatE7 + require(buf.position() == PmtilesV3.HEADER_BYTES) + buf.flip() + return buf + } + + @Test + fun parses_well_formed_header_and_exposes_fields() { + val header = PmtilesHeader.parse(headerBytes()) + assertEquals(PmtilesV3.VERSION, header.version) + assertEquals(127L, header.rootDirOffset) + assertEquals(400L, header.tileDataOffset) + assertEquals(PmtilesV3.TYPE_MVT, header.tileType) + } + + @Test + fun isClustered_true_when_flag_is_one() { + assertTrue(PmtilesHeader.parse(headerBytes(clustered = 1)).isClustered()) + } + + @Test + fun isClustered_false_when_flag_is_zero() { + assertFalse(PmtilesHeader.parse(headerBytes(clustered = 0)).isClustered()) + } + + @Test + fun lon_lat_conversions_divide_by_1e7() { + val header = PmtilesHeader.parse( + headerBytes( + minLonE7 = -10_000_000, + minLatE7 = 5_000_000, + maxLonE7 = 20_000_000, + maxLatE7 = 30_000_000, + ) + ) + assertEquals(-1.0, header.minLon(), 1e-9) + assertEquals(0.5, header.minLat(), 1e-9) + assertEquals(2.0, header.maxLon(), 1e-9) + assertEquals(3.0, header.maxLat(), 1e-9) + } + + @Test + fun parse_rejects_buffer_too_small() { + val tiny = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES - 1).order(ByteOrder.LITTLE_ENDIAN) + assertThrows(IllegalArgumentException::class.java) { PmtilesHeader.parse(tiny) } + } + + @Test + fun parse_rejects_bad_magic() { + val badMagic = byteArrayOf( + 'X'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), + 'i'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(), 's'.code.toByte(), + ) + assertThrows(IllegalArgumentException::class.java) { + PmtilesHeader.parse(headerBytes(magic = badMagic)) + } + } + + @Test + fun parse_rejects_unsupported_version() { + assertThrows(IllegalArgumentException::class.java) { + PmtilesHeader.parse(headerBytes(version = 2)) + } + } + + @Test + fun toByteArray_roundtrips_through_parse() { + val original = PmtilesHeader.parse(headerBytes()) + val reparsed = PmtilesHeader.parse( + ByteBuffer.wrap(original.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(original, reparsed) + } + + /** + * Build a header buffer with explicit tile-type and internal/tile compression + * bytes so the decode of those fields is exercised for non-default values + * (the default fixture always uses WEBP-less MVT + gzip/none). + */ + private fun headerBytesWithCodes( + internalCompression: Byte, + tileCompression: Byte, + tileType: Byte, + ): ByteBuffer { + val buf = ByteBuffer.allocate(PmtilesV3.HEADER_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(PmtilesV3.MAGIC) + buf.put(PmtilesV3.VERSION) + repeat(11) { buf.putLong(0L) } // the 11 long fields — values irrelevant here + buf.put(1) // clustered + buf.put(internalCompression) + buf.put(tileCompression) + buf.put(tileType) + buf.put(0) // minZoom + buf.put(4) // maxZoom + buf.putInt(0) // minLonE7 + buf.putInt(0) // minLatE7 + buf.putInt(0) // maxLonE7 + buf.putInt(0) // maxLatE7 + buf.put(0) // centerZoom + buf.putInt(0) // centerLonE7 + buf.putInt(0) // centerLatE7 + require(buf.position() == PmtilesV3.HEADER_BYTES) + buf.flip() + return buf + } + + @Test + fun parse_decodes_png_tiles_with_brotli_and_none_codes() { + // A non-MVT tile type plus brotli internal-compression / none + // tile-compression — distinct byte values from the WEBP-gzip default, + // confirming the per-byte field decode (offsets 97/98/99) is correct. + val header = PmtilesHeader.parse( + headerBytesWithCodes( + internalCompression = PmtilesV3.COMPRESSION_BROTLI, + tileCompression = PmtilesV3.COMPRESSION_NONE, + tileType = PmtilesV3.TYPE_PNG, + ) + ) + assertEquals(PmtilesV3.TYPE_PNG, header.tileType) + assertEquals(PmtilesV3.COMPRESSION_BROTLI, header.internalCompression) + assertEquals(PmtilesV3.COMPRESSION_NONE, header.tileCompression) + // These three bytes survive a serialize round-trip unchanged. + val reparsed = PmtilesHeader.parse( + ByteBuffer.wrap(header.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(PmtilesV3.TYPE_PNG, reparsed.tileType) + assertEquals(PmtilesV3.COMPRESSION_BROTLI, reparsed.internalCompression) + assertEquals(PmtilesV3.COMPRESSION_NONE, reparsed.tileCompression) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt new file mode 100644 index 0000000..5d9c265 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt @@ -0,0 +1,47 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class PmtilesHeaderTest { + + private val bundledNe: File = File( + // Path used at test-time — the bundled NE archive ships under + // maps-plugin/src/main/assets/maps/. Tests run from the maps-plugin + // module root. + "src/main/assets/maps/natural-earth-z0-z4.pmtiles" + ) + + @Test + fun parses_bundled_natural_earth_header() { + assertTrue("Bundled NE PMTiles missing at ${bundledNe.absolutePath}", bundledNe.exists()) + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val header = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("version", 3.toByte(), header.version) + assertEquals("root_dir_offset", 127L, header.rootDirOffset) + // Tile type 4 = WEBP per the bundled NE archive. + assertEquals("tile type", PmtilesV3.TYPE_WEBP, header.tileType) + // Internal compression for directories — gzip per planetiler defaults. + assertEquals("internal_compression", PmtilesV3.COMPRESSION_GZIP, header.internalCompression) + assertEquals("min_zoom", 0.toByte(), header.minZoom) + assertEquals("max_zoom", 4.toByte(), header.maxZoom) + } + + @Test + fun roundtrips_serialize_then_parse() { + assertTrue(bundledNe.exists()) + val original = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val header = PmtilesHeader.parse( + ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN) + ) + val reserialized = header.toByteArray() + assertArrayEquals(original, reserialized) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt new file mode 100644 index 0000000..d82a5b3 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt @@ -0,0 +1,143 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the coalesce + split helpers that drive the parallel + * region-download path. Keep these pure-Kotlin so they run without a JVM + * test fixture and stay fast. + * + * Each blob is `(offset, length)`. Tests assert that: + * 1. The coalesced chunks cover exactly the same byte ranges as the input + * blobs (no over-fetching beyond the requested gap budget). + * 2. Every input blob still appears exactly once in some chunk's `blobs`. + * 3. Chunks honor the `maxBytes` cap after splitting. + */ +class PmtilesRegionSlicerCoalesceTest { + + @Test + fun coalesceBlobs_empty_input_returns_empty() { + assertEquals(emptyList(), + PmtilesRegionSlicer.coalesceBlobs(emptyList(), 64)) + } + + @Test + fun coalesceBlobs_single_blob_returns_single_chunk() { + val out = PmtilesRegionSlicer.coalesceBlobs(listOf(100L to 50L), 64) + assertEquals(1, out.size) + assertEquals(100L, out[0].offset) + assertEquals(50L, out[0].length) + assertEquals(listOf(100L to 50L), out[0].blobs) + } + + @Test + fun coalesceBlobs_adjacent_blobs_with_zero_gap_merge() { + // [0..100) [100..200) — zero gap, must merge into [0..200) holding both. + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 100L to 100L), + 64, + ) + assertEquals(1, out.size) + assertEquals(0L, out[0].offset) + assertEquals(200L, out[0].length) + assertEquals(2, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_small_gap_within_threshold_merges() { + // [0..100) [150..250) — gap=50 ≤ maxGap=64, must merge into [0..250). + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 150L to 100L), + 64, + ) + assertEquals(1, out.size) + assertEquals(0L, out[0].offset) + assertEquals(250L, out[0].length) + assertEquals(2, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_gap_above_threshold_splits() { + // [0..100) [200..300) — gap=100 > maxGap=64, must produce 2 chunks. + val out = PmtilesRegionSlicer.coalesceBlobs( + listOf(0L to 100L, 200L to 100L), + 64, + ) + assertEquals(2, out.size) + assertEquals(0L, out[0].offset) + assertEquals(100L, out[0].length) + assertEquals(200L, out[1].offset) + assertEquals(100L, out[1].length) + } + + @Test + fun coalesceBlobs_real_world_clustered_pattern_merges_aggressively() { + // A typical PMTiles cluster: ~20 tiles, each ~5 KB, packed back-to-back + // with occasional 1-2 KB gaps from dedup'd shared blobs. + val blobs = buildList { + var off = 1_000_000L + repeat(20) { + add(off to 5000L) + off += 5000L + 1000L // 1 KB gap between each + } + } + val out = PmtilesRegionSlicer.coalesceBlobs(blobs, 64L * 1024L) + // Gap (1 KB) well below 64 KB threshold — all should merge. + assertEquals("all 20 blobs should merge into a single chunk", 1, out.size) + assertEquals(20, out[0].blobs.size) + } + + @Test + fun coalesceBlobs_preserves_total_blob_byte_count() { + val blobs = listOf( + 10L to 100L, 200L to 50L, // gap=90, merges (≤64? no) + 10_000L to 200L, 10_300L to 100L, // gap=100, merges? no with 64KB + ) + // With maxGap=200: first two merge (gap=90), third+fourth merge (gap=100), + // middle gap between groups = 9750, won't merge. + val out = PmtilesRegionSlicer.coalesceBlobs(blobs, 200L) + val totalBlobBytes = blobs.sumOf { it.second } + val totalOutBytes = out.sumOf { chunk -> chunk.blobs.sumOf { it.second } } + assertEquals("blob-byte sum preserved across coalescing", totalBlobBytes, totalOutBytes) + val totalOutBlobs = out.sumOf { it.blobs.size } + assertEquals("each blob appears exactly once", blobs.size, totalOutBlobs) + } + + @Test + fun splitOversizedChunk_no_op_when_within_bound() { + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 1000L, listOf(0L to 1000L)) + val out = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4096L) + assertEquals(1, out.size) + assertEquals(chunk, out[0]) + } + + @Test + fun splitOversizedChunk_splits_large_chunk_into_bounded_parts() { + // 10 blobs of 1 MB each, packed back-to-back. Total = 10 MB. + // maxBytes = 4 MB → must produce ≥3 parts, each ≤ 4 MB. + val blobs = (0 until 10).map { (it * 1_000_000L) to 1_000_000L } + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 10_000_000L, blobs) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("at least 3 parts for 10MB/4MB cap", parts.size >= 3) + for (p in parts) { + assertTrue("part length ${p.length} must be ≤ maxBytes", p.length <= 4_000_000L) + } + // Every blob should appear in exactly one part. + val allBlobsInParts = parts.flatMap { it.blobs } + assertEquals(blobs.size, allBlobsInParts.size) + assertEquals(blobs.toSet(), allBlobsInParts.toSet()) + } + + @Test + fun splitOversizedChunk_keeps_oversized_single_blob_intact() { + // One 10 MB blob (atypical but possible). Must NOT be silently dropped + // even though it exceeds maxBytes — return it as its own part. + val blob = 0L to 10_000_000L + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 10_000_000L, listOf(blob)) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertEquals(1, parts.size) + assertEquals(blob, parts[0].blobs[0]) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt new file mode 100644 index 0000000..67071c1 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt @@ -0,0 +1,642 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch-coverage tests for [PmtilesRegionSlicer]'s orchestrator paths that the + * happy-path slicer tests don't reach: + * + * - `tilesInRegion` zoom-clip-to-empty (`zMin > zMax`) and `runCatching` failure. + * - `tilesInRegionImpl` `require(...)` precondition branches. + * - `tileIdRangesForBbox` empty / single-tile / multi-zoom branches. + * - `splitOversizedChunk` mid-stream "close out a non-empty part before a blob + * that would overflow" branch. + * - `downloadAndSlice` empty-tile-list precondition + dedup/run-length path. + * - `walkEntries` leaf-pointer arm — leaf fetch + recursion + per-leaf merge + * AND the non-overlapping-leaf `continue` — via a synthetic TWO-level archive + * (the bundled NE archive has `leafDirsBytes==0`, so its directory is flat and + * those branches are otherwise unreachable offline). + * + * Everything runs wholly offline against the bundled Natural Earth z0-z4 archive + * (same source the existing slicer tests load), pure-Kotlin helper inputs, and a + * hand-built leaf-bearing PMTiles fixture written to a temp file. + */ +class PmtilesRegionSlicerCoverageTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + private val tempFiles = mutableListOf() + + @After + fun cleanup() { + tempFiles.forEach { f -> + f.delete() + File(f.parentFile, f.name + ".partial").delete() + } + tempFiles.clear() + } + + private fun sourceUrl(): String = "file://" + bundledNe.absolutePath + + private fun sourceHeader(): PmtilesHeader { + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + return PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + } + + // ---------- tilesInRegion / tilesInRegionImpl branches ---------- + + @Test + fun tilesInRegion_zooms_above_archive_max_clip_to_empty() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + // NE z0-z4 has maxZoom=4. Requesting zoomMin=10..zoomMax=10 means after + // clipping zMin=10, zMax=min(10,4)=4 → zMin > zMax → emptyList branch. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 10, + zoomMax = 10, + ).getOrThrow() + assertTrue("zooms entirely above archive max produce no tiles", tiles.isEmpty()) + } + + @Test + fun tilesInRegion_bad_url_returns_failure_result() = runBlocking { + assumeTrue(bundledNe.exists()) + // A file:// URL pointing at a nonexistent path makes RangeFetcher throw, + // which runCatching captures → Result.isFailure (the tilesInRegion$2 + // failure lambda). + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file:///definitely/not/a/real/path/nope.pmtiles", + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ) + assertTrue("nonexistent source must yield a failure Result", result.isFailure) + } + + @Test + fun tilesInRegionImpl_rejects_zoomMin_out_of_range() = runBlocking { + assumeTrue(bundledNe.exists()) + // zoomMin=21 violates require(zoomMin in 0..20). runCatching wraps the + // IllegalArgumentException into a failure Result. + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 21, + zoomMax = 21, + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun tilesInRegionImpl_rejects_zoomMax_below_zoomMin() = runBlocking { + assumeTrue(bundledNe.exists()) + // zoomMax(0) < zoomMin(3) violates require(zoomMax in zoomMin..20). + val result = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 0, + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun tilesInRegion_single_zoom_in_archive_range_returns_tiles() = runBlocking { + assumeTrue(bundledNe.exists()) + // z2 only — exercises the zMin..zMax single-zoom associateWith path and + // the non-leaf inline tile-collection branch in walkEntries. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 2, + zoomMax = 2, + ).getOrThrow() + assertTrue("z2 worldwide returns tiles", tiles.isNotEmpty()) + assertTrue("all tiles are z2", tiles.all { it.z == 2 }) + } + + @Test + fun tilesInRegion_tiny_bbox_filters_to_small_subset() = runBlocking { + assumeTrue(bundledNe.exists()) + // A tiny bbox at a single point exercises tileIntersectsBbox's filter + // (most tiles continue-skipped) and tileIdRangesForBbox's single-tile + // ranges across z0..z4. + val tiny = Bbox(0.0, 0.0, 0.001, 0.001) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = tiny, + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + // At most one tile per zoom level (z0..z4 = 5). + assertTrue("tiny bbox collects few tiles, got ${tiles.size}", tiles.size in 1..6) + } + + @Test + fun tilesInRegion_wide_thin_latitude_band_rejects_gap_tiles_by_bbox() = runBlocking { + assumeTrue(bundledNe.exists()) + // A full-longitude, narrow-latitude band at z3..z4 is NOT a + // Hilbert-contiguous region: the Hilbert query returns id-ranges that + // weave through OTHER latitude rows, so some candidate tile-ids pass the + // per-zoom id-range pre-filter yet land in rows the band doesn't touch. + // Those candidates reach tileIntersectsBbox and must be rejected by its + // latitude conditions (`tileNorth > bbox.south` / `tileSouth < bbox.north` + // false). Proof the filter ran: every returned tile genuinely intersects + // the band, and the count is a strict subset of the full-world count. + val band = Bbox(south = -1.0, west = -180.0, north = 1.0, east = 180.0) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = band, + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("band intersects the archive → some tiles", tiles.isNotEmpty()) + // Every surviving tile must actually overlap the band in latitude. + for (t in tiles) { + val n = Math.pow(2.0, t.z.toDouble()) + val tileNorth = tileLat(t.y, n) + val tileSouth = tileLat(t.y + 1, n) + assertTrue( + "tile z${t.z}/${t.x}/${t.y} (lat [$tileSouth,$tileNorth]) must overlap band", + tileNorth > band.south && tileSouth < band.north, + ) + } + // A thin band is a strict subset of the whole world at the same zooms. + val whole = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("band drops some tiles vs whole world", tiles.size < whole.size) + } + + @Test + fun tilesInRegion_tall_thin_longitude_band_rejects_gap_tiles_by_bbox() = runBlocking { + assumeTrue(bundledNe.exists()) + // The mirror of the latitude-band case: a full-latitude, narrow-longitude + // strip forces tileIntersectsBbox's LONGITUDE conditions + // (`tileEast > bbox.west` / `tileWest < bbox.east` false) to fire for the + // Hilbert-gap candidates that live in other longitude columns. + val strip = Bbox(south = -84.0, west = -1.0, north = 84.0, east = 1.0) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = strip, + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("strip intersects the archive → some tiles", tiles.isNotEmpty()) + for (t in tiles) { + val n = Math.pow(2.0, t.z.toDouble()) + val tileWest = t.x / n * 360.0 - 180.0 + val tileEast = (t.x + 1) / n * 360.0 - 180.0 + assertTrue( + "tile z${t.z}/${t.x}/${t.y} (lon [$tileWest,$tileEast]) must overlap strip", + tileEast > strip.west && tileWest < strip.east, + ) + } + val whole = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 3, + zoomMax = 4, + ).getOrThrow() + assertTrue("strip drops some tiles vs whole world", tiles.size < whole.size) + } + + /** Slippy-map tile north/south latitude (degrees) for tile row [y] on an n×n grid. */ + private fun tileLat(y: Int, n: Double): Double { + val latRad = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * y / n))) + return Math.toDegrees(latRad) + } + + // ---------- tileIdRangesForBbox branches ---------- + + @Test + fun tileIdRangesForBbox_single_tile_at_z0() { + // z0 has exactly one tile (0,0). Range list is non-empty and covers + // tile-id 0 (base accumulate(0)=0). + val ranges = PmtilesRegionSlicer.tileIdRangesForBbox( + Bbox(-85.0, -180.0, 85.0, 180.0), 0 + ) + assertEquals(1, ranges.size) + assertTrue("z0 range covers tile-id 0", 0L in ranges[0]) + } + + @Test + fun tileIdRangesForBbox_full_world_multi_tile_at_z2() { + // z2 full world: xMin=0..xMax=3, yMin..yMax span the whole grid → the + // Hilbert query returns one or more ranges covering all 16 ids. + val ranges = PmtilesRegionSlicer.tileIdRangesForBbox( + Bbox(-85.0, -180.0, 85.0, 180.0), 2 + ) + assertTrue("z2 full-world produces at least one range", ranges.isNotEmpty()) + val base = Hilbert.accumulate(2) + // Every z2 tile-id (base..base+15) must be inside some returned range. + for (id in base until base + 16) { + assertTrue("tile-id $id must be covered", ranges.any { id in it }) + } + } + + @Test + fun tileIdRangesForBbox_west_edge_clamps_into_grid() { + // West edge of the world at z3 — coerceIn clamps xMin to 0; the result + // is a valid non-empty range list, exercising the coerceIn branches. + val ranges = PmtilesRegionSlicer.tileIdRangesForBbox( + Bbox(-80.0, -180.0, -70.0, -179.0), 3 + ) + assertTrue("west-edge bbox yields ranges", ranges.isNotEmpty()) + } + + // ---------- splitOversizedChunk mid-stream break branch ---------- + + @Test + fun splitOversizedChunk_closes_part_before_overflowing_blob() { + // A small blob followed by a large blob, both inside a chunk that + // exceeds maxBytes. Processing the small blob first leaves partBlobs + // non-empty; the next (large) blob would push blobEnd-currentStart past + // maxBytes, so the loop must `break` and close the part — exercising the + // `partBlobs.isNotEmpty() && blobEnd - currentStart > maxBytes` branch. + val small = 0L to 1_000L + val large = 1_000L to 5_000_000L + val chunk = PmtilesRegionSlicer.FetchChunk( + offset = 0L, + length = 5_001_000L, + blobs = listOf(small, large), + ) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("must split into >= 2 parts", parts.size >= 2) + // The small blob is alone in the first part (the large one was deferred). + assertEquals(listOf(small), parts[0].blobs) + assertEquals(listOf(large), parts[1].blobs) + // Every blob appears exactly once. + val all = parts.flatMap { it.blobs } + assertEquals(setOf(small, large), all.toSet()) + } + + @Test + fun splitOversizedChunk_multiple_small_blobs_pack_then_break() { + // Five 1.5 MB blobs in a 7.5 MB chunk, maxBytes=4 MB. The first part + // packs blobs until adding the next would overflow, then breaks; the + // remainder forms subsequent parts. Exercises both the pack-and-continue + // and the break-on-overflow arms. + val blobs = (0 until 5).map { (it * 1_500_000L) to 1_500_000L } + val chunk = PmtilesRegionSlicer.FetchChunk(0L, 7_500_000L, blobs) + val parts = PmtilesRegionSlicer.splitOversizedChunk(chunk, 4_000_000L) + assertTrue("expected multiple parts", parts.size >= 2) + for (p in parts) { + assertTrue("part ${p.length} within cap (or single oversized blob)", + p.length <= 4_000_000L || p.blobs.size == 1) + } + assertEquals(blobs.toSet(), parts.flatMap { it.blobs }.toSet()) + } + + // ---------- downloadAndSlice branches ---------- + + @Test + fun downloadAndSlice_empty_tiles_fails_precondition() = runBlocking { + assumeTrue(bundledNe.exists()) + val tmp = File.createTempFile("empty-slice-", ".pmtiles").also { tempFiles += it } + val result = PmtilesRegionSlicer.downloadAndSlice( + tiles = emptyList(), + globalPmtilesUrl = sourceUrl(), + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + targetFile = tmp, + onProgress = { _, _ -> }, + ) + assertTrue("empty tile list must fail the require(...)", result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun downloadAndSlice_overwrites_existing_target_file() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = sourceUrl() + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = src, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + ).getOrThrow() + assertTrue(tiles.isNotEmpty()) + + // Pre-create the target so the `if (targetFile.exists()) delete()` branch + // runs. Also leave a stale `.partial` so its delete branch runs too. + val tmp = File.createTempFile("overwrite-", ".pmtiles").also { tempFiles += it } + tmp.writeText("stale contents that must be replaced") + File(tmp.parentFile, tmp.name + ".partial").writeText("stale partial") + + var lastProgress = -1L to -1L + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + targetFile = tmp, + onProgress = { d, t -> lastProgress = d to t }, + ).getOrThrow() + + assertTrue("output rewritten as a real pmtiles file", + tmp.length() > PmtilesV3.HEADER_BYTES.toLong()) + assertNotNull(lastProgress) + assertTrue("final downloaded reached total", lastProgress.first == lastProgress.second) + + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals(3.toByte(), sliced.version) + assertEquals(1.toByte(), sliced.clustered) + } + + @Test + fun downloadAndSlice_single_zoom_run_length_dedup_path() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = sourceUrl() + // z0 single tile — exercises the run-length build loop with a one-tile + // input (runEnd never extends) and the dedup blobLocalOffset path. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = src, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals(1, tiles.size) + + val tmp = File.createTempFile("z0-slice-", ".pmtiles").also { tempFiles += it } + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + targetFile = tmp, + onProgress = { _, _ -> }, + ).getOrThrow() + + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals(tiles.size, resliced.size) + } + + @Test + fun downloadAndSlice_extends_run_for_contiguous_tiles_sharing_one_blob() = runBlocking { + assumeTrue(bundledNe.exists()) + // The run-length EXTEND arm of the directory builder fires only when two + // CONTIGUOUS tile-ids point at the SAME (byteOffset, byteLength) blob. The + // NE archive's natural tiling rarely produces that pair for a real bbox, so + // construct the TileEntry list by hand: two contiguous tile-ids (10, 11) + // both aliasing NE's real tile-0 blob (absOffset 1215, length 30122). The + // blob bytes genuinely exist in the source file, so the chunk fetch reads + // real data; the builder must collapse the two entries into one run-length-2 + // directory entry (a single content blob, dedup preserved). + val src = sourceUrl() + val sharedOffset = 1215L + val sharedLength = 30122L + val tiles = listOf( + TileEntry(z = 2, x = 0, y = 0, tileId = 10L, byteOffset = sharedOffset, byteLength = sharedLength), + TileEntry(z = 2, x = 1, y = 0, tileId = 11L, byteOffset = sharedOffset, byteLength = sharedLength), + ) + + val tmp = File.createTempFile("run-extend-", ".pmtiles").also { tempFiles += it } + var lastProgress = -1L to -1L + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = src, + sourceHeader = sourceHeader(), + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 2, + zoomMax = 2, + targetFile = tmp, + onProgress = { d, t -> lastProgress = d to t }, + ).getOrThrow() + + // Only ONE unique blob → total bytes = the single blob length (dedup), + // not the doubled sum. That's the dedup+run-extend payoff. + assertEquals("dedup collapses both entries to one blob's bytes", + sharedLength, lastProgress.second) + assertEquals("download reached total", lastProgress.first, lastProgress.second) + + // The written header reports two addressed tiles but one tile-content, + // and a single tile-entry (the collapsed run) — proof the run extended. + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("two addressed tiles", 2L, sliced.addressedTilesCount) + assertEquals("collapsed into ONE run entry", 1L, sliced.tileEntriesCount) + assertEquals("one shared content blob", 1L, sliced.tileContentsCount) + } + + // ---------- estimateRegionBytes empty branch ---------- + + @Test + fun estimateRegionBytes_empty_list_is_just_overhead() { + // No tiles → sum is 0, estimate is the fixed 8 KB overhead. + val estimate = PmtilesRegionSlicer.estimateRegionBytes(emptyList()) + assertEquals(8L * 1024L, estimate) + assertFalse(estimate == 0L) + } + + // ---------- walkEntries leaf-pointer branches (synthetic leaf archive) ---------- + + @Test + fun tilesInRegion_walks_leaf_directory_and_skips_non_overlapping_leaf() = runBlocking { + // The bundled NE archive has leafDirsBytes==0, so its root directory holds + // every entry inline — the leaf-pointer arm of walkEntries (the async leaf + // fetch + recursion) is never exercised by any file:// test against it. + // Build a tiny well-formed v3 archive with a TWO-level directory: + // root: [leaf-pointer @tileId 0] [leaf-pointer @tileId 1000] + // leafA (covers ids [0,1000)): one real z0 tile entry + // leafB (covers ids [1000,MAX)): one z-high tile entry, out of our zoom + // Slicing z0..z0 full-world makes leafA OVERLAP (fetched + recursed) and + // leafB NOT overlap (`if (!anyOverlap) continue`), covering both arms plus + // the parallel leaf-fetch coroutineScope and the per-leaf merge loop. + val archive = buildLeafBearingArchive().also { tempFiles += it.file } + + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + archive.file.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + + // Exactly the single z0 tile from leafA; the leafB entry was zoom-excluded + // AND its leaf was never fetched (non-overlap continue). + assertEquals("leaf walk yields the one z0 tile", 1, tiles.size) + val t = tiles.single() + assertEquals(0, t.z) + assertEquals(0L, t.tileId) + // byteOffset = header.tileDataOffset + entry.offset; entry.offset was 0. + assertEquals(archive.tileDataOffset, t.byteOffset) + assertEquals(LEAF_ARCHIVE_TILE_LEN, t.byteLength) + } + + @Test + fun tilesInRegionImpl_directly_on_open_fetcher_walks_leaves() = runBlocking { + // Exercise the internal `tilesInRegionImpl(fetcher, ...)` entry point + // (the "caller already has a RangeFetcher open" overload) against the + // synthetic leaf archive, so its leaf-walk path is covered through the + // fetcher-passing variant too. + val archive = buildLeafBearingArchive().also { tempFiles += it.file } + RangeFetcher.forUrl("file://" + archive.file.absolutePath).use { fetcher -> + val tiles = PmtilesRegionSlicer.tilesInRegionImpl( + fetcher = fetcher, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ) + assertEquals(1, tiles.size) + assertEquals(0L, tiles.single().tileId) + } + } + + // ---------- synthetic leaf-bearing archive builder ---------- + + /** A synthetic archive plus the offsets a test needs to assert against. */ + private class LeafArchive(val file: File, val tileDataOffset: Long) + + /** + * Write a minimal but spec-valid PMTiles v3 archive that uses a real LEAF + * directory level (which the bundled NE archive does not), so the slicer's + * leaf-pointer / leaf-fetch / recursive-walk branches become reachable + * offline via `file://`. Layout: + * ``` + * [0..127) header + * [127..rootEnd) root dir (gzip): 2 leaf pointers + * [rootEnd..metaEnd) json metadata (empty {} — unused by tilesInRegion) + * [metaEnd..tdOff) leaf section: leafA then leafB (each gzip) + * [tdOff..) tile-data placeholder (never read by tilesInRegion) + * ``` + */ + private fun buildLeafBearingArchive(): LeafArchive { + val comp = PmtilesV3.COMPRESSION_GZIP + + // leafA: covers tile-ids [0,1000); holds the single z0 tile (id 0). + val leafA = PmtilesDirectory.maybeCompress( + PmtilesDirectory.serialize( + listOf( + DirectoryEntry( + tileId = 0L, + runLength = 1L, + length = LEAF_ARCHIVE_TILE_LEN, + offset = 0L, + ) + ) + ), + comp, + ) + // leafB: covers tile-ids [1000,MAX); a lone high-zoom tile we never request. + val leafB = PmtilesDirectory.maybeCompress( + PmtilesDirectory.serialize( + listOf( + DirectoryEntry( + tileId = 1000L, + runLength = 1L, + length = 50L, + offset = LEAF_ARCHIVE_TILE_LEN, + ) + ) + ), + comp, + ) + + // root: two leaf pointers (runLength==0). offset is relative to + // leafDirsOffset; lengths are the on-disk (compressed) leaf sizes. + val rootRaw = PmtilesDirectory.serialize( + listOf( + DirectoryEntry(tileId = 0L, runLength = 0L, length = leafA.size.toLong(), offset = 0L), + DirectoryEntry( + tileId = 1000L, + runLength = 0L, + length = leafB.size.toLong(), + offset = leafA.size.toLong(), + ), + ) + ) + val rootComp = PmtilesDirectory.maybeCompress(rootRaw, comp) + + val metadata = PmtilesDirectory.maybeCompress("{}".toByteArray(), comp) + + val headerBytes = PmtilesV3.HEADER_BYTES.toLong() + val rootDirOffset = headerBytes + val rootDirBytes = rootComp.size.toLong() + val metaOffset = rootDirOffset + rootDirBytes + val metaBytes = metadata.size.toLong() + val leafOffset = metaOffset + metaBytes + val leafBytes = (leafA.size + leafB.size).toLong() + val tileDataOffset = leafOffset + leafBytes + // A couple of placeholder tile-data bytes — tilesInRegion never reads them. + val tileDataBytes = LEAF_ARCHIVE_TILE_LEN + 50L + + val header = PmtilesHeader( + version = PmtilesV3.VERSION, + rootDirOffset = rootDirOffset, + rootDirBytes = rootDirBytes, + jsonMetadataOffset = metaOffset, + jsonMetadataBytes = metaBytes, + leafDirsOffset = leafOffset, + leafDirsBytes = leafBytes, + tileDataOffset = tileDataOffset, + tileDataBytes = tileDataBytes, + addressedTilesCount = 2L, + tileEntriesCount = 2L, + tileContentsCount = 2L, + clustered = 1, + internalCompression = comp, + tileCompression = PmtilesV3.COMPRESSION_NONE, + tileType = PmtilesV3.TYPE_MVT, + minZoom = 0, + maxZoom = 14, + minLonE7 = -1_800_000_000, + minLatE7 = -850_000_000, + maxLonE7 = 1_800_000_000, + maxLatE7 = 850_000_000, + centerZoom = 0, + centerLonE7 = 0, + centerLatE7 = 0, + ) + + val file = File.createTempFile("leaf-archive-", ".pmtiles") + java.io.RandomAccessFile(file, "rw").use { raf -> + raf.setLength(tileDataOffset + tileDataBytes) + raf.write(header.toByteArray()) + raf.write(rootComp) + raf.write(metadata) + raf.write(leafA) + raf.write(leafB) + // tile-data region left zero-filled; never read by tilesInRegion. + } + return LeafArchive(file, tileDataOffset) + } + + private companion object { + private const val LEAF_ARCHIVE_TILE_LEN = 100L + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt new file mode 100644 index 0000000..f5ff0d6 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt @@ -0,0 +1,152 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * End-to-end slicer tests against the bundled Natural Earth z0-z4 archive. + * + * NE z0-z4 is small (~7 MB, 341 tiles total) and ships in the plugin assets, + * so the slicer can run wholly offline against a real PMTiles v3 source. We + * use `file://` URLs here; production wires up `https://`. + */ +class PmtilesRegionSlicerTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + + @Test + fun tilesInRegion_full_world_z0_returns_single_tile() = runBlocking { + assumeTrue("bundled NE archive must exist", bundledNe.exists()) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 0, + ).getOrThrow() + assertEquals("full world at z0 has exactly 1 tile", 1, tiles.size) + assertEquals(0, tiles[0].z) + assertEquals(0, tiles[0].x) + assertEquals(0, tiles[0].y) + } + + @Test + fun tilesInRegion_full_world_z0_z4_returns_all_341() = runBlocking { + assumeTrue(bundledNe.exists()) + // z0..z4 covers 1 + 4 + 16 + 64 + 256 = 341 tiles for full world. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + assertTrue( + "expected ~341 tiles for z0..z4 worldwide, got ${tiles.size}", + tiles.size in 300..341 + ) + // Per-zoom sanity. + val byZ = tiles.groupBy { it.z }.mapValues { it.value.size } + assertEquals(1, byZ[0]) + assertEquals(4, byZ[1]) + assertEquals(16, byZ[2]) + // z3 might have ocean tiles missing in the source archive; allow <=64. + assertTrue("z3 count ${byZ[3]} should be <=64", (byZ[3] ?: 0) <= 64) + assertTrue("z4 count ${byZ[4]} should be <=256", (byZ[4] ?: 0) <= 256) + } + + @Test + fun tilesInRegion_small_bbox_returns_subset() = runBlocking { + assumeTrue(bundledNe.exists()) + // Cox's Bazar region — but NE is z0-z4 only, so this returns just a + // single tile at most zoom levels. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(21.10, 92.10, 21.30, 92.25), + zoomMin = 0, + zoomMax = 4, + ).getOrThrow() + // Expect: 1 (z0) + 1 (z1) + 1 (z2) + 1 (z3) + 1 (z4) = 5 tiles. + // The exact number depends on whether the source archive carries every + // ocean tile (Cox's Bazar is coastal — could be missing entries). + assertTrue("expected ~5 tiles for a small bbox z0-z4, got ${tiles.size}", tiles.size in 1..10) + } + + @Test + fun estimateRegionBytes_is_nonzero_and_matches_byte_sum() = runBlocking { + assumeTrue(bundledNe.exists()) + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + bundledNe.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 2, + ).getOrThrow() + val estimate = PmtilesRegionSlicer.estimateRegionBytes(tiles) + val byteSum = tiles.sumOf { it.byteLength } + assertEquals("estimate = tile-byte sum + small overhead", estimate, byteSum + 8 * 1024) + } + + @Test + fun downloadAndSlice_writes_valid_pmtiles_v3() = runBlocking { + assumeTrue(bundledNe.exists()) + val sourceUrl = "file://" + bundledNe.absolutePath + + // Read the source header to pass into downloadAndSlice. + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + val sourceHeader = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + + // Slice z0-z3 worldwide — produces a small archive (~1 MB or less) + // we can write to a temp file and re-parse. + val tiles = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = sourceUrl, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + ).getOrThrow() + assertTrue("expected tiles", tiles.isNotEmpty()) + + val tmp = File.createTempFile("sliced-", ".pmtiles") + tmp.deleteOnExit() + val progress = mutableListOf>() + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = sourceUrl, + sourceHeader = sourceHeader, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + targetFile = tmp, + onProgress = { d, t -> progress += d to t }, + ).getOrThrow() + + assertTrue("sliced file exists", tmp.exists()) + assertTrue("sliced file non-empty", tmp.length() > PmtilesV3.HEADER_BYTES.toLong()) + assertTrue("progress reported", progress.isNotEmpty()) + + // Round-trip: parse the sliced header. + val slicedHeader = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + assertEquals("version", 3.toByte(), slicedHeader.version) + assertEquals("clustered=1", 1.toByte(), slicedHeader.clustered) + assertNotNull(slicedHeader) + + // Re-slice from the sliced file — should yield the same tile count. + val resliced = PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = "file://" + tmp.absolutePath, + bbox = Bbox(-85.0, -180.0, 85.0, 180.0), + zoomMin = 0, + zoomMax = 3, + ).getOrThrow() + assertEquals("sliced and re-sliced tile counts match", tiles.size, resliced.size) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt new file mode 100644 index 0000000..c19c0b0 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt @@ -0,0 +1,41 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Branch coverage for [PmtilesV3]'s `compressionName` and `tileTypeName` + * lookup `when` expressions — every named code plus the `else` (unknown) arm. + */ +class PmtilesV3CoverageTest { + + @Test + fun compressionName_covers_every_known_code() { + assertEquals("none", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_NONE)) + assertEquals("gzip", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_GZIP)) + assertEquals("brotli", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_BROTLI)) + assertEquals("zstd", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_ZSTD)) + } + + @Test + fun compressionName_unknown_falls_through_to_else() { + // COMPRESSION_UNKNOWN (0) and any other byte hit the else arm. + assertEquals("unknown(0)", PmtilesV3.compressionName(PmtilesV3.COMPRESSION_UNKNOWN)) + assertEquals("unknown(9)", PmtilesV3.compressionName(9)) + } + + @Test + fun tileTypeName_covers_every_known_code() { + assertEquals("mvt", PmtilesV3.tileTypeName(PmtilesV3.TYPE_MVT)) + assertEquals("png", PmtilesV3.tileTypeName(PmtilesV3.TYPE_PNG)) + assertEquals("jpeg", PmtilesV3.tileTypeName(PmtilesV3.TYPE_JPEG)) + assertEquals("webp", PmtilesV3.tileTypeName(PmtilesV3.TYPE_WEBP)) + assertEquals("avif", PmtilesV3.tileTypeName(PmtilesV3.TYPE_AVIF)) + } + + @Test + fun tileTypeName_unknown_falls_through_to_else() { + assertEquals("unknown(0)", PmtilesV3.tileTypeName(PmtilesV3.TYPE_UNKNOWN)) + assertEquals("unknown(7)", PmtilesV3.tileTypeName(7)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt new file mode 100644 index 0000000..6952f56 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt @@ -0,0 +1,240 @@ +package org.appdevforall.maps.slicer + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.IOException + +/** + * Covers [RangeFetcher.forUrl] + companion, [HttpRangeFetcher] (via MockWebServer), + * and [FileRangeFetcher] (via a temp file). + * + * The HTTP cases exercise the spec-compliant 206-partial path, the 200-OK + * local-slice fallback, the 200-fallback refusals (unknown-length and + * over-cap), and the non-2xx error. The file cases exercise normal reads and + * the short-read/EOF branch. `HttpRangeByteCache` is cleared per-test so a + * cached value never short-circuits the network call under assertion. + */ +class RangeFetcherCoverageTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + // ---- forUrl scheme routing ------------------------------------------------- + + @Test + fun forUrl_http_and_https_yield_http_fetcher() { + assertTrue(RangeFetcher.forUrl("http://example.com/a.pmtiles") is HttpRangeFetcher) + assertTrue(RangeFetcher.forUrl("https://example.com/a.pmtiles") is HttpRangeFetcher) + } + + @Test + fun forUrl_file_yields_file_fetcher() { + val f = File.createTempFile("range", ".bin").apply { writeBytes(byteArrayOf(1, 2, 3)) } + try { + val fetcher = RangeFetcher.forUrl("file://${f.absolutePath}") + assertTrue(fetcher is FileRangeFetcher) + fetcher.close() + } finally { + f.delete() + } + } + + @Test + fun forUrl_unsupported_scheme_throws() { + val ex = assertThrows(IllegalArgumentException::class.java) { + RangeFetcher.forUrl("ftp://example.com/a.pmtiles") + } + assertTrue(ex.message!!.contains("Unsupported URL scheme")) + } + + @Test + fun defaultClient_is_non_null() { + // Smoke the companion factory so it isn't an uncovered line. + RangeFetcher.defaultClient() + } + + @Test + fun http_fetcher_close_is_the_noop_interface_default() { + // HttpRangeFetcher does not override close(); it inherits the interface's + // `close() = Unit` default. Exercise it (and confirm it's idempotent) so the + // default body is covered and a closed fetcher can still be GC'd safely. + val fetcher = HttpRangeFetcher(server.url("/x").toString(), RangeFetcher.defaultClient()) + fetcher.close() + fetcher.close() + } + + // ---- HttpRangeFetcher: 206 partial ----------------------------------------- + + @Test + fun http_206_returns_range_bytes_and_sends_range_header() { + val payload = ByteArray(8) { (it + 1).toByte() } + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(payload))) + val url = server.url("/a.pmtiles").toString() + + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + val got = fetcher.readRange(4L, 8) + + assertArrayEquals(payload, got) + val recorded = server.takeRequest() + assertEquals("bytes=4-11", recorded.getHeader("Range")) + } + + @Test + fun http_206_second_read_is_served_from_cache() { + val payload = ByteArray(4) { it.toByte() } + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(payload))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + fetcher.readRange(0L, 4) + // Second identical read must hit the cache — no second response enqueued, + // so if it tried the network the request would hang/fail. + val second = fetcher.readRange(0L, 4) + assertArrayEquals(payload, second) + assertEquals(1, server.requestCount) + } + + @Test + fun http_readRange_rejects_negative_offset_and_nonpositive_length() { + val fetcher = HttpRangeFetcher(server.url("/x").toString(), RangeFetcher.defaultClient()) + assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(-1L, 4) } + assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 0) } + } + + // ---- HttpRangeFetcher: 200 fallback ---------------------------------------- + + @Test + fun http_200_with_known_length_slices_locally() { + val whole = ByteArray(16) { it.toByte() } + // Content-Length is set automatically from the body by MockWebServer. + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(whole))) + val url = server.url("/a.pmtiles").toString() + + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + val got = fetcher.readRange(4L, 5) + + assertArrayEquals(whole.copyOfRange(4, 9), got) + } + + @Test + fun http_200_unknown_length_chunked_is_refused() { + // Chunked transfer → contentLength() == -1 → refuse rather than buffer. + server.enqueue( + MockResponse() + .setResponseCode(200) + .setChunkedBody(okio.Buffer().write(ByteArray(8)), 4), + ) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("refusing to buffer")) + } + + @Test + fun http_206_with_wrong_size_body_fails_length_check() { + // A spec-compliant 206 must return exactly `length` bytes. A server that + // returns 206 but a body of the wrong size trips the + // `require(bytes.size == length || ...)` guard. (Both operands of the `||` + // reduce to the same length equality, so a wrong size fails both arms.) + val short = ByteArray(3) // requested 8, server returns only 3 + server.enqueue(MockResponse().setResponseCode(206).setBody(okio.Buffer().write(short))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(0L, 8) } + assertTrue(ex.message!!.contains("Range response returned")) + } + + @Test + fun http_200_over_cap_known_length_is_refused() { + // A 200 whose declared Content-Length exceeds MAX_200_FALLBACK_BYTES + // (32 MiB) must be refused rather than buffered — the `declared > cap` arm + // of the refusal guard. Send a body just over the cap so contentLength() + // is a real, large positive value (distinct from the chunked/-1 case). + val overCap = ByteArray(32 * 1024 * 1024 + 16) + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(overCap))) + val url = server.url("/big.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("refusing to buffer")) + } + + @Test + fun http_200_fallback_cannot_satisfy_out_of_range_request() { + // A 200 (Range-ignored) returns the whole file. If the requested range + // extends past the returned body, the local slice can't be cut — the + // `require(... (offset.toInt() + length) <= whole.size)` guard fails. + val whole = ByteArray(8) { it.toByte() } // 8-byte "whole file", well under the cap + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(whole))) + val url = server.url("/a.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + // Request 10 bytes from offset 4 → 4+10=14 > 8 → unsatisfiable. + val ex = assertThrows(IllegalArgumentException::class.java) { fetcher.readRange(4L, 10) } + assertTrue(ex.message!!.contains("200-OK fallback can't satisfy range")) + } + + @Test + fun http_non_2xx_throws_io_exception() { + server.enqueue(MockResponse().setResponseCode(404)) + val url = server.url("/missing.pmtiles").toString() + val fetcher = HttpRangeFetcher(url, RangeFetcher.defaultClient()) + + val ex = assertThrows(IOException::class.java) { fetcher.readRange(0L, 4) } + assertTrue(ex.message!!.contains("HTTP 404")) + } + + // ---- FileRangeFetcher ------------------------------------------------------ + + @Test + fun file_reads_requested_range() { + val f = File.createTempFile("range", ".bin") + try { + val data = ByteArray(32) { it.toByte() } + f.writeBytes(data) + val fetcher = FileRangeFetcher(f) + val got = fetcher.readRange(8L, 10) + assertArrayEquals(data.copyOfRange(8, 18), got) + fetcher.close() + } finally { + f.delete() + } + } + + @Test + fun file_read_past_eof_throws() { + val f = File.createTempFile("range", ".bin") + try { + f.writeBytes(ByteArray(4)) + val fetcher = FileRangeFetcher(f) + // Request 8 bytes starting at offset 2 of a 4-byte file → EOF mid-read. + val ex = assertThrows(IOException::class.java) { fetcher.readRange(2L, 8) } + assertTrue(ex.message!!.contains("EOF")) + fetcher.close() + } finally { + f.delete() + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt new file mode 100644 index 0000000..478368c --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt @@ -0,0 +1,85 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +/** + * Covers [SliceEstimateCache] — the process-wide LRU of slicer estimates keyed + * by `(sourceUrl, rounded bbox, zoom range)`. Exercises get/put round-trip, the + * 4-decimal bbox rounding that collapses sub-tile jitter into one key, key + * distinctness across url / zoom, and LRU eviction past the 16-entry bound. + * + * Pure in-memory singleton, so each test clears it in @Before / @After. + */ +class SliceEstimateCacheCoverageTest { + + @Before fun reset() = SliceEstimateCache.clear() + + @After fun tearDown() = SliceEstimateCache.clear() + + private fun entry(z: Int = 0) = TileEntry(z, 0, 0, 0L, 0L, 0L) + + @Test + fun get_returns_null_when_absent() { + assertNull(SliceEstimateCache.get("u", Bbox(0.0, 0.0, 1.0, 1.0), 0, 4)) + } + + @Test + fun put_then_get_round_trips_same_list() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + val value = listOf(entry(1), entry(2)) + SliceEstimateCache.put("u", box, 0, 4, value) + assertSame(value, SliceEstimateCache.get("u", box, 0, 4)) + } + + @Test + fun bbox_rounding_collapses_sub_decimal_jitter_to_one_key() { + val value = listOf(entry()) + // Two bboxes differing only below the 4th decimal (~1 m) must hit the same key. + SliceEstimateCache.put("u", Bbox(10.000001, 20.000002, 30.000003, 40.000004), 0, 4, value) + val jittered = Bbox(10.000009, 20.000008, 30.000007, 40.000006) + assertSame(value, SliceEstimateCache.get("u", jittered, 0, 4)) + } + + @Test + fun bbox_difference_above_rounding_is_a_distinct_key() { + SliceEstimateCache.put("u", Bbox(10.0, 20.0, 30.0, 40.0), 0, 4, listOf(entry())) + // A 0.001-degree shift exceeds the 4-decimal rounding → different key. + assertNull(SliceEstimateCache.get("u", Bbox(10.001, 20.0, 30.0, 40.0), 0, 4)) + } + + @Test + fun url_and_zoom_range_are_part_of_the_key() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + SliceEstimateCache.put("a", box, 0, 4, listOf(entry())) + assertNull(SliceEstimateCache.get("b", box, 0, 4)) + assertNull(SliceEstimateCache.get("a", box, 1, 4)) + assertNull(SliceEstimateCache.get("a", box, 0, 5)) + } + + @Test + fun clear_drops_all_entries() { + val box = Bbox(0.0, 0.0, 1.0, 1.0) + SliceEstimateCache.put("u", box, 0, 4, listOf(entry())) + SliceEstimateCache.clear() + assertNull(SliceEstimateCache.get("u", box, 0, 4)) + } + + @Test + fun eviction_drops_eldest_past_sixteen_entries() { + // Insert 17 distinct entries (distinct by latitude); the 16-entry LRU bound + // evicts the eldest (first-inserted) when the 17th lands. + for (i in 0 until 17) { + SliceEstimateCache.put("u", Bbox(i.toDouble(), 0.0, i + 0.5, 1.0), 0, 4, listOf(entry(i))) + } + assertNull(SliceEstimateCache.get("u", Bbox(0.0, 0.0, 0.5, 1.0), 0, 4)) + // The most-recently inserted is still present. + val newest = SliceEstimateCache.get("u", Bbox(16.0, 0.0, 16.5, 1.0), 0, 4) + assertEquals(16, newest?.first()?.z) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt new file mode 100644 index 0000000..0b3477e --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt @@ -0,0 +1,53 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Branch coverage for [Varint] focusing on the guard arms the happy-path + * roundtrip test does not reach: the negative-value encode `require`, the + * ">64 bits" decode `require`, and decoding of a maximal-width varint. + */ +class VarintCoverageTest { + + private fun roundtrip(v: Long): Long { + val out = ByteArrayOutputStream() + Varint.encode(v, out) + return Varint.decode(ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN)) + } + + @Test + fun decodes_single_byte_boundary() { + assertEquals(127L, roundtrip(127L)) + } + + @Test + fun decodes_two_byte_boundary() { + assertEquals(128L, roundtrip(128L)) + } + + @Test + fun decodes_max_long() { + // Long.MAX_VALUE encodes to a 9-byte varint; exercises the multi-byte loop fully. + assertEquals(Long.MAX_VALUE, roundtrip(Long.MAX_VALUE)) + } + + @Test + fun encode_rejects_negative() { + assertThrows(IllegalArgumentException::class.java) { + Varint.encode(-1L, ByteArrayOutputStream()) + } + } + + @Test + fun decode_rejects_overlong_varint() { + // 10 continuation bytes (all MSB set) push shift past 64 -> require fails. + val bytes = ByteArray(10) { 0x80.toByte() } + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertThrows(IllegalArgumentException::class.java) { Varint.decode(buf) } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt new file mode 100644 index 0000000..a199f3e --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt @@ -0,0 +1,44 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class VarintTest { + + @Test + fun encodes_zero_as_single_byte() { + val out = ByteArrayOutputStream() + Varint.encode(0L, out) + assertArrayEquals(byteArrayOf(0), out.toByteArray()) + } + + @Test + fun encodes_127_as_single_byte() { + val out = ByteArrayOutputStream() + Varint.encode(127L, out) + assertArrayEquals(byteArrayOf(0x7f), out.toByteArray()) + } + + @Test + fun encodes_128_as_two_bytes() { + val out = ByteArrayOutputStream() + Varint.encode(128L, out) + assertArrayEquals(byteArrayOf(0x80.toByte(), 0x01), out.toByteArray()) + } + + @Test + fun roundtrips_random_values() { + val samples = listOf(0L, 1L, 127L, 128L, 16_383L, 16_384L, 1_000_000L, Long.MAX_VALUE / 2) + for (v in samples) { + val out = ByteArrayOutputStream() + Varint.encode(v, out) + val buf = ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN) + val decoded = Varint.decode(buf) + assertEquals("roundtrip $v", v, decoded) + } + } +} From c30e5ca29ea4042030d8f67c6d6985b8ee456546 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 05/10] =?UTF-8?q?feat(maps):=20data=20layer=20=E2=80=94=20?= =?UTF-8?q?region=20cache,=20downloader=20(injectable=20IO=20seams),=20ins?= =?UTF-8?q?taller,=20stores=20(+=20unit=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maps/data/ActiveRegionStore.kt | 93 +++++ .../maps/data/FirstRegionAutoActivator.kt | 95 +++++ .../org/appdevforall/maps/data/RegionCache.kt | 280 ++++++++++++++ .../maps/data/RegionDownloader.kt | 356 ++++++++++++++++++ .../appdevforall/maps/data/RegionInstaller.kt | 89 +++++ .../ActiveRegionStoreBranchCoverageTest.kt | 103 +++++ .../maps/data/ActiveRegionStoreTest.kt | 96 +++++ ...stRegionAutoActivatorBranchCoverageTest.kt | 200 ++++++++++ .../maps/data/FirstRegionAutoActivatorTest.kt | 282 ++++++++++++++ .../data/RegionCacheBranchCoverageTest.kt | 239 ++++++++++++ .../appdevforall/maps/data/RegionCacheTest.kt | Bin 0 -> 15918 bytes .../maps/data/RegionDownloaderCoverageTest.kt | 225 +++++++++++ .../maps/data/RegionDownloaderPhaseTest.kt | 33 ++ .../maps/data/RegionDownloaderUrlTest.kt | 94 +++++ .../maps/data/RegionIdValidationTest.kt | 78 ++++ .../maps/data/RegionInfoCoverageTest.kt | 171 +++++++++ .../maps/data/RegionInstallerCoverageTest.kt | 171 +++++++++ .../maps/data/RegionInstallerTest.kt | 52 +++ 18 files changed, 2657 insertions(+) create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/data/ActiveRegionStore.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/data/RegionCache.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/data/RegionInstaller.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt diff --git a/maps/src/main/kotlin/org/appdevforall/maps/data/ActiveRegionStore.kt b/maps/src/main/kotlin/org/appdevforall/maps/data/ActiveRegionStore.kt new file mode 100644 index 0000000..5d7f8de --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/data/ActiveRegionStore.kt @@ -0,0 +1,93 @@ +package org.appdevforall.maps.data + +import android.util.Log +import org.appdevforall.maps.util.AtomicFiles +import java.io.File + +/** + * Read/write the per-project "active region" pointer. + * + * Each project records which cached region is bundled into its builds in + * `//active.txt` — the canonical sentinel. An older + * `region-id.txt` marker is still honoured on read so projects created before + * `active.txt` existed keep working; [clear] removes both so a deactivate toggle + * can't be silently undone by a surviving legacy marker. + */ +internal object ActiveRegionStore { + + /** Canonical per-project sentinel naming the active region. */ + const val ACTIVE_REGION_FILE = "active.txt" + + /** Legacy top-level marker, honoured on read for backward compatibility. */ + const val REGION_MARKER_FILE = "region-id.txt" + + /** Cap on a marker file's read size — defensive bound so a corrupt sentinel + * can't OOM the panel (CodeRabbit resource-bounds theme). */ + private const val MARKER_MAX_BYTES = 1024L + + private const val TAG = "MapsPlugin.ActiveRegion" + + /** + * Read the currently active regionId for the project, or null when none is + * set / the sentinel is missing, oversized, blank, or invalid. + * + * Prefers [ACTIVE_REGION_FILE]; falls back to the legacy [REGION_MARKER_FILE]. + */ + fun read(projectDir: File, mapsSubpath: String): String? { + readMarker(File(projectDir, "$mapsSubpath/$ACTIVE_REGION_FILE"))?.let { return it } + return readMarker(File(projectDir, "$mapsSubpath/$REGION_MARKER_FILE")) + } + + /** + * Write [regionId] into the project's [ACTIVE_REGION_FILE]. No-op (with a + * warning) when [regionId] fails [RegionCache.isValidRegionId], so a bad id + * can never land in the sentinel. + */ + fun write(projectDir: File, mapsSubpath: String, regionId: String) { + Log.i(TAG, "write: enter regionId=$regionId projectDir=$projectDir") + if (!RegionCache.isValidRegionId(regionId)) { + Log.w(TAG, "Refusing to write invalid regionId to active.txt: $regionId") + return + } + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + val dest = File(mapsRoot, ACTIVE_REGION_FILE) + Log.i(TAG, "write: about to write $dest") + runCatching { + AtomicFiles.writeText(dest, regionId) + }.onSuccess { + Log.i(TAG, "write: wrote ok, dest.exists=${dest.exists()} size=${dest.length()}") + }.onFailure { + Log.e(TAG, "write FAILED for dest=$dest", it) + } + } + + /** + * Clear the active pointer. Deletes BOTH [ACTIVE_REGION_FILE] and the legacy + * [REGION_MARKER_FILE]: if the legacy marker survives, [read] falls back to + * it and the project still reports a region as active, so the deactivate + * toggle would look ignored. + */ + fun clear(projectDir: File, mapsSubpath: String) { + listOf(ACTIVE_REGION_FILE, REGION_MARKER_FILE).forEach { name -> + val f = File(projectDir, "$mapsSubpath/$name") + if (f.exists()) { + runCatching { f.delete() } + .onFailure { Log.w(TAG, "clear: couldn't delete $name: ${it.message}") } + } + } + } + + /** + * Read one marker file: must exist, be a regular file, be within + * [MARKER_MAX_BYTES], and hold a non-blank, valid regionId. Returns null + * otherwise. + */ + private fun readMarker(marker: File): String? { + if (marker.exists() && marker.isFile && marker.length() <= MARKER_MAX_BYTES) { + return runCatching { marker.readText().trim() } + .getOrNull() + ?.takeIf { it.isNotBlank() && RegionCache.isValidRegionId(it) } + } + return null + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt b/maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt new file mode 100644 index 0000000..3d2810a --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt @@ -0,0 +1,95 @@ +package org.appdevforall.maps.data + +import java.io.File + +/** + * Decides — and executes — auto-activation of the **first** region in a + * project's cache. When the user downloads their very first region for a + * project, the wizard should not also force them to tap "Use in this project" + * before they can build; this object handles that handoff. + * + * Decision table: + * + * | Project state | Behavior | + * | ---------------------------------------------- | --------------------- | + * | No `active.txt` (or blank) | Apply + write active | + * | `active.txt` already names a region | No-op (user's choice) | + * | Region missing from cache (race / corruption) | No-op (nothing to do) | + * + * The file-copy + ProjectMapEmitter wiring and the active-sentinel write are + * injected as lambdas so the policy stays decoupled from the Android machinery. + */ +internal object FirstRegionAutoActivator { + + sealed class Result { + /** Project already had an active region — left alone. */ + object NoOpAlreadyActive : Result() + + /** [downloadedRegionId] wasn't found in the cache root. */ + object NoOpRegionNotFound : Result() + + /** First region auto-activated successfully. */ + data class Activated(val regionId: String, val displayName: String) : Result() + + /** Apply step (file copy + manifest patch) failed. */ + data class ApplyFailed(val regionId: String, val reason: String) : Result() + } + + /** + * Apply [downloadedRegionId] as the project's active region if (and only if) + * the project doesn't already have one. Pure I/O against [projectDir] and + * [regionsCacheRoot]; no UI, no coroutine launching. + * + * @param projectDir project root (where `app/src/main/assets/maps/` lives) + * @param mapsSubpath subpath under the project where active.txt is kept + * (default: `app/src/main/assets/maps`) + * @param regionsCacheRoot `/sdcard/CodeOnTheGo/maps/` root in production + * @param downloadedRegionId regionId that just finished downloading + * @param applyRegionToProject injectable file-copy + emitter callback; + * returns true on success + * @param writeActiveRegion injectable writer for the active.txt sentinel + */ + suspend fun maybeAutoActivate( + projectDir: File, + mapsSubpath: String, + regionsCacheRoot: File, + downloadedRegionId: String, + applyRegionToProject: suspend (info: RegionInfo, projectDir: File) -> Boolean, + writeActiveRegion: (projectDir: File, regionId: String) -> Unit, + ): Result { + val mapsRoot = File(projectDir, mapsSubpath) + val activeFile = File(mapsRoot, "active.txt") + val existingActive = readActiveSentinel(activeFile) + // Look up the cache list once — used for both the stale-sentinel + // check below AND the downloaded-region lookup further down. + val cacheEntries = runCatching { RegionCache.listFromRoot(regionsCacheRoot) } + .getOrNull() ?: emptyList() + if (!existingActive.isNullOrBlank()) { + // Verify the named region still exists. If the user deleted the + // previously-active region, the sentinel is stale — treat the project + // as having no active region so the new download auto-activates. + val activeStillExists = cacheEntries.any { it.regionId == existingActive } + if (activeStillExists) return Result.NoOpAlreadyActive + // else fall through — sentinel is stale. + } + val info = cacheEntries.firstOrNull { it.regionId == downloadedRegionId } + ?: return Result.NoOpRegionNotFound + + val applied = applyRegionToProject(info, projectDir) + if (!applied) { + // Don't write active.txt if files weren't actually copied — would + // produce a project that references a region but doesn't have its + // tiles, failing the build with a misleading "missing tiles" error. + return Result.ApplyFailed(info.regionId, "applyRegionToProject returned false") + } + writeActiveRegion(projectDir, info.regionId) + return Result.Activated(info.regionId, info.displayName) + } + + private fun readActiveSentinel(file: File): String? { + if (!file.exists() || !file.isFile) return null + // Bounded read so a corrupt sentinel can't OOM the caller. + if (file.length() > 256L) return null + return runCatching { file.readText().trim() }.getOrNull() + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/data/RegionCache.kt b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionCache.kt new file mode 100644 index 0000000..68ff7d0 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionCache.kt @@ -0,0 +1,280 @@ +package org.appdevforall.maps.data + +import android.os.Environment +import android.util.Log +import org.appdevforall.maps.domain.RegionId +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * On-disk view of `/sdcard/CodeOnTheGo/maps//`. + * + * **Layout:** + * - `tiles.pmtiles` — vector tiles (OpenMapTiles schema, OSM-derived). + * Single-file PMTiles archive, loaded via PMTiles' + * HTTP range-request reader. + * - `basemap.pmtiles` — Natural Earth raster basemap (public domain). Always + * present; the renderer falls back to it at low zooms. + * - `meta.json` — `{regionId, displayName, bbox, zoomMin, zoomMax, + * source: {kind, host?, version?, downloadedAt}, + * sizeBytes, layers: ["vector", "basemap"]}` + * + * The cache lives outside the plugin so it survives plugin re-installs and is + * shareable between projects. Public external storage is used because: + * (a) Internet-in-a-Box and similar channels can sideload tile packs into a + * known path without going through app-specific storage; + * (b) the cache can be 100s of MB and shouldn't count against one app's quota. + * + * Permission caveat: scoped storage on API 30+ disallows arbitrary writes to + * `/sdcard/...`. The IDE holds `MANAGE_EXTERNAL_STORAGE` and this plugin runs + * inside that process, so writes work. + * + * **regionId precondition.** The id must satisfy [RegionId.isValid]. [isValidRegionId] + * is the authoritative validator, called from every entry point that resolves a + * path under the cache root. + * + * **Testability.** The `*FromRoot` overloads take an explicit root File so JVM + * tests can run without `Environment.getExternalStorageDirectory`; the no-arg + * variants delegate to [rootDir]. + */ +internal object RegionCache { + + private const val TAG = "MapsPlugin.RegionCache" + private const val DEFAULT_ROOT_NAME = "CodeOnTheGo/maps" + + /** + * File names the wizard writes inside `/`. Region Manager + * sums their sizes for display, and `applyRegionToProject` knows which + * ones to copy into a project. + */ + internal const val FILE_TILES_PMTILES = "tiles.pmtiles" + internal const val FILE_BASEMAP_PMTILES = "basemap.pmtiles" + internal const val FILE_META_JSON = "meta.json" + + /** + * Path-safety allowlist check for a regionId, delegating to the canonical, + * unit-tested [RegionId.isValid] (its doc carries the Unicode + path-safety + * rationale). Kept here so the data layer has one validator entry point — + * callers don't reach into `domain/` — and it's backed by the + * canonical-containment check in [deleteFromRoot] / the downloader (defense in + * depth). + */ + fun isValidRegionId(regionId: String): Boolean = RegionId.isValid(regionId) + + /** Returns the root directory of the cache. Creates it lazily; never null. */ + fun rootDir(): File { + val storage = Environment.getExternalStorageDirectory() + ?: error("External storage unavailable") + val root = File(storage, DEFAULT_ROOT_NAME) + if (!root.exists()) root.mkdirs() + return root + } + + /** + * List regions currently present in the cache. Returns an empty list if + * the cache is empty or unreadable. Never throws — the bottom-sheet tab + * needs to render even if storage is in a bad state. + * + * Skips any directory without a wizard-written `meta.json` — that means + * sideloaded directories (or partial / aborted downloads) don't show up + * as ghost rows. Plain dirs land on a quiet log line, not in the UI. + */ + fun list(): List { + val root = runCatching { rootDir() }.getOrNull() ?: return emptyList() + return listFromRoot(root) + } + + /** Testable variant of [list] taking an explicit root directory. */ + fun listFromRoot(root: File): List { + val children = root.listFiles { f -> f.isDirectory } ?: return emptyList() + return children.mapNotNull { dir -> + val meta = File(dir, FILE_META_JSON) + if (!meta.exists()) { + // The wizard writes meta.json LAST, so a missing meta means a + // sideloaded / partial / aborted download — skip the ghost row. + warn("Skipping region without meta.json: ${dir.name}") + return@mapNotNull null + } + runCatching { read(dir) } + .onFailure { warn("Skipping malformed region at ${dir.name}: ${it.message}") } + .getOrNull() + }.sortedBy { it.displayName.lowercase() } + } + + /** + * Delete a cached region directory recursively. Returns true if the + * directory either was removed or did not exist (idempotent). Defensive + * checks ensure the path is a child of the cache root before deletion — + * catches `regionId` values that contain `..` or absolute paths. + * + * Validates [regionId] via [isValidRegionId] first, then + * canonicalises and asserts containment. The two checks are + * complementary: the regex prevents most attacks at the API boundary, + * canonicalisation catches anything the regex missed (defense in depth). + */ + fun delete(regionId: String): Boolean { + val root = runCatching { rootDir() }.getOrNull() ?: return false + return deleteFromRoot(root, regionId) + } + + /** Testable variant of [delete] taking an explicit root directory. */ + fun deleteFromRoot(root: File, regionId: String): Boolean { + if (!isValidRegionId(regionId)) { + warn("Refusing to delete invalid regionId: $regionId") + return false + } + val target = File(root, regionId).canonicalFile + if (!target.toPath().startsWith(root.canonicalFile.toPath())) { + warn("Refusing to delete out-of-bounds path: $target") + return false + } + if (!target.exists()) return true + return target.deleteRecursively() + } + + /** + * Public read of a single region directory. Returns null on read failure. + * Used by tests; production code goes through [list] / [listFromRoot]. + */ + fun readDir(dir: File): RegionInfo? = runCatching { read(dir) }.getOrNull() + + /** Best-effort read of `meta.json`. Falls back to disk-derived defaults. */ + private fun read(dir: File): RegionInfo { + val metaFile = File(dir, FILE_META_JSON) + + // Sum all the on-disk artifacts so the "size" column reflects what + // the user actually paid for in flash space. + val candidates = sequenceOf( + FILE_TILES_PMTILES, + FILE_BASEMAP_PMTILES, + FILE_META_JSON, + ) + val sizeOnDisk = candidates + .map { File(dir, it) } + .filter { it.exists() } + .sumOf { it.length() } + + val meta: JSONObject = if (metaFile.exists() && metaFile.length() > 0) { + runCatching { JSONObject(metaFile.readText()) }.getOrNull() ?: JSONObject() + } else JSONObject() + + // Optional bbox (south, west, north, east). Only surfaced when all four + // values are present — partial / malformed bbox arrays are dropped. + val bbox: DoubleArray? = runCatching { + if (!meta.has("bbox")) return@runCatching null + val arr: JSONArray = meta.getJSONArray("bbox") + if (arr.length() != 4) return@runCatching null + DoubleArray(4) { i -> arr.getDouble(i) } + }.getOrNull() + + // Source is either the object form { "kind": ..., "host": ..., "version": + // ..., "downloadedAt": } (what downloads write) or a plain string on + // older entries. Tolerate both. + val (sourceKind, sourceHost) = run { + val sourceObj = meta.optJSONObject("source") + if (sourceObj != null) { + sourceObj.optString("kind", "unknown") to + sourceObj.optString("host").takeIf { it.isNotBlank() } + } else { + meta.optString("source", "unknown") to null + } + } + + val layers: List = runCatching { + val arr = meta.optJSONArray("layers") ?: return@runCatching emptyList() + buildList(arr.length()) { + for (i in 0 until arr.length()) { + arr.optString(i)?.takeIf { it.isNotBlank() }?.let { add(it) } + } + } + }.getOrDefault(emptyList()) + + return RegionInfo( + regionId = meta.optString("regionId", dir.name), + displayName = meta.optString("displayName", dir.name), + sizeBytes = if (meta.has("sizeBytes")) meta.optLong("sizeBytes", sizeOnDisk) else sizeOnDisk, + downloadedAt = meta.optLong("downloadedAt", 0L).takeIf { it > 0L }, + lastUsedAt = meta.optLong("lastUsedAt", 0L).takeIf { it > 0L }, + source = sourceKind, + sourceHost = sourceHost, + layers = layers, + directory = dir, + bbox = bbox, + ) + } + + /** + * Defensive logger wrapper. Production calls go to `android.util.Log`; + * unit tests run on the JVM where `Log` isn't classloaded, so we + * runCatch around it and fall back to stderr. + */ + private fun warn(message: String) { + runCatching { Log.w(TAG, message) } + .onFailure { System.err.println("$TAG: $message") } + } +} + +/** + * What the bottom-sheet tab renders per row. Public because the + * `RegionManagerFragment.Listener` overrides reference it; still plugin-internal + * in meaning. + * + * - `bbox`: the south, west, north, east tuple from `meta.json` — used by Refresh + * to re-pick the same area without making the user re-draw. + * - `source`: the source kind (`"iiab-lan"`, `"internet"`, or `"unknown"`) from + * `meta.source.kind`; older string-only entries surface as `"unknown"` or the + * legacy string value. + * - `sourceHost`: the LAN host when known, shown as a sub-label so the user can + * see where each region came from. + * - `layers`: bundled layers from `meta.layers` (e.g. `["vector", "basemap"]`). + */ +data class RegionInfo( + val regionId: String, + val displayName: String, + val sizeBytes: Long, + val downloadedAt: Long?, + val lastUsedAt: Long?, + val source: String, + val sourceHost: String? = null, + val layers: List = emptyList(), + val directory: File, + val bbox: DoubleArray? = null, +) { + // bbox is a DoubleArray so equality needs structural comparison; the + // generated equals/hashCode for data classes uses array reference equality. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RegionInfo) return false + if (regionId != other.regionId) return false + if (displayName != other.displayName) return false + if (sizeBytes != other.sizeBytes) return false + if (downloadedAt != other.downloadedAt) return false + if (lastUsedAt != other.lastUsedAt) return false + if (source != other.source) return false + if (sourceHost != other.sourceHost) return false + if (layers != other.layers) return false + if (directory != other.directory) return false + if (bbox == null) { + if (other.bbox != null) return false + } else { + if (other.bbox == null) return false + if (!bbox.contentEquals(other.bbox)) return false + } + return true + } + + override fun hashCode(): Int { + var result = regionId.hashCode() + result = 31 * result + displayName.hashCode() + result = 31 * result + sizeBytes.hashCode() + result = 31 * result + (downloadedAt?.hashCode() ?: 0) + result = 31 * result + (lastUsedAt?.hashCode() ?: 0) + result = 31 * result + source.hashCode() + result = 31 * result + (sourceHost?.hashCode() ?: 0) + result = 31 * result + layers.hashCode() + result = 31 * result + directory.hashCode() + result = 31 * result + (bbox?.contentHashCode() ?: 0) + return result + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt new file mode 100644 index 0000000..8dd98ed --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt @@ -0,0 +1,356 @@ +package org.appdevforall.maps.data + +import android.content.Context +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.util.AtomicFiles +import org.appdevforall.maps.slicer.PmtilesHeader +import org.appdevforall.maps.slicer.PmtilesRegionSlicer +import org.appdevforall.maps.slicer.PmtilesV3 +import org.appdevforall.maps.slicer.RangeFetcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.TimeUnit +import kotlin.coroutines.coroutineContext + +/** + * Region download coordinator. Writes the canonical cache layout in + * `/sdcard/CodeOnTheGo/maps//`. + * + * Instead of pulling the whole multi-GB global PMTiles per region, the vector + * tiles are *sliced* — only the bbox's tile bytes are + * transferred and written to a fresh PMTiles v3 archive with a matching header. + * The basemap (Natural Earth, ~7 MB worldwide) is the same global file every + * region uses — and it's already bundled in the plugin (byte-for-byte identical + * to the IIAB copy), so it's copied from the bundle rather than re-downloaded. + * + * Single source of truth for "which tiles cover the region" is + * [PmtilesRegionSlicer.tilesInRegion]; both the size estimate and the download + * read from it. + * + * URL contract: + * - **Internet** — `https://iiab.switnet.org/maps/2/`: + * - `openstreetmap-openmaptiles..z00-z14.pmtiles` (vector tiles) + * - **LAN** — `http:///maps/` with the same filenames. + * The Natural Earth basemap is copied from the plugin bundle, not fetched. + */ +internal object RegionDownloader { + + /** Schema version. Bump when adding required fields. */ + internal const val META_SCHEMA_VERSION = 1 + + /** Hard-coded fallback date for the IIAB archive filename. */ + private const val FALLBACK_VECTOR_DATE = "2026-04-01" + + /** + * The Natural Earth basemap bundled in the plugin assets. It's a global, + * static file — identical for every region and byte-for-byte the same as the + * IIAB-hosted copy — so we copy it from here instead of downloading ~7 MB per + * region. (Same asset the bbox picker renders for its world view.) + */ + private const val BUNDLED_BASEMAP_ASSET = "maps/natural-earth-z0-z4.pmtiles" + + private const val INTERNET_BASE = "https://iiab.switnet.org/maps/2/" + private const val LAN_PATH_SUFFIX = "/maps/" + + // OkHttp client tuned for the IIAB use case: + // - Many in-flight range requests against the same host while slicing a + // region archive. Default Dispatcher caps at 5 concurrent requests per + // host, which throttles our parallel slicer (PmtilesRegionSlicer + // fan-outs). Raise to 16 so the slicer's PARALLEL_FETCHES=6 is the + // real cap, not OkHttp. + // - Keep connections alive across requests so we don't pay TCP+TLS + // handshake per range. Pool 12 idle for 5 min covers a long slice. + /** + * Shared OkHttpClient — exposed `internal` so the bbox-picker's slicer + * estimate reuses the same connection pool + dispatcher tuning as the + * download path. Warm connections across estimate→download pay the TLS + * handshake once instead of twice. + */ + internal val httpClient = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .dispatcher(Dispatcher().apply { maxRequestsPerHost = 16 }) + .connectionPool(ConnectionPool(12, 5, TimeUnit.MINUTES)) + .build() + + /** + * Discrete phases the wizard surfaces in the progress UI. + */ + enum class Phase { BASEMAP, TILES } + + /** + * Per-phase progress callback. [bytesRead] = downloaded so far, + * [totalBytes] = Content-Length (or -1 if unknown). + */ + fun interface ProgressListener { + fun onProgress(phase: Phase, bytesRead: Long, totalBytes: Long) + } + + /** + * Run the full download into `/.partial/`; rename + * atomically to `/` on success. + * + * @return the final region directory (NOT the .partial path). + * @throws IOException on network / disk failures. + * @throws IllegalArgumentException if [regionId] is malformed. + */ + suspend fun download( + context: Context, + regionId: String, + displayName: String, + bbox: Bbox, + zoomMin: Int = 6, + zoomMax: Int = 14, + sourceKind: SourceKind = SourceKind.UNKNOWN, + sourceHost: String? = null, + sourceVersion: String? = null, + onProgress: ProgressListener = ProgressListener { _, _, _ -> }, + ): File { + android.util.Log.i( + "RegionDownloader", + "download: regionId=$regionId bbox=$bbox z=$zoomMin..$zoomMax", + ) + // The estimate loop populates HttpRangeByteCache. A download needs fresh + // bytes — IIAB rotates the same URL weekly, and a mid-session swap would + // glue a header from version N to leaves from N+1. + org.appdevforall.maps.slicer.HttpRangeByteCache.clear() + val tilesUrl = buildTilesUrl(sourceKind, sourceHost) + return downloadInto( + cacheRoot = RegionCache.rootDir(), + regionId = regionId, + displayName = displayName, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + sourceKind = sourceKind, + sourceHost = sourceHost, + sourceVersion = sourceVersion, + nowMillis = System.currentTimeMillis(), + onProgress = onProgress, + // The bundled Natural Earth basemap is the same global file for every + // region (byte-identical to the IIAB copy) — copy from the plugin asset + // instead of re-downloading ~7 MB. A failure here is a packaging error. + copyBasemap = { dest, op -> copyBundledBasemap(dest, op) }, + // Vector tiles: sliced down to the bbox over the network. + sliceTiles = { target, op -> + sliceDownload(tilesUrl, bbox, zoomMin, zoomMax, target, op) + }, + ) + } + + /** + * The pure download orchestration, with the three IO seams ([cacheRoot], + * [copyBasemap], [sliceTiles]) and the clock ([nowMillis]) injected so it's + * unit-testable on the JVM without `Environment`, the plugin asset bundle, or a + * live PMTiles host. [download] supplies the real implementations; tests pass a + * temp dir + fakes. + * + * Writes into `/.partial/` and renames atomically to + * `/` only after `meta.json` is written (its presence implies + * completion). Any failure — including cancellation — deletes the partial dir. + */ + internal suspend fun downloadInto( + cacheRoot: File, + regionId: String, + displayName: String, + bbox: Bbox, + zoomMin: Int, + zoomMax: Int, + sourceKind: SourceKind, + sourceHost: String?, + sourceVersion: String?, + nowMillis: Long, + onProgress: ProgressListener, + copyBasemap: (dest: File, onProgress: (Long, Long) -> Unit) -> Unit, + sliceTiles: suspend (target: File, onProgress: (Long, Long) -> Unit) -> Unit, + ): File = withContext(Dispatchers.IO) { + require(RegionCache.isValidRegionId(regionId)) { + "regionId '$regionId' is not a valid region identifier " + + "(lowercase letters/digits/marks of any script, '-', no path separators)" + } + val finalDir = File(cacheRoot, regionId) + val partialDir = File(cacheRoot, "$regionId.partial").apply { + // Clean any leftover partial from a prior cancelled run. + if (exists()) deleteRecursively() + mkdirs() + } + + // Defense-in-depth: canonicalise to keep the partial dir under the root. + val canonicalRoot = cacheRoot.canonicalFile.toPath() + val canonicalPartial = partialDir.canonicalFile.toPath() + require(canonicalPartial.startsWith(canonicalRoot)) { + "partialDir escapes cache root: $canonicalPartial not under $canonicalRoot" + } + + try { + // ---- BASEMAP ---- + copyBasemap(File(partialDir, RegionCache.FILE_BASEMAP_PMTILES)) { bytes, total -> + onProgress.onProgress(Phase.BASEMAP, bytes, total) + } + coroutineContext.ensureActive() + + // ---- TILES (vector): sliced ---- + sliceTiles(File(partialDir, RegionCache.FILE_TILES_PMTILES)) { bytes, total -> + onProgress.onProgress(Phase.TILES, bytes, total) + } + coroutineContext.ensureActive() + + // Build + write meta.json (LAST — its presence implies completion). + val sizeBytes = listOf( + RegionCache.FILE_BASEMAP_PMTILES, + RegionCache.FILE_TILES_PMTILES, + ).sumOf { File(partialDir, it).length() } + val layers = buildList { + add("vector") + add("basemap") + } + val sourceObj = JSONObject().apply { + put("kind", sourceKind.wireValue) + if (sourceHost != null) put("host", sourceHost) + if (sourceVersion != null) put("version", sourceVersion) + put("downloadedAt", nowMillis) + } + val metaJson = JSONObject().apply { + put("version", META_SCHEMA_VERSION) + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(bbox.toBoundsArray().toList())) + put("zoomMin", zoomMin) + put("zoomMax", zoomMax) + put("source", sourceObj) + put("sizeBytes", sizeBytes) + put("downloadedAt", nowMillis) + put("lastUsedAt", nowMillis) + put("layers", JSONArray(layers)) + } + AtomicFiles.writeText(File(partialDir, RegionCache.FILE_META_JSON), metaJson.toString(2)) + + // Atomic rename .partial → final. + if (finalDir.exists()) finalDir.deleteRecursively() + partialDir.renameTo(finalDir) || error( + "Couldn't rename ${partialDir.absolutePath} → ${finalDir.absolutePath}" + ) + finalDir + } catch (t: Throwable) { + // Clean partial on ANY failure — including cancellation. + runCatching { partialDir.deleteRecursively() } + throw t + } + } + + /** + * Slice [globalUrl]'s PMTiles archive down to [bbox]·[zoomMin..zoomMax] + * tiles, writing the result to [target] (a valid v3 PMTiles file). + * + * Uses [PmtilesRegionSlicer.tilesInRegion] to discover which tiles to fetch + * (same function the size estimator calls — single source of truth), then + * [PmtilesRegionSlicer.downloadAndSlice] to range-fetch each tile blob and + * reassemble. + */ + private suspend fun sliceDownload( + globalUrl: String, + bbox: Bbox, + zoomMin: Int, + zoomMax: Int, + target: File, + onProgress: (downloaded: Long, total: Long) -> Unit, + ) { + // Stage 1: parse header + identify tiles. One fetcher session. + val (sourceHeader, tiles) = RangeFetcher.forUrl(globalUrl, httpClient).use { fetcher -> + val headerBytes = fetcher.readRange(0L, PmtilesV3.HEADER_BYTES) + val header = PmtilesHeader.parse( + ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN) + ) + val regionTiles = PmtilesRegionSlicer.tilesInRegionImpl(fetcher, bbox, zoomMin, zoomMax) + if (regionTiles.isEmpty()) { + throw IOException( + "No tiles intersect bbox $bbox at zoom $zoomMin..$zoomMax in $globalUrl" + ) + } + header to regionTiles + } + + // Stage 2: fetch + reassemble. + PmtilesRegionSlicer.downloadAndSlice( + tiles = tiles, + globalPmtilesUrl = globalUrl, + sourceHeader = sourceHeader, + bbox = bbox, + zoomMin = zoomMin, + zoomMax = zoomMax, + targetFile = target, + onProgress = { downloaded, total -> onProgress(downloaded, total) }, + client = httpClient, + ).getOrThrow() + } + + // ----- URL builders ----- + + private fun base(sourceKind: SourceKind, sourceHost: String?): String = when (sourceKind) { + SourceKind.IIAB_LAN -> { + val host = sourceHost?.trim().orEmpty() + require(host.isNotBlank()) { + "IIAB_LAN sourceKind requires a non-blank host" + } + val withScheme = when { + host.startsWith("http://") || host.startsWith("https://") -> host + else -> "http://$host" + } + withScheme.trimEnd('/') + LAN_PATH_SUFFIX + } + else -> INTERNET_BASE + } + + /** + * package-internal: the vector tiles (OpenMapTiles) URL for [sourceKind]. + * + * Exposed so [BboxPickerFragment] can drive a live size estimate + * via [PmtilesRegionSlicer.tilesInRegion] against the same URL the + * download will use — keeps "estimate ≈ actual download size" honest. + */ + internal fun buildTilesUrl(sourceKind: SourceKind, sourceHost: String?): String = + base(sourceKind, sourceHost) + + "openstreetmap-openmaptiles.$FALLBACK_VECTOR_DATE.z00-z14.pmtiles" + + /** + * Copy the bundled Natural Earth basemap ([BUNDLED_BASEMAP_ASSET]) into [dest]. + * It's the same global basemap every region uses and is byte-for-byte identical + * to the IIAB copy, so this replaces a redundant ~7 MB download per region. The + * asset always ships in the plugin; if it can't be read that's a packaging + * error, so we throw rather than silently falling back to the network. + */ + private fun copyBundledBasemap(dest: File, onProgress: (Long, Long) -> Unit) { + val input = MapsPlugin.pluginContext?.resources?.openPluginAsset(BUNDLED_BASEMAP_ASSET) + ?: throw IOException("bundled basemap asset '$BUNDLED_BASEMAP_ASSET' unavailable") + val total = input.available().toLong().coerceAtLeast(0L) + input.use { src -> + dest.outputStream().use { dst -> + val buf = ByteArray(64 * 1024) + var copied = 0L + while (true) { + val n = src.read(buf) + if (n < 0) break + dst.write(buf, 0, n) + copied += n + onProgress(copied, total) + } + } + } + android.util.Log.i( + "RegionDownloader", + "basemap copied from bundle ($BUNDLED_BASEMAP_ASSET, $total bytes)", + ) + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/data/RegionInstaller.kt b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionInstaller.kt new file mode 100644 index 0000000..02e1f75 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/data/RegionInstaller.kt @@ -0,0 +1,89 @@ +package org.appdevforall.maps.data + +import android.util.Log +import org.appdevforall.maps.templates.ProjectMapEmitter +import java.io.File + +/** + * Applies a cached region to an open Maps project as a pure **data copy**: copies + * the region's data files (`tiles.pmtiles`, `basemap.pmtiles`, `meta.json`) into + * the project's fixed, flat asset location + * `app/src/main/assets/maps/`, overwriting any previous region. + * + * The actual verify + copy (require-a-Maps-project gate, canonical-containment + * guards, atomic copy, cleanup-on-failure) lives in [ProjectMapEmitter]; this + * object adds a free-space precheck and routes failures to the plugin logger. + * + * No code injection, no manifest/Gradle patching, no active/region-id markers, no + * per-region subdir, no pruning — the emitted project is single-region and ships + * all of its own wiring (see [org.appdevforall.maps.templates.MapTemplateBuilder]). + */ +internal object RegionInstaller { + + private const val TAG = "MapsPlugin.RegionInstaller" + + /** + * Copy [info]'s data files into [projectDir]'s fixed flat maps assets, + * overwriting any previous region. + * + * @param cacheRoot the regions cache root, passed through to + * [ProjectMapEmitter.apply]. Null (the default) resolves it via + * [RegionCache.rootDir] *inside* the try block (so a resolution failure is + * still caught + routed); JVM tests pass a temp dir to exercise the whole copy + * without `Environment`'s external storage, which is null on a plain JVM. + * @param logError routes a failure (with the caught exception) to the plugin + * logger in addition to logcat; defaults to a no-op for tests. + * @param onChunk cooperative-cancellation seam invoked between copy chunks — + * the caller (always inside `withContext(Dispatchers.IO)`) passes + * `{ coroutineContext.ensureActive() }` so closing the bottom sheet mid-copy + * of a large `tiles.pmtiles` aborts promptly. Defaults to a no-op for tests. + * @return true if the region data was copied successfully. + */ + fun apply( + info: RegionInfo, + projectDir: File, + cacheRoot: File? = null, + logError: (String, Throwable) -> Unit = { _, _ -> }, + onChunk: () -> Unit = {}, + ): Boolean { + return try { + val root = cacheRoot ?: RegionCache.rootDir() + val canonicalSrc = info.directory.canonicalFile + + // Free-space precheck: bail before writing if the volume can't fit the + // copy plus a 1 MB safety margin. `usableSpace` returns 0 both for a + // genuinely-full volume and when it can't be read; treat 0 as + // "can't verify" and proceed — ProjectMapEmitter's atomic copy cleans + // up a partial write if a mid-copy ENOSPC hits anyway. + val needed = listOf( + RegionCache.FILE_TILES_PMTILES, + RegionCache.FILE_BASEMAP_PMTILES, + RegionCache.FILE_META_JSON, + ).map { File(canonicalSrc, it) } + .filter { it.exists() } + .sumOf { it.length() } + val safetyMargin = 1L * 1024L * 1024L + val usable = File(projectDir, "app").usableSpace + if (usable in 1 until needed + safetyMargin) { + Log.w( + TAG, + "Insufficient space to apply region ${info.regionId}: " + + "need ${needed + safetyMargin}, have $usable", + ) + return false + } + + val result = ProjectMapEmitter.apply(projectDir, canonicalSrc, root, onChunk) + if (!result.success) { + Log.w(TAG, "apply region ${info.regionId} failed: ${result.reason}") + } + result.success + } catch (e: Exception) { + // Log.e to logcat too (the plugin logger only routes to CoGo's own + // log file, invisible via `adb logcat`). + Log.e(TAG, "applyRegionToProject failed for ${info.regionId}", e) + logError("applyRegionToProject failed for ${info.regionId}", e) + false + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt new file mode 100644 index 0000000..1b35c20 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt @@ -0,0 +1,103 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Branch-coverage top-up for [ActiveRegionStore] — the branches the round-trip + * suite in `ActiveRegionStoreTest` leaves uncovered: + * + * - `clear` when neither marker file exists (the `if (f.exists())` false branch). + * - `readMarker` when the path is a directory, not a regular file (`isFile` false). + * - `write` happy path with mapsSubpath created on demand (the onSuccess log path). + * - `read` falling through to null when both markers are absent. + */ +class ActiveRegionStoreBranchCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val mapsSubpath = "app/src/main/assets/maps" + + private fun activeFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + + @Test + fun clearIsIdempotentWhenNoMarkersExist() { + // Neither active.txt nor region-id.txt present → clear is a no-op and + // must not throw (exercises the `if (f.exists())` false branch twice). + val project = tmp.newFolder("project") + ActiveRegionStore.clear(project, mapsSubpath) + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun readIgnoresMarkerThatIsADirectory() { + // A directory named active.txt exists → `isFile` is false, so readMarker + // returns null and read falls through to the (absent) legacy marker. + val project = tmp.newFolder("project") + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}").mkdirs() + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeCreatesMapsSubpathOnDemand() { + // mapsSubpath doesn't exist yet → write mkdirs() it and lands a valid id. + val project = tmp.newFolder("project") + val mapsRoot = File(project, mapsSubpath) + assertFalse("precondition: subpath absent", mapsRoot.exists()) + + ActiveRegionStore.write(project, mapsSubpath, "kandahar-afg") + + assertTrue("subpath created", mapsRoot.exists()) + assertTrue(activeFile(project).exists()) + assertEquals("kandahar-afg", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeSwallowsFailureWhenMapsPathIsBlockedByAFile() { + // Force AtomicFiles.writeText to throw: plant a regular FILE where the + // maps subpath's parent directory needs to be, so mkdirs() can't create + // the maps dir and the temp-file write fails. write() must catch it (the + // onFailure log arm) and not propagate — the panel can't crash on a + // wedged filesystem. + val project = tmp.newFolder("project") + // mapsSubpath = "app/src/main/assets/maps"; block it at "app" by making + // "app" a file, so File(project, mapsSubpath) can never be a directory. + File(project, "app").writeText("I am a file, not a directory") + + // Must not throw. + ActiveRegionStore.write(project, mapsSubpath, "blocked-region") + + // And nothing landed. + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun readPrefersActiveOverLegacyWhenBothValid() { + // Both markers present & valid → the first readMarker returns non-null, + // so `?.let { return it }` short-circuits before the legacy read (the + // success arm of the first marker lookup). + val project = tmp.newFolder("project") + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + .apply { parentFile!!.mkdirs(); writeText("active-region") } + File(project, "$mapsSubpath/${ActiveRegionStore.REGION_MARKER_FILE}") + .writeText("legacy-region") + assertEquals("active-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun writeOverwritesPreviousActiveRegion() { + // Two writes — the second wins (no stale value left behind). + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "first-region") + ActiveRegionStore.write(project, mapsSubpath, "second-region") + assertEquals("second-region", ActiveRegionStore.read(project, mapsSubpath)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt new file mode 100644 index 0000000..c047069 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt @@ -0,0 +1,96 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [ActiveRegionStore]. Covers: read/write round-trip, the legacy + * region-id.txt fallback, clear removing both markers, bounded reads, and + * invalid-id refusal. + */ +class ActiveRegionStoreTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val mapsSubpath = "app/src/main/assets/maps" + + private fun activeFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") + + private fun legacyFile(project: File) = + File(project, "$mapsSubpath/${ActiveRegionStore.REGION_MARKER_FILE}") + + @Test + fun `write then read round-trips the regionId`() { + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "addis-ababa") + assertEquals("addis-ababa", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read returns null when no marker exists`() { + val project = tmp.newFolder("project") + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read prefers active-txt over the legacy marker`() { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("current-region") } + legacyFile(project).writeText("old-region") + assertEquals("current-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read falls back to the legacy region-id-txt marker`() { + val project = tmp.newFolder("project") + legacyFile(project).apply { parentFile!!.mkdirs(); writeText("legacy-region") } + assertEquals("legacy-region", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `clear removes both active-txt and the legacy marker`() { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("region-a") } + legacyFile(project).writeText("region-b") + ActiveRegionStore.clear(project, mapsSubpath) + assertTrue("active.txt should be gone", !activeFile(project).exists()) + assertTrue("region-id.txt should be gone", !legacyFile(project).exists()) + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `write refuses an invalid regionId`() { + val project = tmp.newFolder("project") + ActiveRegionStore.write(project, mapsSubpath, "../escape") + assertNull("invalid id must not land in active.txt", ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores a blank marker`() { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText(" ") } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores an oversized marker`() { + val project = tmp.newFolder("project") + // Over the 1 KB defensive bound — even if it parses to a valid-looking id. + activeFile(project).apply { parentFile!!.mkdirs(); writeText("a".repeat(2048)) } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } + + @Test + fun `read ignores a marker whose content is not a valid regionId`() { + val project = tmp.newFolder("project") + activeFile(project).apply { parentFile!!.mkdirs(); writeText("Not A Valid Id!") } + assertNull(ActiveRegionStore.read(project, mapsSubpath)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt new file mode 100644 index 0000000..f4c3a17 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt @@ -0,0 +1,200 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Branch-coverage top-up for [FirstRegionAutoActivator] — the + * `readActiveSentinel` branches the policy suite in + * `FirstRegionAutoActivatorTest` leaves uncovered: + * + * - the sentinel path exists but is a DIRECTORY (`isFile` false) → treated as + * no active region, so the download auto-activates. + * - no `active.txt` at all (the `!file.exists()` true branch) with a region + * present → activates. + */ +class FirstRegionAutoActivatorBranchCoverageTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private val mapsSubpath = "app/src/main/assets/maps" + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-fra-branch-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-branch-cache").toFile() + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + private fun seedCachedRegion(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(listOf(0.0, 0.0, 1.0, 1.0))) + put("source", JSONObject().apply { put("kind", "internet") }) + }.toString() + ) + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(8)) + } + + private val applyOk: suspend (RegionInfo, File) -> Boolean = { _, _ -> true } + + private val writeCalls = mutableListOf>() + private val writeActive: (File, String) -> Unit = { dir, id -> + writeCalls += dir to id + File(dir, mapsSubpath).apply { mkdirs() } + val sentinel = File(File(dir, mapsSubpath), "active.txt") + // The dir-as-sentinel test seeds active.txt as a directory; clear it + // before writing the real marker (mirrors the activate-proceeds path). + if (sentinel.isDirectory) sentinel.deleteRecursively() + sentinel.writeText(id) + } + + @Test + fun activatesWhenSentinelPathIsADirectory() { + runBlocking { + // active.txt exists but is a directory → readActiveSentinel's + // `!file.isFile` guard returns null → activator proceeds. + seedCachedRegion("freetown", "Freetown") + File(projectDir, "$mapsSubpath/active.txt").mkdirs() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "freetown", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "Directory sentinel should be ignored; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals(1, writeCalls.size) + assertEquals("freetown", writeCalls.first().second) + } + } + + @Test + fun applyFailedCarriesRegionIdAndReason() { + runBlocking { + // Drive the ApplyFailed arm and read its generated accessors so the + // data class's component/equals/toString branches are covered, and + // assert the contract: the failing region's id + a reason string, + // and writeActive is NOT called (no half-applied active.txt). + seedCachedRegion("monrovia", "Monrovia") + writeCalls.clear() + + val applyFails: suspend (RegionInfo, File) -> Boolean = { _, _ -> false } + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "monrovia", + applyRegionToProject = applyFails, + writeActiveRegion = writeActive, + ) + + assertTrue(result is FirstRegionAutoActivator.Result.ApplyFailed) + result as FirstRegionAutoActivator.Result.ApplyFailed + assertEquals("monrovia", result.regionId) + assertTrue("reason should be non-blank", result.reason.isNotBlank()) + assertTrue("apply failure must not write active.txt", writeCalls.isEmpty()) + // toString is part of the generated surface — exercise it. + assertTrue(result.toString().contains("monrovia")) + } + } + + @Test + fun activatedResultExposesRegionIdAndDisplayName() { + runBlocking { + // Read both generated accessors of the Activated data class. + seedCachedRegion("bamako", "Bamako, Mali") + writeCalls.clear() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "bamako", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + result as FirstRegionAutoActivator.Result.Activated + assertEquals("bamako", result.regionId) + assertEquals("Bamako, Mali", result.displayName) + } + } + + @Test + fun noOpAlreadyActiveWhenSentinelNamesAStillPresentRegion() { + runBlocking { + // existingActive non-blank AND the named region still exists in the + // cache → the `activeStillExists` true branch returns NoOpAlreadyActive + // without touching apply/write. (The policy suite covers this too, but + // it's the decision arm paired with the stale-sentinel fall-through, so + // assert it explicitly here against the directory-sentinel doubles.) + seedCachedRegion("dakar", "Dakar") + seedCachedRegion("conakry", "Conakry") + File(projectDir, "$mapsSubpath").mkdirs() + File(projectDir, "$mapsSubpath/active.txt").writeText("dakar") + writeCalls.clear() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "conakry", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "active region present → NoOp; got $result", + result is FirstRegionAutoActivator.Result.NoOpAlreadyActive, + ) + assertTrue("must not write when already active", writeCalls.isEmpty()) + } + } + + @Test + fun activatesWhenNoSentinelFileExistsAtAll() { + runBlocking { + // No active.txt on disk — the `!file.exists()` true branch in + // readActiveSentinel. A seeded region auto-activates. + seedCachedRegion("juba", "Juba") + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "juba", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + + assertTrue( + "Missing sentinel should activate; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals("juba", (result as FirstRegionAutoActivator.Result.Activated).regionId) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt new file mode 100644 index 0000000..303b36d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt @@ -0,0 +1,282 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for [FirstRegionAutoActivator]. Exercises the policy + applies it + * end-to-end against temp project / cache directories. + * + * The actual file-copy (`applyRegionToProject`) is injected as a fake that + * just records what it was called with — same shape RegionManagerFragment + * passes in production, but no real I/O. This keeps the test fast + isolated. + */ +class FirstRegionAutoActivatorTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private val mapsSubpath = "app/src/main/assets/maps" + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-fra-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-cache").toFile() + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + /** Drop a minimal valid region dir into [cacheRoot] so `RegionCache.listFromRoot` finds it. */ + private fun seedCachedRegion(regionId: String, displayName: String = regionId) { + val dir = File(cacheRoot, regionId).apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", regionId) + put("displayName", displayName) + put("bbox", JSONArray(listOf(0.0, 0.0, 1.0, 1.0))) + put("zoomMin", 6) + put("zoomMax", 14) + put("source", JSONObject().apply { put("kind", "internet") }) + put("sizeBytes", 1024) + put("downloadedAt", System.currentTimeMillis()) + put("lastUsedAt", System.currentTimeMillis()) + }.toString() + ) + // The size sum reads from on-disk artifacts; touch a stub tiles file so + // the listed RegionInfo isn't a phantom. + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(1024)) + } + + private fun writeActiveSentinel(text: String) { + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(text) + } + + /** Apply fake that records its arguments + returns true. */ + private val applyOk: (RegionInfo, File) -> Boolean = { _, _ -> true } + + /** Apply fake that simulates a failure (disk full / perms). */ + private val applyFail: (RegionInfo, File) -> Boolean = { _, _ -> false } + + private val writeActiveCalls = mutableListOf>() + private val writeActive: (File, String) -> Unit = { dir, id -> + writeActiveCalls += dir to id + // Mirror RegionManagerFragment's writeActiveRegionId behavior — write a + // real file so subsequent assertions can verify it. + val mapsRoot = File(dir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(id) + } + + // ----- Empty project (the user's first download) ----- + + @Test + fun activates_when_no_active_sentinel_yet() = runBlocking { + seedCachedRegion("lalibela", "Lalibela, Ethiopia") + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected Activated, got $result", + result is FirstRegionAutoActivator.Result.Activated + ) + assertEquals("lalibela", (result as FirstRegionAutoActivator.Result.Activated).regionId) + assertEquals("Lalibela, Ethiopia", result.displayName) + // File-copy was triggered exactly once. + assertEquals(1, applyCalls.size) + assertEquals("lalibela", applyCalls[0].regionId) + // active.txt was written via the injected writer. + val activeFile = File(projectDir, "$mapsSubpath/active.txt") + assertTrue("active.txt should exist after auto-activation", activeFile.exists()) + assertEquals("lalibela", activeFile.readText().trim()) + } + + // ----- Project already has an active region — must not overwrite ----- + + @Test + fun noOp_when_active_sentinel_already_names_a_region() = runBlocking { + // lalibela MUST also be in the cache — otherwise the sentinel is + // stale (the region was deleted out from under us) and the activator + // falls through to apply the new region. Tested separately below. + seedCachedRegion("lalibela", "Lalibela") + seedCachedRegion("kathmandu", "Kathmandu") + writeActiveSentinel("lalibela") // user's prior choice + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "kathmandu", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected NoOpAlreadyActive, got $result", + result is FirstRegionAutoActivator.Result.NoOpAlreadyActive + ) + assertTrue("apply must not be invoked when an active region exists", applyCalls.isEmpty()) + assertTrue("writeActive must not be invoked", writeActiveCalls.isEmpty()) + // Existing active.txt unchanged. + assertEquals("lalibela", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + /** + * Bryan, 2026-05-26 — observed bug: deleted all regions, downloaded + * Spain, got "active region is unchanged" because the previously-active + * region's name was still in active.txt even though its cache files + * were gone. Fix: treat such a sentinel as stale and proceed. + */ + @Test + fun activates_when_active_sentinel_names_a_deleted_region() = runBlocking { + // spain is in the cache; lalibela was deleted (only its stale + // sentinel survives). + seedCachedRegion("spain", "Spain") + writeActiveSentinel("lalibela") // stale — region cache doesn't have it + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "spain", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Stale sentinel should fall through to apply; got $result", + result is FirstRegionAutoActivator.Result.Activated, + ) + assertEquals(1, writeActiveCalls.size) + assertEquals("spain", writeActiveCalls.first().second) + // active.txt now updated to spain. + assertEquals("spain", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + // ----- Blank active sentinel = treat as no active region ----- + + @Test + fun activates_when_active_sentinel_is_blank() = runBlocking { + seedCachedRegion("kandahar") + writeActiveSentinel(" \n") // whitespace-only — should not block + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "kandahar", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue(result is FirstRegionAutoActivator.Result.Activated) + } + + // ----- Race: regionId not in cache (e.g., deleted mid-flight) ----- + + @Test + fun noOp_when_region_missing_from_cache() = runBlocking { + // Don't seed any region. + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "phantom", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected NoOpRegionNotFound, got $result", + result is FirstRegionAutoActivator.Result.NoOpRegionNotFound + ) + assertTrue(writeActiveCalls.isEmpty()) + } + + // ----- Apply failure (disk full, perms): MUST NOT write active.txt ----- + + @Test + fun does_not_write_active_when_apply_fails() = runBlocking { + seedCachedRegion("goma") + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "goma", + applyRegionToProject = applyFail, + writeActiveRegion = writeActive, + ) + assertTrue( + "Expected ApplyFailed, got $result", + result is FirstRegionAutoActivator.Result.ApplyFailed + ) + assertTrue("writeActive must not be invoked when apply fails", writeActiveCalls.isEmpty()) + // active.txt must NOT exist (or exists empty); otherwise the project + // would name a region whose files weren't copied → build fails with + // a misleading "missing tiles" error. + val activeFile = File(projectDir, "$mapsSubpath/active.txt") + assertFalse( + "active.txt must NOT exist after apply failure", + activeFile.exists() && activeFile.readText().trim().isNotEmpty() + ) + } + + // ----- Sentinel-format robustness ----- + + @Test + fun ignores_oversized_sentinel_treats_as_no_active() = runBlocking { + // A 1 KB file in active.txt is suspicious — could be a corrupted dump. + // The bounded-read in maybeAutoActivate caps at 256 bytes, so anything + // larger reads as "no active" and lets the auto-activate proceed. + seedCachedRegion("maiduguri") + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeBytes(ByteArray(1024) { 'x'.code.toByte() }) + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "maiduguri", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue( + "Oversized sentinel should be ignored; expected Activated, got $result", + result is FirstRegionAutoActivator.Result.Activated + ) + } + + // ----- Nested mapsSubpath is created when missing ----- + + @Test + fun creates_maps_subpath_if_absent_during_write() = runBlocking { + seedCachedRegion("port-au-prince", "Port-au-Prince") + val mapsRoot = File(projectDir, mapsSubpath) + assertFalse("precondition: maps subpath should not exist yet", mapsRoot.exists()) + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "port-au-prince", + applyRegionToProject = applyOk, + writeActiveRegion = writeActive, + ) + assertTrue(result is FirstRegionAutoActivator.Result.Activated) + assertTrue("maps subpath must exist after activation", mapsRoot.exists()) + assertEquals( + "port-au-prince", + File(mapsRoot, "active.txt").readText().trim(), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt new file mode 100644 index 0000000..29dbb3e --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt @@ -0,0 +1,239 @@ +package org.appdevforall.maps.data + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Branch-coverage top-up for [RegionCache] — the parse branches the safety-path + * suite in `RegionCacheTest` leaves uncovered: + * + * - source object present but with a BLANK host → host coalesces to null. + * - `downloadedAt` / `lastUsedAt` positive (the `takeIf { it > 0 }` true branch). + * - `layers` array with blank / null-ish entries dropped. + * - `deleteFromRoot` invalid-id refusal (regex guard, before canonicalisation). + * - `listFromRoot` skipping a directory without `meta.json`. + */ +class RegionCacheBranchCoverageTest { + + private lateinit var tempRoot: File + + @Before + fun setUp() { + tempRoot = Files.createTempDirectory("maps-region-cache-branch").toFile() + } + + @After + fun tearDown() { + tempRoot.deleteRecursively() + } + + @Test + fun sourceObjectWithBlankHostCoalescesToNull() { + // Object-form source whose host is blank → sourceHost must read as null, + // exercising the `takeIf { it.isNotBlank() }` false branch. + val dir = File(tempRoot, "blank-host").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "blank-host") + put("source", JSONObject().apply { + put("kind", "internet") + put("host", " ") + }) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("internet", info!!.source) + assertNull("blank host must coalesce to null", info.sourceHost) + } + + @Test + fun sourceObjectMissingKindFallsBackToUnknown() { + // Object-form source with no "kind" → optString default "unknown". + val dir = File(tempRoot, "no-kind").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "no-kind") + put("source", JSONObject().apply { put("host", "box.lan") }) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir) + assertEquals("unknown", info!!.source) + assertEquals("box.lan", info.sourceHost) + } + + @Test + fun downloadedAtAndLastUsedAtSurfacedWhenPositive() { + // The `takeIf { it > 0L }` TRUE branch — both timestamps are non-null. + val dir = File(tempRoot, "with-times").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "with-times") + put("downloadedAt", 1735000000000L) + put("lastUsedAt", 1735100000000L) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertEquals(1735000000000L, info.downloadedAt) + assertEquals(1735100000000L, info.lastUsedAt) + } + + @Test + fun zeroTimestampsCoalesceToNull() { + // The `takeIf { it > 0L }` FALSE branch — explicit 0 reads as null. + val dir = File(tempRoot, "zero-times").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "zero-times") + put("downloadedAt", 0L) + put("lastUsedAt", 0L) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNull(info.downloadedAt) + assertNull(info.lastUsedAt) + } + + @Test + fun layersDropsBlankEntries() { + // Layers array containing blank strings — the `takeIf { isNotBlank }` + // filter must drop them, keeping only the real layer names. + val dir = File(tempRoot, "blank-layers").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "blank-layers") + put("layers", JSONArray(listOf("vector", "", " ", "basemap"))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertEquals(listOf("vector", "basemap"), info.layers) + } + + @Test + fun bboxWithNonNumericEntriesIsDroppedViaRunCatching() { + // A length-4 bbox whose entries aren't parseable as doubles makes + // arr.getDouble(i) throw inside the runCatching, so getOrNull() yields + // null — the catch arm, distinct from the `!has` / `length != 4` early + // returns. The rest of the record still parses. + val dir = File(tempRoot, "bad-bbox-values").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "bad-bbox-values") + put("bbox", JSONArray(listOf("north", "south", "east", "west"))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNull("non-numeric bbox must be dropped", info.bbox) + assertEquals("bad-bbox-values", info.regionId) + } + + @Test + fun bboxFullyParsedWhenAllFourPresentAndNumeric() { + // The success arm: has("bbox") true, length == 4, all getDouble succeed. + val dir = File(tempRoot, "good-bbox").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "good-bbox") + put("bbox", JSONArray(listOf(1.5, 2.5, 3.5, 4.5))) + } + File(dir, "meta.json").writeText(meta.toString()) + + val info = RegionCache.readDir(dir)!! + assertNotNull(info.bbox) + assertEquals(4, info.bbox!!.size) + assertEquals(4.5, info.bbox!![3], 0.0) + } + + @Test + fun layersPresentButEmptyArrayYieldsEmptyList() { + // layers key present but the array is empty → the for-loop body never + // runs, buildList returns empty. Distinct from the no-layers-key path. + val dir = File(tempRoot, "empty-layers").apply { mkdirs() } + val meta = JSONObject().apply { + put("regionId", "empty-layers") + put("layers", JSONArray()) + } + File(dir, "meta.json").writeText(meta.toString()) + + assertEquals(emptyList(), RegionCache.readDir(dir)!!.layers) + } + + @Test + fun deleteFromRootRefusesInvalidIdViaRegex() { + // Hits the `!isValidRegionId` true branch in deleteFromRoot (the regex + // guard) — returns false before any canonicalisation. + assertFalse(RegionCache.deleteFromRoot(tempRoot, "Bad Id!")) + assertFalse(RegionCache.deleteFromRoot(tempRoot, "UPPER")) + } + + @Test + fun deleteFromRootSucceedsForValidExistingRegion() { + // The happy path: valid id, path contained, target exists → deleted. + val dir = File(tempRoot, "goodregion").apply { mkdirs() } + File(dir, "meta.json").writeText("{}") + assertTrue(dir.exists()) + assertTrue(RegionCache.deleteFromRoot(tempRoot, "goodregion")) + assertFalse(dir.exists()) + } + + @Test + fun listFromRootSkipsDirWithoutMeta_butKeepsValidSibling() { + // No-meta dir is skipped (warn path); the valid sibling still lists. + File(tempRoot, "ghost").apply { mkdirs() } + val ok = File(tempRoot, "real").apply { mkdirs() } + File(ok, "meta.json").writeText( + JSONObject().apply { put("regionId", "real") }.toString() + ) + + val regions = RegionCache.listFromRoot(tempRoot) + assertEquals(1, regions.size) + assertEquals("real", regions[0].regionId) + } + + @Test + fun listFromRootReturnsEmptyWhenRootHasNoSubdirs() { + // listFiles filter returns an empty array (root has only files). + File(tempRoot, "loose-file.txt").writeText("x") + assertTrue(RegionCache.listFromRoot(tempRoot).isEmpty()) + } + + @Test + fun listReturnsEmptyWhenExternalStorageUnavailableOnJvm() { + // The no-arg list() routes through rootDir(), which calls + // Environment.getExternalStorageDirectory() — unavailable on the JVM, so + // it throws and the `runCatching { rootDir() }.getOrNull() ?: return + // emptyList()` elvis-true branch fires. Must yield an empty list, never + // throw (the bottom-sheet still has to render). + assertTrue(RegionCache.list().isEmpty()) + } + + @Test + fun deleteReturnsFalseWhenExternalStorageUnavailableOnJvm() { + // The no-arg delete() also reaches rootDir(); when it throws, the + // `getOrNull() ?: return false` branch returns false (couldn't even + // resolve the root, so nothing was deleted). + assertFalse(RegionCache.delete("goodregion")) + } + + @Test + fun readDirReturnsNullWhenReadThrowsOnNonDirectory() { + // read() opening a path that isn't a usable directory: readDir wraps in + // runCatching and yields a best-effort RegionInfo (dir name fallbacks), + // never throwing. + val dir = File(tempRoot, "empty-region").apply { mkdirs() } + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("empty-region", info!!.regionId) + // No artifacts on disk → size sum is 0. + assertEquals(0L, info.sizeBytes) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ef7bb7ab10074452ebbd9a8aa460ad361c70b879 GIT binary patch literal 15918 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 literal 0 HcmV?d00001 diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt new file mode 100644 index 0000000..d766187 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt @@ -0,0 +1,225 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Coverage tests for [RegionDownloader]'s JVM-testable surface. + * + * The download orchestration was refactored into [RegionDownloader.downloadInto], + * which takes the three IO seams (cache root, basemap copy, tile slice) plus the + * clock injected — so the partial→final dir flow, `meta.json` assembly, atomic + * rename, progress sequencing, regionId validation, and cleanup-on-failure are all + * exercised here with a temp dir + fakes. The pure URL builders + * ([RegionDownloader.buildTilesUrl] / `base`) are covered too. + * + * The thin remaining IO — `copyBundledBasemap` (reads a bundled asset via + * `MapsPlugin.pluginContext`) and `sliceDownload` (range-fetches over the network) — + * is Android/host-bound (no `Environment` / `AssetManager` on a plain JVM) and is + * covered by the android-qa device walk instead, documented at each call site. + */ +class RegionDownloaderCoverageTest { + + private lateinit var tmpDir: File + + @Before + fun setUp() { + tmpDir = File.createTempFile("region-downloader-cov", "").let { + it.delete() + it.mkdirs() + it + } + } + + @After + fun tearDown() { + tmpDir.deleteRecursively() + } + + // ----- downloadInto(): the injected-seam orchestration. The refactor that + // extracted this from download() made it testable with a temp dir + + // fakes — no Environment, no plugin asset bundle, no network. ----- + + private val tinyBbox = Bbox(south = 5.0, west = -1.0, north = 6.0, east = 0.0) + + /** copyBasemap fake: writes [n] bytes to dest + reports progress once. */ + private fun fakeBasemap(n: Int): (File, (Long, Long) -> Unit) -> Unit = { dest, op -> + dest.writeBytes(ByteArray(n)) + op(n.toLong(), n.toLong()) + } + + /** sliceTiles fake: writes [n] bytes to target + reports progress once. */ + private fun fakeSlice(n: Int): suspend (File, (Long, Long) -> Unit) -> Unit = { target, op -> + target.writeBytes(ByteArray(n)) + op(n.toLong(), n.toLong()) + } + + @Test + fun `downloadInto writes the region layout plus meta-json and renames atomically`() = runBlocking { + val progress = mutableListOf>() + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, + regionId = "freetown", + displayName = "Freetown", + bbox = tinyBbox, + zoomMin = 6, + zoomMax = 14, + sourceKind = SourceKind.IIAB_LAN, + sourceHost = "box.local", + sourceVersion = "2026-04-01", + nowMillis = 1_700_000_000_000L, + onProgress = { phase, bytes, _ -> progress += phase to bytes }, + copyBasemap = fakeBasemap(11), + sliceTiles = fakeSlice(22), + ) + + // Renamed to the final dir; the .partial is gone. + assertEquals(File(tmpDir, "freetown"), result) + assertTrue("final dir exists", result.isDirectory) + assertFalse(".partial removed", File(tmpDir, "freetown.partial").exists()) + + // All three artifacts present with the written sizes. + assertEquals(11L, File(result, RegionCache.FILE_BASEMAP_PMTILES).length()) + assertEquals(22L, File(result, RegionCache.FILE_TILES_PMTILES).length()) + val meta = File(result, RegionCache.FILE_META_JSON) + assertTrue("meta.json written", meta.isFile) + + // meta.json is exactly what was passed in (deterministic via nowMillis). + val json = org.json.JSONObject(meta.readText()) + assertEquals(RegionDownloader.META_SCHEMA_VERSION, json.getInt("version")) + assertEquals("freetown", json.getString("regionId")) + assertEquals("Freetown", json.getString("displayName")) + assertEquals(6, json.getInt("zoomMin")) + assertEquals(14, json.getInt("zoomMax")) + assertEquals(33L, json.getLong("sizeBytes")) // 11 + 22 + assertEquals(1_700_000_000_000L, json.getLong("downloadedAt")) + assertEquals(1_700_000_000_000L, json.getLong("lastUsedAt")) + assertEquals(2, json.getJSONArray("layers").length()) // [vector, basemap] + val source = json.getJSONObject("source") + assertEquals(SourceKind.IIAB_LAN.wireValue, source.getString("kind")) + assertEquals("box.local", source.getString("host")) + assertEquals("2026-04-01", source.getString("version")) + + // Progress fired BASEMAP first, then TILES. + assertEquals(RegionDownloader.Phase.BASEMAP, progress.first().first) + assertEquals(RegionDownloader.Phase.TILES, progress.last().first) + } + + @Test + fun `downloadInto omits source host and version when null`() = runBlocking { + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "a", displayName = "A", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(1), sliceTiles = fakeSlice(1), + ) + val source = org.json.JSONObject(File(result, RegionCache.FILE_META_JSON).readText()) + .getJSONObject("source") + assertFalse("host omitted when null", source.has("host")) + assertFalse("version omitted when null", source.has("version")) + } + + @Test + fun `downloadInto rejects a malformed regionId before any IO`() = runBlocking { + try { + RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "Bad Region ID", displayName = "X", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = { _, _ -> fail("basemap must not run for an invalid id") }, + sliceTiles = { _, _ -> fail("slice must not run for an invalid id") }, + ) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue("message names the offending id", e.message?.contains("Bad Region ID") == true) + } + assertFalse(File(tmpDir, "Bad Region ID.partial").exists()) + } + + @Test + fun `downloadInto cleans up the partial dir when slicing fails`() = runBlocking { + try { + RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "kakuma", displayName = "Kakuma", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(8), + sliceTiles = { _, _ -> throw IllegalStateException("slice exploded") }, + ) + fail("expected the slice failure to propagate") + } catch (e: IllegalStateException) { + assertEquals("slice exploded", e.message) + } + // The whole partial dir is removed; no final dir was created. + assertFalse("partial cleaned up", File(tmpDir, "kakuma.partial").exists()) + assertFalse("no final dir on failure", File(tmpDir, "kakuma").exists()) + } + + @Test + fun `downloadInto replaces a pre-existing final dir`() = runBlocking { + File(tmpDir, "goma").apply { mkdirs() }.let { File(it, "stale.txt").writeText("old") } + val result = RegionDownloader.downloadInto( + cacheRoot = tmpDir, regionId = "goma", displayName = "Goma", bbox = tinyBbox, + zoomMin = 6, zoomMax = 14, sourceKind = SourceKind.UNKNOWN, + sourceHost = null, sourceVersion = null, nowMillis = 1L, + onProgress = { _, _, _ -> }, + copyBasemap = fakeBasemap(3), sliceTiles = fakeSlice(3), + ) + assertFalse("stale file gone after replace", File(result, "stale.txt").exists()) + assertTrue(File(result, RegionCache.FILE_META_JSON).isFile) + } + + // ----- buildTilesUrl / base: the pure URL-builder branches ----- + + private val tilesFile = "openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" + + @Test + fun `buildTilesUrl uses the internet base for non-LAN sources`() { + val url = RegionDownloader.buildTilesUrl(SourceKind.INTERNET, sourceHost = null) + assertEquals("https://iiab.switnet.org/maps/2/$tilesFile", url) + // UNKNOWN also routes to the internet base (the else arm). + assertEquals(url, RegionDownloader.buildTilesUrl(SourceKind.UNKNOWN, "ignored.host")) + } + + @Test + fun `buildTilesUrl prefixes a bare LAN host with http and the maps path`() { + assertEquals( + "http://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "box.local"), + ) + } + + @Test + fun `buildTilesUrl keeps an explicit scheme and trims a trailing slash on the LAN host`() { + assertEquals( + "http://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "http://box.local/"), + ) + assertEquals( + "https://box.local/maps/$tilesFile", + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, "https://box.local"), + ) + } + + @Test + fun `buildTilesUrl requires a non-blank host for a LAN source`() { + try { + RegionDownloader.buildTilesUrl(SourceKind.IIAB_LAN, sourceHost = " ") + fail("expected IllegalArgumentException for a blank LAN host") + } catch (e: IllegalArgumentException) { + assertTrue(e.message?.contains("non-blank host") == true) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt new file mode 100644 index 0000000..e5c642d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt @@ -0,0 +1,33 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Trivial enum coverage for [RegionDownloader.Phase] — `values()` and `valueOf` + * generated members. The wizard progress UI switches on these constants, so the + * set is part of the public contract. + */ +class RegionDownloaderPhaseTest { + + @Test + fun enumHasExactlyTheTwoExpectedPhases() { + val phases = RegionDownloader.Phase.values() + assertEquals(2, phases.size) + assertEquals(RegionDownloader.Phase.BASEMAP, phases[0]) + assertEquals(RegionDownloader.Phase.TILES, phases[1]) + } + + @Test + fun valueOfRoundTripsEachName() { + assertSame( + RegionDownloader.Phase.BASEMAP, + RegionDownloader.Phase.valueOf("BASEMAP"), + ) + assertSame( + RegionDownloader.Phase.TILES, + RegionDownloader.Phase.valueOf("TILES"), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt new file mode 100644 index 0000000..43af268 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt @@ -0,0 +1,94 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.SourceKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * URL-builder smoke tests for [RegionDownloader]. + * + * The class is `internal` so we reach into it via reflection on the + * package-private `base` function rather than re-exposing it for tests + * alone. Tests cover: + * - INTERNET source → iiab.switnet.org/maps/2/ regardless of host + * - IIAB_LAN with bare hostname → http:///maps/ + * - IIAB_LAN with scheme'd URL → preserves scheme + * - IIAB_LAN with blank host → throws (Phase 2 guard) + */ +class RegionDownloaderUrlTest { + + private fun invokeBase(sourceKind: SourceKind, sourceHost: String?): String { + val method = RegionDownloader::class.java.getDeclaredMethod( + "base", SourceKind::class.java, String::class.java, + ).apply { isAccessible = true } + return method.invoke(RegionDownloader, sourceKind, sourceHost) as String + } + + @Test + fun `internet source ignores host`() { + assertEquals( + "https://iiab.switnet.org/maps/2/", + invokeBase(SourceKind.INTERNET, null), + ) + assertEquals( + "https://iiab.switnet.org/maps/2/", + invokeBase(SourceKind.INTERNET, "ignored.example.com"), + ) + } + + @Test + fun `lan bare hostname gets http scheme + maps suffix`() { + assertEquals( + "http://box.adfa.lan/maps/", + invokeBase(SourceKind.IIAB_LAN, "box.adfa.lan"), + ) + } + + @Test + fun `lan host with scheme preserves scheme`() { + assertEquals( + "https://secure.box.local/maps/", + invokeBase(SourceKind.IIAB_LAN, "https://secure.box.local"), + ) + } + + @Test + fun `lan host with trailing slashes is normalised`() { + assertEquals( + "http://box.adfa.lan/maps/", + invokeBase(SourceKind.IIAB_LAN, "http://box.adfa.lan///"), + ) + } + + @Test + fun `lan with blank host rejects`() { + try { + invokeBase(SourceKind.IIAB_LAN, null) + fail("Expected IllegalArgumentException for null host") + } catch (e: java.lang.reflect.InvocationTargetException) { + assertTrue( + "Expected IllegalArgumentException, got ${e.targetException.javaClass}", + e.targetException is IllegalArgumentException, + ) + } + try { + invokeBase(SourceKind.IIAB_LAN, " ") + fail("Expected IllegalArgumentException for blank host") + } catch (e: java.lang.reflect.InvocationTargetException) { + assertTrue( + "Expected IllegalArgumentException, got ${e.targetException.javaClass}", + e.targetException is IllegalArgumentException, + ) + } + } + + @Test + fun `source kind wire values match meta-json contract`() { + // Catches accidental rename — the meta.json schema depends on these. + assertEquals("iiab-lan", SourceKind.IIAB_LAN.wireValue) + assertEquals("internet", SourceKind.INTERNET.wireValue) + assertEquals("unknown", SourceKind.UNKNOWN.wireValue) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt new file mode 100644 index 0000000..ab3132a --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt @@ -0,0 +1,78 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.RegionId +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [RegionCache.isValidRegionId] — the security-critical allowlist + * that gates a regionId becoming a cache-directory name. Covers the Unicode + * (non-Latin) support added 2026-06-04 alongside the path-traversal, casing and + * length guards that must keep holding. + * + * Control/format chars are built from code points (not embedded raw) so this + * source stays free of null bytes and bidi-control "Trojan Source" sequences; the + * script literals are ordinary UTF-8 letters and safe to embed verbatim. + */ +class RegionIdValidationTest { + + @Test + fun accepts_ascii_kebab() { + assertTrue(RegionCache.isValidRegionId("san-francisco")) + assertTrue(RegionCache.isValidRegionId("region1")) + assertTrue(RegionCache.isValidRegionId("a")) + } + + @Test + fun accepts_non_latin_scripts() { + // The point of the change: a user names a region in their own script. + assertTrue("CJK", RegionCache.isValidRegionId("北京")) + assertTrue("Arabic", RegionCache.isValidRegionId("القاهرة")) + assertTrue("Cyrillic", RegionCache.isValidRegionId("москва")) + assertTrue("accented Latin", RegionCache.isValidRegionId("córdoba")) + assertTrue("Devanagari", RegionCache.isValidRegionId("मुंबई")) + assertTrue("script + ascii + hyphen", RegionCache.isValidRegionId("tokyo-東京")) + } + + @Test + fun rejects_uppercase() { + // Lowercase-only keeps case-variants from spawning colliding directories. + assertFalse(RegionCache.isValidRegionId("Café")) + assertFalse(RegionCache.isValidRegionId("SF")) + assertFalse(RegionCache.isValidRegionId("Москва")) + } + + @Test + fun rejects_path_traversal_and_separators() { + assertFalse(RegionCache.isValidRegionId("..")) + assertFalse(RegionCache.isValidRegionId("../etc")) + assertFalse(RegionCache.isValidRegionId("a/b")) + assertFalse(RegionCache.isValidRegionId("a\\b")) + assertFalse(RegionCache.isValidRegionId("a.b")) + assertFalse(RegionCache.isValidRegionId("/abs")) + } + + @Test + fun rejects_leading_hyphen_blank_and_whitespace() { + assertFalse(RegionCache.isValidRegionId("-foo")) + assertFalse(RegionCache.isValidRegionId("")) + assertFalse(RegionCache.isValidRegionId("a b")) + assertFalse(RegionCache.isValidRegionId("北京 ")) // trailing space + } + + @Test + fun rejects_control_format_and_separator_codepoints() { + // null, tab, RTL override, zero-width joiner, fullwidth solidus. + for (cp in listOf(0x0000, 0x0009, 0x202E, 0x200D, 0xFF0F)) { + val s = "a" + Char(cp) + "b" + assertFalse("U+%04X must be rejected".format(cp), RegionCache.isValidRegionId(s)) + } + } + + @Test + fun length_is_capped() { + assertTrue(RegionCache.isValidRegionId("a".repeat(RegionId.MAX_LEN))) + assertFalse(RegionCache.isValidRegionId("a".repeat(RegionId.MAX_LEN + 1))) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt new file mode 100644 index 0000000..f5b7b93 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt @@ -0,0 +1,171 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Branch-coverage tests for [RegionInfo]'s hand-written `equals` / `hashCode`. + * + * The data class overrides equality so the `bbox` [DoubleArray] is compared + * structurally (`contentEquals`) rather than by reference. This walks every + * field-mismatch branch, the self / other-type short-circuits, and the four + * bbox null-vs-set permutations. + */ +class RegionInfoCoverageTest { + + private val dir = File("/tmp/region-info-coverage") + + /** A fully-populated canonical instance every test mutates one field of. */ + private fun base(): RegionInfo = RegionInfo( + regionId = "addis-ababa", + displayName = "Addis Ababa", + sizeBytes = 4096L, + downloadedAt = 1735000000000L, + lastUsedAt = 1735100000000L, + source = "internet", + sourceHost = "box.adfa.lan", + layers = listOf("vector", "basemap"), + directory = dir, + bbox = doubleArrayOf(1.0, 2.0, 3.0, 4.0), + ) + + // ----- self / other-type short-circuits ----- + + @Test + fun equalsIsReflexive() { + val a = base() + assertTrue(a == a) + } + + @Test + fun equalsRejectsOtherType() { + assertFalse(base().equals("not-a-region-info")) + assertFalse(base().equals(null)) + } + + @Test + fun equalIdenticalInstances() { + // Two independently-constructed-but-equal instances compare equal and + // share a hashCode (the contract the override exists to satisfy). + val a = base() + val b = base() + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + // ----- one branch per scalar field differing ----- + + @Test + fun differsByRegionId() { + assertNotEquals(base(), base().copy(regionId = "kathmandu")) + } + + @Test + fun differsByDisplayName() { + assertNotEquals(base(), base().copy(displayName = "Other")) + } + + @Test + fun differsBySizeBytes() { + assertNotEquals(base(), base().copy(sizeBytes = 1L)) + } + + @Test + fun differsByDownloadedAt() { + assertNotEquals(base(), base().copy(downloadedAt = 999L)) + // null-vs-set on the same nullable field. + assertNotEquals(base(), base().copy(downloadedAt = null)) + } + + @Test + fun differsByLastUsedAt() { + assertNotEquals(base(), base().copy(lastUsedAt = 999L)) + assertNotEquals(base(), base().copy(lastUsedAt = null)) + } + + @Test + fun differsBySource() { + assertNotEquals(base(), base().copy(source = "iiab-lan")) + } + + @Test + fun differsBySourceHost() { + assertNotEquals(base(), base().copy(sourceHost = "other.lan")) + assertNotEquals(base(), base().copy(sourceHost = null)) + } + + @Test + fun differsByLayers() { + assertNotEquals(base(), base().copy(layers = listOf("vector"))) + assertNotEquals(base(), base().copy(layers = emptyList())) + } + + @Test + fun differsByDirectory() { + assertNotEquals(base(), base().copy(directory = File("/tmp/other-dir"))) + } + + // ----- the four bbox null-vs-set permutations ----- + + @Test + fun bbothBboxNull_areEqual() { + val a = base().copy(bbox = null) + val b = base().copy(bbox = null) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun bboxNull_vs_set_areNotEqual() { + // this.bbox == null, other.bbox != null. + assertNotEquals(base().copy(bbox = null), base()) + } + + @Test + fun bboxSet_vs_null_areNotEqual() { + // this.bbox != null, other.bbox == null. + assertNotEquals(base(), base().copy(bbox = null)) + } + + @Test + fun bothBboxSet_sameContents_areEqual() { + // Different array instances, identical contents → contentEquals true. + val a = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + val b = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun bothBboxSet_differentContents_areNotEqual() { + val a = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 8.0)) + val b = base().copy(bbox = doubleArrayOf(5.0, 6.0, 7.0, 9.0)) + assertNotEquals(a, b) + } + + // ----- hashCode null-coalescing branches ----- + + @Test + fun hashCodeHandlesAllNullableFieldsNull() { + // Exercises the `?: 0` fallbacks for downloadedAt/lastUsedAt/sourceHost/bbox. + val sparse = RegionInfo( + regionId = "x", + displayName = "x", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "unknown", + sourceHost = null, + layers = emptyList(), + directory = dir, + bbox = null, + ) + // Stable + equal to an identical sparse instance. + assertEquals(sparse.hashCode(), sparse.copy().hashCode()) + assertEquals(sparse, sparse.copy()) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt new file mode 100644 index 0000000..973568c --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt @@ -0,0 +1,171 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Coverage for [RegionInstaller]. + * + * `apply` takes an injectable `cacheRoot` (null → resolved via + * `RegionCache.rootDir()` *inside* the try). Passing a temp dir lets the whole + * orchestration run on a plain JVM: + * - the success path (valid project + region under the cache root → copied → true), + * - the `!result.success` branch (ProjectMapEmitter rejects → logged → false), + * - and the cache-root-resolution failure (default null → `rootDir()` throws on the + * JVM → caught + routed to `logError` → false), in its injected- and default- + * logger shapes. + * + * The free-space precheck's *insufficient-space* arm needs a constrained volume + * (a temp dir reports the host disk's usable space), so it's left to the android-qa + * device walk; the precheck's compute + proceed path is exercised here. + */ +class RegionInstallerCoverageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun regionInfo(dir: File) = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = dir, + ) + + /** A project dir that satisfies ProjectMapEmitter's "is a Maps project" gate. */ + private fun mapsProject(name: String): File { + val project = tmp.newFolder(name) + // app/ dir + a MapRegionActivity source anywhere under app/src/main. + File(project, "app/src/main/java/com/example/MapRegionActivity.kt").apply { + parentFile.mkdirs() + writeText("class MapRegionActivity") + } + return project + } + + @Test + fun applyCopiesRegionDataIntoTheProjectAndReturnsTrue() { + val cacheRoot = tmp.newFolder("cache-ok") + val regionDir = File(cacheRoot, "test-region").apply { mkdirs() } + File(regionDir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(16)) + File(regionDir, RegionCache.FILE_BASEMAP_PMTILES).writeBytes(ByteArray(8)) + File(regionDir, RegionCache.FILE_META_JSON).writeText("{}") + val project = mapsProject("project-ok") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + cacheRoot = cacheRoot, + ) + + assertTrue("a valid region + project must apply successfully", result) + // The data landed in the project's flat maps assets with the right bytes. + val dest = File(project, "app/src/main/assets/maps") + assertEquals(16L, File(dest, RegionCache.FILE_TILES_PMTILES).length()) + assertEquals(8L, File(dest, RegionCache.FILE_BASEMAP_PMTILES).length()) + assertTrue(File(dest, RegionCache.FILE_META_JSON).exists()) + } + + @Test + fun applyReturnsFalseWhenEmitterRejectsTheRegion() { + // Region is under the cache root but has NO tiles.pmtiles, so + // ProjectMapEmitter returns success=false — exercising RegionInstaller's + // post-rootDir orchestration: the free-space `needed` compute + proceed, + // the ProjectMapEmitter.apply call, and the `!result.success` log + false. + val cacheRoot = tmp.newFolder("cache-notiles") + val regionDir = File(cacheRoot, "test-region").apply { mkdirs() } + File(regionDir, RegionCache.FILE_META_JSON).writeText("{}") // no tiles.pmtiles + val project = mapsProject("project-notiles") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + cacheRoot = cacheRoot, + ) + + assertFalse("a region without tiles must be rejected", result) + // Nothing copied into the project. + assertFalse( + File(project, "app/src/main/assets/maps/${RegionCache.FILE_TILES_PMTILES}").exists(), + ) + } + + @Test + fun applyRoutesFailureToInjectedLogger() { + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("cache", "test-region") + var reportedMessage: String? = null + var reportedThrowable: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { msg, t -> reportedMessage = msg; reportedThrowable = t }, + ) + + assertFalse("must return false on cache-root failure", result) + assertNotNull("logError message must be supplied", reportedMessage) + assertTrue( + "message should name the failing region", + reportedMessage!!.contains("test-region"), + ) + assertNotNull("the caught throwable must be routed to logError", reportedThrowable) + } + + @Test + fun applyUsesDefaultNoOpLoggerWithoutThrowing() { + // Calling apply without a logError lambda exercises the default no-op + // parameter; the catch block must still swallow the exception and + // return false rather than propagating. + val project = tmp.newFolder("project2") + val regionDir = tmp.newFolder("cache2", "test-region") + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + ) + + assertFalse("default-logger apply must still return false, not throw", result) + } + + @Test + fun applyRoutesTheExternalStorageGuardSpecifically() { + // Pin the failure MODE, not just "some throwable". On the JVM the very + // first statement in `apply` — `RegionCache.rootDir()` — throws an + // IllegalStateException("External storage unavailable") because + // Environment.getExternalStorageDirectory() returns null + // (isReturnDefaultValues). If a future refactor reordered apply so a + // DIFFERENT exception surfaced first (e.g. an NPE on info.directory), this + // would catch it. The free-space precheck + ProjectMapEmitter copy + + // !result.success branches all sit AFTER this throw and are unreachable on + // a plain JVM (they need a resolvable cache root — Robolectric/Mockk or a + // device); they're covered by ProjectMapEmitterTest + the android-qa walk. + val project = tmp.newFolder("project3") + val regionDir = tmp.newFolder("cache3", "test-region") + var reportedThrowable: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { _, t -> reportedThrowable = t }, + ) + + assertFalse(result) + assertTrue( + "the routed failure must be an IllegalStateException", + reportedThrowable is IllegalStateException, + ) + assertEquals( + "External storage unavailable", + reportedThrowable?.message, + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt new file mode 100644 index 0000000..99f785b --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt @@ -0,0 +1,52 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [RegionInstaller]. + * + * The happy-path copy reaches `RegionCache.rootDir()`, which calls + * `Environment.getExternalStorageDirectory()` — unavailable in plain JVM unit + * tests. So this test exercises the failure contract: when the cache root can't be + * resolved, [RegionInstaller.apply] must fail gracefully (return false) and route + * the exception to the injected logger rather than throwing. Flat-copy + the + * require-a-Maps-project gate are covered by + * [org.appdevforall.maps.templates.ProjectMapEmitterTest]; full apply coverage + * lives in the android-qa device walk. + */ +class RegionInstallerTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun regionInfo(dir: File) = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = dir, + ) + + @Test + fun `apply fails gracefully and reports the error when the cache root is unavailable`() { + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("cache", "test-region") + var reported: Throwable? = null + + val result = RegionInstaller.apply( + info = regionInfo(regionDir), + projectDir = project, + logError = { _, t -> reported = t }, + ) + + assertFalse("must not throw; must return false when cache root is unavailable", result) + assertTrue("the caught exception should be routed to logError", reported != null) + } +} From b3b53f2bbe55121d5bda659def93b14fa7f150a6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 06/10] =?UTF-8?q?feat(maps):=20UI=20=E2=80=94=20Maps=20tab?= =?UTF-8?q?,=20region=20manager,=20bbox=20picker,=20download=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../appdevforall/maps/ui/BboxOverlayView.kt | 407 +++++ .../maps/ui/BboxPickerFragment.kt | 1320 +++++++++++++++++ .../maps/ui/DownloadProgressFragment.kt | 201 +++ .../maps/ui/PluginChildFragmentFactory.kt | 37 + .../org/appdevforall/maps/ui/RegionAdapter.kt | 155 ++ .../maps/ui/RegionManagerFragment.kt | 647 ++++++++ .../maps/ui/SourcePickerFragment.kt | 341 +++++ .../appdevforall/maps/ui/Step3SaveFragment.kt | 281 ++++ maps/src/main/res/drawable/badge_active.xml | 9 + maps/src/main/res/drawable/badge_internet.xml | 11 + .../src/main/res/drawable/badge_reachable.xml | 12 + maps/src/main/res/drawable/badge_unknown.xml | 10 + .../main/res/drawable/badge_unreachable.xml | 10 + maps/src/main/res/drawable/estimate_bg.xml | 14 + maps/src/main/res/drawable/radio_row_bg.xml | 14 + maps/src/main/res/drawable/region_row_bg.xml | 14 + .../res/drawable/region_row_bg_active.xml | 13 + .../main/res/layout/fragment_bbox_picker.xml | 135 ++ .../res/layout/fragment_download_progress.xml | 173 +++ .../res/layout/fragment_region_manager.xml | 165 +++ .../main/res/layout/fragment_save_region.xml | 142 ++ .../res/layout/fragment_source_picker.xml | 198 +++ maps/src/main/res/layout/item_region.xml | 134 ++ maps/src/main/res/layout/item_summary_row.xml | 29 + maps/src/main/res/values-night/colors.xml | 21 + maps/src/main/res/values/colors.xml | 23 + maps/src/main/res/values/strings.xml | 114 ++ maps/src/main/res/values/styles.xml | 73 + 28 files changed, 4703 insertions(+) create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/PluginChildFragmentFactory.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/SourcePickerFragment.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/ui/Step3SaveFragment.kt create mode 100644 maps/src/main/res/drawable/badge_active.xml create mode 100644 maps/src/main/res/drawable/badge_internet.xml create mode 100644 maps/src/main/res/drawable/badge_reachable.xml create mode 100644 maps/src/main/res/drawable/badge_unknown.xml create mode 100644 maps/src/main/res/drawable/badge_unreachable.xml create mode 100644 maps/src/main/res/drawable/estimate_bg.xml create mode 100644 maps/src/main/res/drawable/radio_row_bg.xml create mode 100644 maps/src/main/res/drawable/region_row_bg.xml create mode 100644 maps/src/main/res/drawable/region_row_bg_active.xml create mode 100644 maps/src/main/res/layout/fragment_bbox_picker.xml create mode 100644 maps/src/main/res/layout/fragment_download_progress.xml create mode 100644 maps/src/main/res/layout/fragment_region_manager.xml create mode 100644 maps/src/main/res/layout/fragment_save_region.xml create mode 100644 maps/src/main/res/layout/fragment_source_picker.xml create mode 100644 maps/src/main/res/layout/item_region.xml create mode 100644 maps/src/main/res/layout/item_summary_row.xml create mode 100644 maps/src/main/res/values-night/colors.xml create mode 100644 maps/src/main/res/values/colors.xml create mode 100644 maps/src/main/res/values/strings.xml create mode 100644 maps/src/main/res/values/styles.xml diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt new file mode 100644 index 0000000..613c2fe --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt @@ -0,0 +1,407 @@ +package org.appdevforall.maps.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.RectF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.core.content.ContextCompat +import org.appdevforall.maps.R +import org.appdevforall.maps.domain.Bbox +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Bounding-box overlay for the "Choose Region" step. + * + * **Geo-anchored model:** the box stores its corners in **lat/lon**, not pixels, + * so it stays anchored to the same patch of the world as the map pans and zooms + * underneath. To draw, the overlay re-projects the stored geo corners to screen + * pixels via a caller-supplied projection (set via [setProjection] from + * [BboxPickerFragment]). + * + * Touch model: + * - Corner handles (48 dp hit targets): consumed by this view — resize the box. + * The dragged corner's pixel position is projected back to lat/lon and the + * corresponding latitude AND longitude are updated, keeping the box + * axis-aligned in lat/lon space. + * - Inside the box (but not on a corner): consumed by this view — translate + * the box. Each move delta is converted from pixel-delta to lat/lon-delta + * and applied to all four corners so the box keeps its dimensions and + * shifts to a new geographic patch. + * - All other touches (outside the box entirely): returned `false` so the + * [MapView] underneath receives them and handles pan/pinch-zoom naturally. + * + * The opposite corner stays anchored; the two adjacent corners each share one + * coordinate with the dragged corner. Result is always a valid axis-aligned + * rectangle in lat/lon space (which renders as a near-rectangle on screen at + * non-polar latitudes with rotation disabled). + */ +class BboxOverlayView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : View(context, attrs, defStyle) { + + /** Listener fires when the user resizes the box. Argument is the **new + * geographic** bbox (the source of truth). */ + fun interface Listener { + fun onBboxChanged(bbox: Bbox) + } + + /** Projection callbacks from the host fragment (wired to MapLibre's + * `map.projection.toScreenLocation` / `fromScreenLocation`). + * `null` until the map is ready. */ + private var toScreen: ((lat: Double, lon: Double) -> Pair)? = null + private var toLatLon: ((x: Float, y: Float) -> Pair)? = null + + /** Source-of-truth geographic bbox. Null until the fragment sets one. */ + private var geoBbox: Bbox? = null + + /** Last computed pixel rect — derived from [geoBbox] + projection on + * every [recomputePixelRect] call. Empty when not renderable. */ + private val rect = RectF() + + private val handleHitRadius = dp(24f) // 48 dp diameter + private val handleVisibleRadius = dp(10f) // 20 dp diameter + private val borderWidth = dp(1.5f) + + private val normalFillColor = Color.argb(46, 80, 80, 80) // 18 % grey + private val normalBorderColor = Color.argb(153, 0, 0, 0) // ~60 % black + private val overBudgetFillColor = Color.argb(64, 211, 47, 47) // ~25 % Material red 700 + private val overBudgetBorderColor = Color.argb(217, 211, 47, 47) // ~85 % Material red 700 + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = normalFillColor + style = Paint.Style.FILL + } + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = borderWidth + color = normalBorderColor + } + + /** When true, the box renders error-red instead of neutral grey — set via + * [setOverBudget] when the estimate exceeds the 1 GB cap. */ + private var overBudget: Boolean = false + private val handleFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.parseColor("#16A34A") + } + private val handleStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = dp(2f) + color = Color.WHITE + } + + private var listener: Listener? = null + private var dragMode: DragMode = DragMode.NONE + /** Last touch position during a TRANSLATE drag. Pixel-deltas between + * successive ACTION_MOVE events get projected to lat/lon-deltas. Null + * outside an active translate. */ + private var lastTouchPx: PointF? = null + private val minSidePx = dp(48f) + /** Smallest box we'll let the user drag down to, in degrees. Prevents a + * bbox from collapsing to zero size when the user drags a corner past + * the opposite corner; also caps the slicer's tile count cost. */ + private val minSideDeg = 0.001 + + init { + runCatching { + handleFillPaint.color = ContextCompat.getColor(context, R.color.plugin_primary) + } + } + + /** + * Wire the projection callbacks from MapLibre's `Projection`. Must be + * called BEFORE [setBboxLatLon] (or call [setBboxLatLon] again after to + * trigger a recompute). + */ + fun setProjection( + toScreen: (lat: Double, lon: Double) -> Pair, + toLatLon: (x: Float, y: Float) -> Pair, + ) { + this.toScreen = toScreen + this.toLatLon = toLatLon + recomputePixelRect() + } + + /** Set the geographic bbox. Triggers a pixel-rect recompute + redraw. */ + fun setBboxLatLon(bbox: Bbox) { + geoBbox = bbox + recomputePixelRect() + } + + /** + * Flip the overlay to "over-budget" styling — red border + tinted fill. + * Driven by [BboxPickerFragment] when the slicer's estimate exceeds the + * 1 GB cap. Visual reinforcement of the inline estimate text so the user + * sees the warning on the map itself, not just below it. + */ + fun setOverBudget(over: Boolean) { + if (over == overBudget) return + overBudget = over + fillPaint.color = if (over) overBudgetFillColor else normalFillColor + borderPaint.color = if (over) overBudgetBorderColor else normalBorderColor + invalidate() + } + + /** Re-project [geoBbox] to screen pixels using the current camera. + * Cheap; safe to call on every camera-move tick. */ + fun recomputePixelRect() { + val bbox = geoBbox + val project = toScreen + if (bbox == null || project == null || width == 0 || height == 0) { + if (!rect.isEmpty) { + rect.setEmpty() + invalidate() + } + return + } + // Project all 4 corners. The MapLibre projection puts north at top, + // so (north, west) is the TL corner and (south, east) is the BR. + val (tlX, tlY) = project(bbox.north, bbox.west) + val (brX, brY) = project(bbox.south, bbox.east) + rect.set( + min(tlX, brX), + min(tlY, brY), + max(tlX, brX), + max(tlY, brY), + ) + invalidate() + } + + fun setListener(l: Listener?) { + listener = l + } + + /** Current pixel-projected rect. Mostly used for hit-tests + tests. */ + fun currentBboxPx(): RectF = RectF(rect) + + /** Current geographic bbox, or null if none set. */ + fun currentBboxGeo(): Bbox? = geoBbox + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Re-project on resize (e.g., bottom sheet expand/collapse). + recomputePixelRect() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (rect.isEmpty) return + canvas.drawRect(rect, fillPaint) + canvas.drawRect(rect, borderPaint) + // Corner handles + val corners = floatArrayOf( + rect.left, rect.top, + rect.right, rect.top, + rect.left, rect.bottom, + rect.right, rect.bottom + ) + var i = 0 + while (i < corners.size) { + val cx = corners[i] + val cy = corners[i + 1] + canvas.drawCircle(cx, cy, handleVisibleRadius, handleFillPaint) + canvas.drawCircle(cx, cy, handleVisibleRadius, handleStrokePaint) + i += 2 + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val mode = hitTest(event.x, event.y) + if (mode == DragMode.NONE) return false + dragMode = mode + if (mode == DragMode.TRANSLATE) { + lastTouchPx = PointF(event.x, event.y) + } + parent?.requestDisallowInterceptTouchEvent(true) + return true + } + + MotionEvent.ACTION_MOVE -> { + if (dragMode == DragMode.NONE) return false + if (dragMode == DragMode.TRANSLATE) { + handleTranslateMove(event.x, event.y) + } else { + handleDragMove(event.x, event.y) + } + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (dragMode != DragMode.NONE) { + // Fire ONE listener event at drag-end (with the final + // geo bbox). Per-frame fires during drag would re-trigger + // the slicer on every pixel, which is wasteful. + geoBbox?.let { listener?.onBboxChanged(it) } + dragMode = DragMode.NONE + lastTouchPx = null + parent?.requestDisallowInterceptTouchEvent(false) + return true + } + return false + } + } + return super.onTouchEvent(event) + } + + /** + * Hit-test priority: + * 1. Corner handles (with a size-scaled hit radius) → RESIZE_* + * 2. Inside the box (not on a corner) → TRANSLATE + * 3. Outside → NONE (event passes through to MapView) + * + * **Corner radius scales with box size** so that corners stay usable on + * small on-screen boxes while still leaving a translate-only band in the + * box centre. The effective radius is `min(handleHitRadius, smallerSide / + * 3f)`. With this rule: + * - 60px box → ~20px corner radius, ~20px centre band → 4 corners + a + * finger-tip-sized translate target. + * - 240px+ box → 48px corner radius (full handle size), generous centre. + * + * Scaling keeps corners reachable on small boxes (e.g. the world-view default) + * while preserving a translate-only band in the centre. + */ + private fun hitTest(x: Float, y: Float): DragMode { + if (rect.isEmpty) return DragMode.NONE + val smallerSide = minOf(rect.width(), rect.height()) + val effectiveRadius = minOf(handleHitRadius, smallerSide / 3f) + // Below ~6dp the corner zones become finger-untargetable; fall back to + // translate-only for genuinely tiny boxes. + if (effectiveRadius < dp(6f)) { + return if (rect.contains(x, y)) DragMode.TRANSLATE else DragMode.NONE + } + val tlDist = distance(x, y, rect.left, rect.top) + val trDist = distance(x, y, rect.right, rect.top) + val blDist = distance(x, y, rect.left, rect.bottom) + val brDist = distance(x, y, rect.right, rect.bottom) + val nearest = minOf(tlDist, trDist, blDist, brDist) + if (nearest <= effectiveRadius) { + return when (nearest) { + tlDist -> DragMode.RESIZE_TL + trDist -> DragMode.RESIZE_TR + blDist -> DragMode.RESIZE_BL + brDist -> DragMode.RESIZE_BR + else -> DragMode.NONE + } + } + if (rect.contains(x, y)) { + return DragMode.TRANSLATE + } + return DragMode.NONE + } + + /** + * Apply a translate drag. Project the previous and current touch points + * to lat/lon, compute the delta, and shift all four corners of the geo + * bbox by that delta. Preserves the box's lat/lon dimensions exactly + * (until clamped at the Web-Mercator pole limit). + */ + private fun handleTranslateMove(pxX: Float, pxY: Float) { + val current = geoBbox ?: return + val unproject = toLatLon ?: return + val last = lastTouchPx ?: return + val (lastLat, lastLon) = unproject( + last.x.coerceIn(0f, (width - 1).toFloat()), + last.y.coerceIn(0f, (height - 1).toFloat()), + ) + val (newLat, newLon) = unproject( + pxX.coerceIn(0f, (width - 1).toFloat()), + pxY.coerceIn(0f, (height - 1).toFloat()), + ) + if (!lastLat.isFinite() || !lastLon.isFinite() || + !newLat.isFinite() || !newLon.isFinite() + ) return + val dLat = newLat - lastLat + val dLon = newLon - lastLon + // Clamp at Web-Mercator's valid range. If a shift would push past the + // pole, snap back so the box shrinks rather than wraps — gentler UX + // than a sudden disappearance. + val newSouth = (current.south + dLat).coerceIn(-85.0511, 85.0511) + val newNorth = (current.north + dLat).coerceIn(-85.0511, 85.0511) + if (newNorth - newSouth < minSideDeg) return + val updated = runCatching { + Bbox( + south = newSouth, + west = current.west + dLon, + north = newNorth, + east = current.east + dLon, + ) + }.getOrNull() ?: return + geoBbox = updated + lastTouchPx = PointF(pxX, pxY) + recomputePixelRect() + } + + /** + * Apply a corner drag. Projects the new pixel position back to lat/lon, + * mutates the dragged corner of [geoBbox], and recomputes the pixel rect. + * Keeps the box axis-aligned in geographic space. + */ + private fun handleDragMove(pxX: Float, pxY: Float) { + val current = geoBbox ?: return + val unproject = toLatLon ?: return + val (newLat, newLon) = unproject( + pxX.coerceIn(0f, (width - 1).toFloat()), + pxY.coerceIn(0f, (height - 1).toFloat()), + ) + if (!newLat.isFinite() || !newLon.isFinite()) return + // Clamp lat to Web-Mercator's valid range. + val clampedLat = newLat.coerceIn(-85.0511, 85.0511) + val updated = when (dragMode) { + DragMode.RESIZE_TL -> { + // Top-left = (north, west). Update lat→new north, lon→new west. + val newNorth = clampedLat.coerceAtLeast(current.south + minSideDeg) + val newWest = newLon.coerceAtMost(current.east - minSideDeg) + runCatching { + Bbox(south = current.south, west = newWest, north = newNorth, east = current.east) + }.getOrNull() + } + DragMode.RESIZE_TR -> { + // Top-right = (north, east). + val newNorth = clampedLat.coerceAtLeast(current.south + minSideDeg) + val newEast = newLon.coerceAtLeast(current.west + minSideDeg) + runCatching { + Bbox(south = current.south, west = current.west, north = newNorth, east = newEast) + }.getOrNull() + } + DragMode.RESIZE_BL -> { + // Bottom-left = (south, west). + val newSouth = clampedLat.coerceAtMost(current.north - minSideDeg) + val newWest = newLon.coerceAtMost(current.east - minSideDeg) + runCatching { + Bbox(south = newSouth, west = newWest, north = current.north, east = current.east) + }.getOrNull() + } + DragMode.RESIZE_BR -> { + // Bottom-right = (south, east). + val newSouth = clampedLat.coerceAtMost(current.north - minSideDeg) + val newEast = newLon.coerceAtLeast(current.west + minSideDeg) + runCatching { + Bbox(south = newSouth, west = current.west, north = current.north, east = newEast) + }.getOrNull() + } + DragMode.NONE, DragMode.TRANSLATE -> null + } ?: return + geoBbox = updated + recomputePixelRect() + } + + private fun dp(value: Float): Float = + value * resources.displayMetrics.density + + private fun distance(x1: Float, y1: Float, x2: Float, y2: Float): Float = + sqrt((x1 - x2).pow(2) + (y1 - y2).pow(2)) + + private enum class DragMode { NONE, RESIZE_TL, RESIZE_TR, RESIZE_BL, RESIZE_BR, TRANSLATE } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt new file mode 100644 index 0000000..88e5377 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -0,0 +1,1320 @@ +package org.appdevforall.maps.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Color +import android.location.Location +import android.location.LocationManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.R +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.domain.AutoShrinkBbox +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.domain.TileEstimate +import org.appdevforall.maps.domain.ZoomCap +import org.appdevforall.maps.slicer.PmtilesRegionSlicer +import org.appdevforall.maps.slicer.SliceEstimateCache +import org.appdevforall.maps.slicer.TileEntry +import com.google.android.gms.location.LocationServices +import com.google.android.material.button.MaterialButton +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import org.maplibre.android.MapLibre +import org.maplibre.android.WellKnownTileServer +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.modes.CameraMode +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style + +/** + * Wizard step 2 — Choose Region. + * + * The bbox rectangle is fixed in the center of the screen; the map pans/zooms + * underneath it via standard MapLibre gestures. [BboxOverlayView] intercepts + * only corner-handle touches; everything else falls through to the [MapView]. + * After each camera idle, the bbox's lat/lon bounds are recomputed from the + * pixel rect via [MapLibreMap.Projection]. Corner handles resize the box. + * + * Map background loads IIAB OpenMapTiles vector tiles via the `pmtiles://` URL + * scheme from the source picked in Step 1, falling back to a solid `#e8f4f8` + * background if that source is unreachable. No third-party tile servers. + * + * This is a [DialogFragment], not a regular `Fragment`, so the map UI runs in + * its own `Window` above the host Activity. That's the only way to escape CoGo's + * `ContentTranslatingDrawerLayout` gesture capture: every in-Activity plugin UI + * surface sits inside the host DrawerLayout and `setDrawerLockMode` is silently + * ignored by CoGo's `InterceptableDrawerLayout` subclass. A DialogFragment's own + * Window keeps the host DrawerLayout out of the touch dispatch chain, so + * left-edge swipe-rights pan the map instead of opening the file-tree drawer. + */ +class BboxPickerFragment : androidx.fragment.app.DialogFragment() { + + interface Listener { + fun onBboxPickerNext( + bbox: Bbox, + /** + * Slicer-derived estimate, or null if the slicer hasn't returned + * yet (or failed). Step 3 should treat null as "calculating" and + * show that state until the cache lookup completes. + */ + estimate: TileEstimate?, + prefillRegionId: String?, + prefillRegionName: String?, + ) + + fun onBboxPickerBack() + } + + companion object { + const val ARG_PREFILL_REGION_ID = "prefillRegionId" + const val ARG_PREFILL_DISPLAY_NAME = "prefillDisplayName" + const val ARG_PREFILL_BBOX = "prefillBbox" + const val ARG_SOURCE_KIND = "sourceKindWire" + const val ARG_SOURCE_HOST = "sourceHost" + const val ARG_ZOOM_MIN = "zoomMin" + const val ARG_ZOOM_MAX = "zoomMax" + + const val REQUEST_LOCATION_PERMISSION = 0xC0A4 + + /** Default bbox edge (km) when GPS is available: 50×50 km around the fix. */ + private const val GPS_DEFAULT_EDGE_KM = 50.0 + + /** Default bbox edge (km) when GPS is unavailable: 200×200 km on the GMT line. */ + private const val NO_GPS_DEFAULT_EDGE_KM = 200.0 + + private const val DEFAULT_BBOX_EDGE_KM = NO_GPS_DEFAULT_EDGE_KM + + // No-GPS world-view default: a 20°×20° box centered ~lat 15°N, lon 5°E + // (Algeria / Mali / Mediterranean). Visually prominent at world-view zoom + // so the user sees they can drag/resize it. Sized to match + // Bbox.isReasonableRegionSize()'s ≤ 20° threshold so the startup state is + // already a valid region. + private const val NO_GPS_BBOX_SOUTH = 5.0 + private const val NO_GPS_BBOX_NORTH = 25.0 + private const val NO_GPS_BBOX_WEST = -5.0 + private const val NO_GPS_BBOX_EAST = 15.0 + + /** + * Fallback camera when GPS is denied or unresolved. applyInitialBbox + * normally drives the camera; these only cover early frames before the + * map style is ready. + */ + private const val FALLBACK_LAT = 0.0 + private const val FALLBACK_LON = 0.0 + private const val FALLBACK_ZOOM = 1.0 + + /** Zoom for the no-GPS world-view default: whole world plus a bit of detail. */ + private const val NO_GPS_WORLD_ZOOM = 1.5 + + /** + * Hard cap on total download size (1 GiB). Applied to the slicer-derived + * vector estimate plus a small basemap allowance. Step 2 disables + * Next over the cap; Step 3 disables Save symmetrically. + */ + internal const val MAX_DOWNLOAD_BYTES: Long = 1L * 1024L * 1024L * 1024L // 1 GiB + + /** Headroom beyond the slicer's vector estimate, for per-tile overhead in + * the sliced archive. The Natural Earth basemap is copied from the plugin + * bundle (not downloaded), so it no longer counts toward the download size. */ + internal const val NON_VECTOR_ALLOWANCE_BYTES: Long = 4L * 1024L * 1024L + + /** Hard timeout for the slicer's directory walk. Surfaces an actionable + * failure state instead of letting the estimate hang on "Calculating…". */ + internal const val SLICER_TIMEOUT_MS: Long = 60_000L + + /** Estimate-text color when the bbox is downloadable (low-emphasis body). */ + private val ESTIMATE_TEXT_NORMAL: Int = Color.parseColor("#5F6368") + /** Estimate-text color when the bbox is too large — Material red 700, same + * hue as the over-budget bbox border in [BboxOverlayView.setOverBudget]. */ + private val ESTIMATE_TEXT_ERROR: Int = Color.parseColor("#D32F2F") + + /** Screen fraction the GPS-default bbox occupies on first open (~half width). */ + private const val GPS_DEFAULT_SCREEN_FRACTION = 0.5 + + /** Web-Mercator meters-per-pixel at zoom 0 at the equator + * (2 × π × earthRadius / 256). Used for the GPS-default zoom calculation. */ + private const val MERCATOR_M_PER_PX_Z0 = 156543.034 + + /** Debounce delay between bbox drag-end and slicer kickoff (ms). */ + private const val ESTIMATE_DEBOUNCE_MS = 300L + + /** + * Wait this long after the camera goes idle before deciding to shrink + * the bbox to fit the new viewport — gives the user time to finish a + * multi-step pan/zoom gesture without thrashing the selection. + */ + private const val AUTO_SHRINK_DEBOUNCE_MS = 1_000L + + /** + * Inset the auto-shrunk bbox by this fraction of the viewport on each side. + * 0.35 leaves the selection at ~30%×30% of the visible area so the user has + * a comfortable grab-margin to pan with — a bbox that fills the viewport + * intercepts every pan gesture as a drag-bbox. + */ + private const val AUTO_SHRINK_MARGIN = 0.35 + + /** Default zoom range (matches RegionDownloader defaults). */ + private const val DEFAULT_ZOOM_MIN = 6 + private const val DEFAULT_ZOOM_MAX = 14 + + /** + * Glyph PBFs bundled in plugin assets under `assets/fonts/{stack}/{range}.pbf`. + * Extracted to filesystem at first picker open because MapLibre's AssetFileSource + * reads only the host APK's assets, not the plugin's. Latin coverage only; + * non-Latin scripts render as tofu boxes until those ranges are added. + */ + private val BUNDLED_FONT_STACKS: Map> = mapOf( + "Noto Sans Regular" to listOf("0-255", "256-511", "512-767", "7680-7935", "8192-8447"), + "Noto Sans Italic" to listOf("0-255", "256-511", "512-767", "7680-7935", "8192-8447"), + ) + + fun newInstance( + prefillRegionId: String? = null, + prefillDisplayName: String? = null, + prefillBbox: DoubleArray? = null, + sourceKind: SourceKind = SourceKind.UNKNOWN, + sourceHost: String? = null, + zoomMin: Int = DEFAULT_ZOOM_MIN, + zoomMax: Int = DEFAULT_ZOOM_MAX, + ): BboxPickerFragment = BboxPickerFragment().apply { + arguments = Bundle().apply { + if (prefillRegionId != null) putString(ARG_PREFILL_REGION_ID, prefillRegionId) + if (prefillDisplayName != null) putString(ARG_PREFILL_DISPLAY_NAME, prefillDisplayName) + if (prefillBbox != null) putDoubleArray(ARG_PREFILL_BBOX, prefillBbox) + putString(ARG_SOURCE_KIND, sourceKind.wireValue) + if (sourceHost != null) putString(ARG_SOURCE_HOST, sourceHost) + putInt(ARG_ZOOM_MIN, zoomMin) + putInt(ARG_ZOOM_MAX, zoomMax) + } + } + } + + private lateinit var mapView: MapView + private lateinit var bboxOverlay: BboxOverlayView + private lateinit var estimateSizeLine: TextView + private lateinit var estimateFree: TextView + private lateinit var btnBack: MaterialButton + private lateinit var btnNext: MaterialButton + + /** Retained so we can enable the LocationComponent once style + permission are both ready. */ + private var mapLibreMap: MapLibreMap? = null + + /** Lat/lon center the user's bbox is anchored at. */ + private var anchorLat: Double = FALLBACK_LAT + private var anchorLon: Double = FALLBACK_LON + + /** + * True once GPS (or any LocationManager provider) has produced a real fix. + * Don't infer this from `anchorLat != FALLBACK_LAT` — the camera-idle + * listener overwrites anchorLat early in the lifecycle with whatever + * MapLibre's default cameraPosition.target.latitude happens to be (rounded + * floats break the equality test). + */ + private var haveRealGpsFix: Boolean = false + + private var currentBbox: Bbox = Bbox.aroundPoint(FALLBACK_LAT, FALLBACK_LON, DEFAULT_BBOX_EDGE_KM) + + private var prefillRegionId: String? = null + private var prefillDisplayName: String? = null + + /** Source context piped in from Step 1, needed for the slicer-driven estimate. */ + private var sourceKind: SourceKind = SourceKind.UNKNOWN + private var sourceHost: String? = null + private var zoomMin: Int = DEFAULT_ZOOM_MIN + private var zoomMax: Int = DEFAULT_ZOOM_MAX + + /** + * Pending coroutine for the debounced slicer call. We cancel-and-restart + * it on every bbox change so only the latest drag-end actually runs. + */ + private var estimateJob: Job? = null + + /** Held so [onDestroyView] can unregister them — they close over the + * overlay view and would leak across dialog re-creates otherwise. */ + private var cameraMoveListener: org.maplibre.android.maps.MapLibreMap.OnCameraMoveListener? = null + private var cameraIdleListener: org.maplibre.android.maps.MapLibreMap.OnCameraIdleListener? = null + + /** + * Pending coroutine for the debounced "shrink the bbox to fit the viewport" + * check. Cancelled + restarted on every camera idle so only the last settled + * view triggers the check. + */ + private var autoShrinkJob: Job? = null + + /** + * Real bytes estimate from the slicer (or null while still computing / + * before the first network call returns). Drives Step 3's summary card + * when present. + */ + private var realByteEstimate: Long? = null + + /** + * True when the most recent slicer call failed (network, server, parse). + * [renderEstimate] uses it to show a visible error state instead of leaving + * the UI stuck on a "calculating…" message forever. + */ + private var realEstimateFailed: Boolean = false + + /** Guard: only fire the permission ask once per fragment instance. */ + private var permissionAsked: Boolean = false + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(MapsPlugin.PLUGIN_ID, inflater) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Material3 fullscreen dialog: no rounded corners, status-bar-respecting, + // edge-to-edge content. We want the whole map visible. + setStyle( + STYLE_NORMAL, + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog, + ) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): android.app.Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + // Fullscreen dim-free window so the map fills the screen. + dialog.window?.apply { + setLayout( + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.MATCH_PARENT, + ) + setBackgroundDrawableResource(android.R.color.transparent) + } + return dialog + } + + override fun onStart() { + super.onStart() + // Re-assert MATCH_PARENT after the dialog rebuilds its window on start. + dialog?.window?.setLayout( + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.MATCH_PARENT, + ) + if (::mapView.isInitialized) mapView.onStart() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + // MapLibre.getInstance must run before MapView is inflated, and must use + // the 3-arg form — the 1-arg variant is a deprecated no-op (the .so loads + // but the Java-side INSTANCE stays null, so MapView.initialize() crashes). + // + // CRITICAL: pass a context whose getResources() resolves the PLUGIN's + // resources, not the host app's. MapLibre stores this context and on a + // single map tap does `getApplicationContext().getResources() + // .getDimension(R.dimen.maplibre_eight_dp)` inside AnnotationManager's + // hit-test. The host Resources have no plugin resource package attached, + // so that lookup throws Resources$NotFoundException and crashes the whole + // host on a misclick. The plugin inflater's context resolves plugin + // resources; wrap it so getApplicationContext() returns itself. (An + // addOnMapClickListener{true} mitigation can't help — it fires AFTER + // AnnotationManager.onTap, where the crash already happened.) + // + // MapLibre.INSTANCE is a process-life singleton (first getInstance wins), + // so this wrapper is pinned for the process — a bounded one-object + // retention, not a per-open leak. The picker is the only in-process + // MapLibre user so it reliably wins; if a future path inits MapLibre + // first, this wrapper must move there. + val pluginResources = inflater.context.resources + val mapLibreContext = object : android.content.ContextWrapper( + requireContext().applicationContext + ) { + override fun getResources(): android.content.res.Resources = pluginResources + override fun getApplicationContext(): android.content.Context = this + } + try { + MapLibre.getInstance( + mapLibreContext, + null, + WellKnownTileServer.MapLibre + ) + } catch (t: Throwable) { + android.util.Log.e("MapsPlugin", "MapLibre.getInstance failed", t) + MapsPlugin.pluginContext?.logger?.warn( + "MapLibre.getInstance failed: ${t.message}" + ) + } + return inflater.inflate(R.layout.fragment_bbox_picker, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + mapView = view.findViewById(R.id.map_view) + bboxOverlay = view.findViewById(R.id.bbox_overlay) + estimateSizeLine = view.findViewById(R.id.estimate_size_line) + estimateFree = view.findViewById(R.id.estimate_free) + btnBack = view.findViewById(R.id.btn_back) + btnNext = view.findViewById(R.id.btn_next) + + val args = arguments + prefillRegionId = args?.getString(ARG_PREFILL_REGION_ID) + prefillDisplayName = args?.getString(ARG_PREFILL_DISPLAY_NAME) + sourceKind = SourceKind.values().firstOrNull { + it.wireValue == args?.getString(ARG_SOURCE_KIND) + } ?: SourceKind.UNKNOWN + sourceHost = args?.getString(ARG_SOURCE_HOST) + zoomMin = args?.getInt(ARG_ZOOM_MIN, DEFAULT_ZOOM_MIN) ?: DEFAULT_ZOOM_MIN + zoomMax = args?.getInt(ARG_ZOOM_MAX, DEFAULT_ZOOM_MAX) ?: DEFAULT_ZOOM_MAX + val prefillBbox = args?.getDoubleArray(ARG_PREFILL_BBOX) + if (prefillBbox != null && prefillBbox.size == 4) { + anchorLat = (prefillBbox[0] + prefillBbox[2]) / 2.0 + anchorLon = (prefillBbox[1] + prefillBbox[3]) / 2.0 + currentBbox = runCatching { + Bbox(prefillBbox[0], prefillBbox[1], prefillBbox[2], prefillBbox[3]) + }.getOrDefault(currentBbox) + } + + mapView.onCreate(savedInstanceState) + mapView.getMapAsync { map -> + mapLibreMap = map + + // Resolve IIAB PMTiles URL on a background thread, then apply the + // style. We show a solid-color fallback immediately (no delay) and + // swap in the real tiles when the URL is ready. + // + // URL source: same URL RegionDownloader uses for the actual download, + // so estimate ≈ download size and the basemap matches the data. + viewLifecycleOwner.lifecycleScope.launch { + // Resolve the tiles URL AND extract the bundled glyph PBFs off the + // main thread — both touch disk (the latter copies ~10 PBFs out of + // plugin assets on a cold cache), which trips StrictMode's + // disk-read policy if done on the UI thread. + val (pmtilesUrl, fontsRoot) = withContext(Dispatchers.IO) { + resolveIiabTilesUrl() to ensureFontsExtracted()?.absolutePath + } + if (view == null) return@launch + val styleJson = buildIiabStyle(pmtilesUrl, fontsRoot) + map.setStyle(Style.Builder().fromJson(styleJson)) { style -> + applyStyleReady(map, style, prefillBbox) + } + } + + // Geo-anchored model: the bbox is stored in lat/lon. Camera move/idle + // does NOT change the bbox — it only re-projects the stored bbox to + // fresh screen pixels so the overlay tracks the map's pan/zoom. + map.uiSettings.isRotateGesturesEnabled = false + // No annotations on the picker, so consume map clicks. This is just + // tidy tap-consumption — the AnnotationManager dimen-resolve crash is + // prevented by the plugin-resource ContextWrapper in onCreateView, not + // here (that crash fires inside onTap, before click listeners run). + map.addOnMapClickListener { true } + // Hold the listener refs so onDestroyView can clear them; the lambdas + // close over bboxOverlay and would otherwise leak across re-creates. + val moveListener = org.maplibre.android.maps.MapLibreMap.OnCameraMoveListener { + bboxOverlay.recomputePixelRect() + } + val idleListener = org.maplibre.android.maps.MapLibreMap.OnCameraIdleListener { + val center = map.cameraPosition.target ?: return@OnCameraIdleListener + anchorLat = center.latitude + anchorLon = center.longitude + bboxOverlay.recomputePixelRect() + scheduleAutoShrinkBbox(map) + } + map.addOnCameraMoveListener(moveListener) + map.addOnCameraIdleListener(idleListener) + cameraMoveListener = moveListener + cameraIdleListener = idleListener + // Wire the projection callbacks so the overlay can convert between + // geographic and screen coordinates. + bboxOverlay.setProjection( + toScreen = { lat, lon -> + val p = map.projection.toScreenLocation(LatLng(lat, lon)) + p.x to p.y + }, + toLatLon = { x, y -> + val ll = map.projection.fromScreenLocation( + android.graphics.PointF(x, y) + ) + ll.latitude to ll.longitude + }, + ) + } + + // User drag-end on a corner handle updates currentBbox + kicks the + // slicer. Camera move/idle do NOT touch currentBbox. + bboxOverlay.setListener { newBbox -> + currentBbox = newBbox + zoomMax = ZoomCap.pickZoomMax(currentBbox, zoomMin) + realByteEstimate = null + realEstimateFailed = false + renderEstimate() + updateBboxDimsLabel() + scheduleRealEstimate() + } + + btnBack.setOnClickListener { back() } + btnNext.setOnClickListener { next() } + + updateBboxDimsLabel() + } + + // ----- Map lifecycle proxying (onStart is at dialog-setup section above) ----- + + override fun onResume() { super.onResume(); mapView.onResume() } + override fun onPause() { super.onPause(); mapView.onPause() } + override fun onStop() { super.onStop(); mapView.onStop() } + override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() } + override fun onDestroyView() { + super.onDestroyView() + estimateJob?.cancel() + autoShrinkJob?.cancel() + // Unregister listeners that capture the bbox overlay before + // disposing the map view — prevents a leak across dialog re-creates. + if (::mapView.isInitialized) { + runCatching { + mapView.getMapAsync { map -> + cameraMoveListener?.let { map.removeOnCameraMoveListener(it) } + cameraIdleListener?.let { map.removeOnCameraIdleListener(it) } + } + } + } + cameraMoveListener = null + cameraIdleListener = null + runCatching { mapView.onDestroy() } + } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (::mapView.isInitialized) mapView.onSaveInstanceState(outState) + } + + // ----- Location permission + fix ----- + + /** + * Enable the MapLibre location component (GPS blue dot) using the given + * [style]. Safe to call after the style is loaded AND location permission + * is granted. Failures are swallowed — the dot is cosmetic; the bbox + * picker still works without it. + * + * [CameraMode.NONE] keeps the camera on the user-chosen anchor rather than + * tracking to the GPS fix (we want the user to stay in control of the + * viewport). [RenderMode.NORMAL] draws the standard blue dot + accuracy + * circle without the compass bearing arrow. + */ + @SuppressLint("MissingPermission") + private fun enableLocationComponent(map: MapLibreMap, style: Style) { + runCatching { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + LocationComponentActivationOptions.builder(requireContext(), style) + .useDefaultLocationEngine(true) + .build() + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.cameraMode = CameraMode.NONE + locationComponent.renderMode = RenderMode.NORMAL + }.onFailure { + MapsPlugin.pluginContext?.logger?.warn( + "LocationComponent activation failed: ${it.message}" + ) + } + } + + @Suppress("DEPRECATION") // Fragment.requestPermissions is deprecated in + // favor of ActivityResultContracts.RequestPermission, but the contract API + // needs ComponentActivity registration that races our plugin-fragment + // lifecycle. Stick with the legacy API; it still works on target SDK 34. + private fun maybeRequestLocation() { + val ctx = context ?: return + val granted = ContextCompat.checkSelfPermission( + ctx, Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + fetchLastKnownLocation() + return + } + // Detect "permanently denied": the user previously tapped Deny (or "Don't + // ask again"), so any new requestPermissions call is silently denied with + // no dialog. Route them to Settings instead of repeating our rationale, + // which would leave the GPS dot invisible with no recovery. + @Suppress("DEPRECATION") + val rationaleAllowed = shouldShowRequestPermissionRationale( + Manifest.permission.ACCESS_COARSE_LOCATION + ) + if (!rationaleAllowed && permissionAsked) { + // permissionAsked guard: shouldShowRequestPermissionRationale is + // also false on the very first request before the user has ever + // answered; we only treat it as "permanently denied" once we've + // asked at least once this session. + showPermissionPermanentlyDeniedDialog(ctx) + return + } + AlertDialog.Builder(ctx) + .setTitle(R.string.maps_location_perm_title) + .setMessage(R.string.maps_location_perm_rationale) + .setPositiveButton(R.string.maps_location_perm_allow) { _, _ -> + requestPermissions( + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), + REQUEST_LOCATION_PERMISSION, + ) + } + .setNegativeButton(R.string.maps_location_perm_deny) { _, _ -> + // Stay on the fallback centroid; user can pan/zoom manually. + } + .show() + } + + /** Open the host CoGo app's system Settings page so the user can flip + * Location on. After they grant + come back, [onResume] will + * re-evaluate and fetch the GPS fix. */ + private fun showPermissionPermanentlyDeniedDialog(ctx: Context) { + AlertDialog.Builder(ctx) + .setTitle("Location is off for Code on the Go") + .setMessage( + "The wizard uses your current location to center the region " + + "you're about to download. Turn Location on for Code on the " + + "Go in Settings, then come back." + ) + .setPositiveButton("Open Settings") { _, _ -> + runCatching { + val intent = android.content.Intent( + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.fromParts("package", ctx.packageName, null) + ) + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) + }.onFailure { + android.util.Log.w("BboxPicker", "Open Settings failed: ${it.message}") + } + } + .setNegativeButton("Not now", null) + .show() + } + + @Deprecated("Legacy Fragment permissions API; see maybeRequestLocation note") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_LOCATION_PERMISSION) return + val granted = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + if (granted) { + // Enable the GPS blue dot now that the style is already loaded and + // permission has just been granted. + mapLibreMap?.let { map -> + map.style?.let { style -> enableLocationComponent(map, style) } + } + fetchLastKnownLocation() + } + } + + @SuppressLint("MissingPermission") + private fun fetchLastKnownLocation() { + val ctx = context ?: return + val client = LocationServices.getFusedLocationProviderClient(ctx) + + // Step 1: fused getLastLocation — fast path. Returns null when no client + // has recently subscribed to updates (a known fused-provider quirk), even + // if Android's LocationManager holds an aged fix. Fall back below. + client.lastLocation + .addOnSuccessListener { loc: Location? -> + if (loc != null) { + android.util.Log.i("BboxPicker", "fused.lastLocation: ${loc.latitude},${loc.longitude}") + applyLocation(loc) + } else { + android.util.Log.i("BboxPicker", "fused.lastLocation returned null; falling back to LocationManager") + fallbackToLocationManager() + } + } + .addOnFailureListener { + android.util.Log.w("BboxPicker", "fused.lastLocation failed: ${it.message}; falling back to LocationManager") + fallbackToLocationManager() + } + } + + /** + * Permission-tolerant fallback when GMS' fused provider hands back null. + * Walks the system [LocationManager]'s passive → network → gps providers + * in order. Passive is cheapest (returns whatever any other app last cached); + * gps is the most stale-resistant but slowest to a first fix. Any non-null + * result wins. + */ + @SuppressLint("MissingPermission") + private fun fallbackToLocationManager() { + val ctx = context ?: return + val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return + val providers = listOf( + LocationManager.PASSIVE_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER, + ) + val loc = providers.firstNotNullOfOrNull { provider -> + runCatching { lm.getLastKnownLocation(provider) }.getOrNull() + ?.also { android.util.Log.i("BboxPicker", "LocationManager.$provider: ${it.latitude},${it.longitude}") } + } + if (loc != null) applyLocation(loc) + else android.util.Log.i("BboxPicker", "all LocationManager providers returned null — staying at fallback (lat=$anchorLat lon=$anchorLon)") + } + + /** + * Apply a resolved [Location] to the wizard state and animate the camera. + * Shared between the fused-success and LocationManager-fallback paths. + */ + private fun applyLocation(loc: Location) { + if (view == null) return + anchorLat = loc.latitude + anchorLon = loc.longitude + haveRealGpsFix = true + android.util.Log.i("BboxPicker", "applyLocation: $anchorLat,$anchorLon") + // When GPS resolves, swap to a 50×50 km box centered on the fix, zoomed + // to ~half the screen width. Skip if editing an existing region (prefill + // bbox) — a late GPS fix shouldn't clobber the user's saved bounds. + val isPrefill = arguments?.getDoubleArray(ARG_PREFILL_BBOX) != null + if (isPrefill) return + val map = mapLibreMap ?: return + applyInitialBbox(map, Bbox.aroundPoint(anchorLat, anchorLon, GPS_DEFAULT_EDGE_KM)) + } + + // ----- IIAB tile source helpers ----- + + /** + * Resolve the IIAB PMTiles URL for the source the user selected in Step 1. + * Delegates to [RegionDownloader.buildTilesUrl] — the same URL the actual + * download uses — so the bbox-picker basemap and the download come from the + * same place. + * + * For internet sources, the URL uses the dated fallback file in + * [RegionDownloader.FALLBACK_VECTOR_DATE]. For LAN sources, uses + * `http:///maps/openstreetmap-openmaptiles...`. + * + * Returns null only when sourceKind is UNKNOWN or LAN with no host — both + * mean "no source configured yet," which falls back to a blank-background map. + */ + private fun resolveIiabTilesUrl(): String? = runCatching { + when (sourceKind) { + SourceKind.INTERNET -> RegionDownloader.buildTilesUrl(sourceKind, null) + SourceKind.IIAB_LAN -> { + val host = sourceHost?.trim().orEmpty() + if (host.isBlank()) null + else RegionDownloader.buildTilesUrl(sourceKind, host) + } + else -> null + } + }.onFailure { + MapsPlugin.pluginContext?.logger?.warn( + "resolveIiabTilesUrl failed: ${it.message}" + ) + }.getOrNull() + + /** + * Extract bundled glyph PBFs from plugin assets to filesystem cache on first + * picker open. MapLibre's AssetFileSource reads only the host APK's assets, + * not the plugin's — so the `asset://fonts/...` URL never resolves. Copy each + * PBF once via [PluginContext.resources.openPluginAsset], then style.json + * references them via `file://` at the cache path. + * + * Idempotent — skips files that already exist with non-zero size. Must be + * called off the main thread (it copies ~10 PBFs out of plugin assets on a + * cold cache); the caller invokes it inside `withContext(Dispatchers.IO)`. + */ + private fun ensureFontsExtracted(): File? { + val resources = MapsPlugin.pluginContext?.resources ?: run { + android.util.Log.w("BboxPicker", "ensureFontsExtracted: pluginContext null") + return null + } + val ctx = context ?: return null + val root = File(ctx.cacheDir, "maps-plugin-fonts") + if (!root.exists() && !root.mkdirs()) { + android.util.Log.w("BboxPicker", "failed to mkdir $root") + return null + } + var extracted = 0 + var skipped = 0 + for ((stack, ranges) in BUNDLED_FONT_STACKS) { + val stackDir = File(root, stack) + if (!stackDir.exists()) stackDir.mkdirs() + for (range in ranges) { + val dest = File(stackDir, "$range.pbf") + if (dest.exists() && dest.length() > 0) { skipped++; continue } + val input = resources.openPluginAsset("fonts/$stack/$range.pbf") + if (input == null) { + android.util.Log.w("BboxPicker", "missing asset fonts/$stack/$range.pbf") + continue + } + runCatching { + input.use { src -> + dest.outputStream().use { dst -> src.copyTo(dst) } + } + extracted++ + }.onFailure { + android.util.Log.w("BboxPicker", "extract failed: $stack/$range.pbf", it) + dest.delete() + } + } + } + android.util.Log.i( + "BboxPicker", + "ensureFontsExtracted: $extracted new, $skipped cached, root=${root.absolutePath}" + ) + return root + } + + /** + * Build a MapLibre v8 style JSON for the IIAB OpenMapTiles vector PMTiles at + * [pmtilesHttpUrl] via the `pmtiles://` protocol. Layers follow the + * OpenMapTiles v3 schema. If [pmtilesHttpUrl] is null (no source / unreachable), + * returns a solid-color background — the picker stays functional, just without + * tile detail. + */ + private fun buildIiabStyle(pmtilesHttpUrl: String?, fontsRoot: String?): String { + if (pmtilesHttpUrl == null) { + return """{"version":8,"layers":[{"id":"background","type":"background","paint":{"background-color":"#e8f4f8"}}]}""" + } + val pmtilesUrl = "pmtiles://$pmtilesHttpUrl" + // Glyphs URL: file:// into the per-app cache dir, populated off-thread by + // ensureFontsExtracted (the caller does that before building the style). + // MapLibre substitutes {fontstack} (URL-encoded) and {range} at fetch + // time; the file source decodes the URL so literal-space directory names + // on disk resolve. + val glyphsUrl = if (fontsRoot != null) { + "file://$fontsRoot/{fontstack}/{range}.pbf" + } else { + // Fonts unavailable → labels render as tofu, but polygons/lines still + // render. Pick an asset:// URL the host can't fulfil; MapLibre logs + // a benign Style error and proceeds without labels. + "asset://fonts/{fontstack}/{range}.pbf" + } + return """ +{ + "version": 8, + "name": "IIAB OpenMapTiles", + "glyphs": "$glyphsUrl", + "sources": { + "openmaptiles": { + "type": "vector", + "url": "$pmtilesUrl", + "attribution": "© OpenStreetMap contributors" + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "#e8f4f8" } + }, + { + "id": "landcover", + "type": "fill", + "source": "openmaptiles", + "source-layer": "landcover", + "paint": { "fill-color": "#d8e8c8", "fill-opacity": 0.7 } + }, + { + "id": "park", + "type": "fill", + "source": "openmaptiles", + "source-layer": "park", + "paint": { "fill-color": "#c8dba0", "fill-opacity": 0.5 } + }, + { + "id": "water", + "type": "fill", + "source": "openmaptiles", + "source-layer": "water", + "paint": { "fill-color": "#a8c5d3" } + }, + { + "id": "waterway", + "type": "line", + "source": "openmaptiles", + "source-layer": "waterway", + "paint": { "line-color": "#a8c5d3", "line-width": 1 } + }, + { + "id": "boundary-country", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]], + "paint": { + "line-color": "#6c6a76", + "line-width": ["interpolate", ["linear"], ["zoom"], 2, 0.6, 6, 1.2, 12, 2.0], + "line-opacity": 0.8 + } + }, + { + "id": "boundary-state", + "type": "line", + "source": "openmaptiles", + "source-layer": "boundary", + "filter": ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]], + "minzoom": 4, + "paint": { + "line-color": "#9a98a4", + "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.4, 12, 1.2], + "line-dasharray": [2, 2], + "line-opacity": 0.7 + } + }, + { + "id": "transportation", + "type": "line", + "source": "openmaptiles", + "source-layer": "transportation", + "paint": { "line-color": "#ffffff", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.5, 14, 2] } + }, + { + "id": "building", + "type": "fill", + "source": "openmaptiles", + "source-layer": "building", + "minzoom": 13, + "paint": { "fill-color": "#d4c9b0", "fill-opacity": 0.7 } + }, + { + "id": "place-city-marker", + "type": "circle", + "source": "openmaptiles", + "source-layer": "place", + "filter": ["in", ["get", "class"], ["literal", ["city", "town"]]], + "minzoom": 3, + "paint": { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 3, 1.5, 8, 3, 12, 5], + "circle-color": "#5a5862", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1 + } + }, + { + "id": "place-city-label", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "place", + "filter": ["in", ["get", "class"], ["literal", ["city", "town"]]], + "minzoom": 4, + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], 4, 10, 8, 13, 12, 16], + "text-anchor": "top", + "text-offset": [0, 0.6], + "text-max-width": 8 + }, + "paint": { + "text-color": "#2a2832", + "text-halo-color": "#ffffff", + "text-halo-width": 1.5 + } + }, + { + "id": "place-country-label", + "type": "symbol", + "source": "openmaptiles", + "source-layer": "place", + "filter": ["==", ["get", "class"], "country"], + "minzoom": 2, + "maxzoom": 6, + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["Noto Sans Italic"], + "text-size": ["interpolate", ["linear"], ["zoom"], 2, 9, 5, 14], + "text-transform": "uppercase", + "text-letter-spacing": 0.1, + "text-max-width": 7 + }, + "paint": { + "text-color": "#5a5862", + "text-halo-color": "#ffffff", + "text-halo-width": 1.5 + } + } + ] +} + """.trimIndent() + } + + /** + * Called once the MapLibre style has finished loading. Positions the camera, + * enables the GPS dot if permission is granted, and queues the location modal. + */ + private fun applyStyleReady(map: MapLibreMap, style: Style, prefillBbox: DoubleArray?) { + // Initial bbox + camera: + // - prefill bbox provided → use the saved bbox (Refresh flow) + // - GPS not granted / not resolved → 200×200 km box on the GMT line, + // camera at world view + // - GPS granted → fetchLastKnownLocation fires applyLocation, which + // swaps in the 50×50 km GPS default + val noGpsDefault = runCatching { + Bbox(NO_GPS_BBOX_SOUTH, NO_GPS_BBOX_WEST, NO_GPS_BBOX_NORTH, NO_GPS_BBOX_EAST) + }.getOrDefault(Bbox.aroundPoint(FALLBACK_LAT, FALLBACK_LON, NO_GPS_DEFAULT_EDGE_KM)) + val initialBbox = when { + prefillBbox != null && prefillBbox.size == 4 -> runCatching { + Bbox(prefillBbox[0], prefillBbox[1], prefillBbox[2], prefillBbox[3]) + }.getOrDefault(noGpsDefault) + else -> noGpsDefault + } + // No-GPS path: force world-view zoom so the whole map is visible and the + // default bbox renders as a clearly-visible rectangle. (GPS path uses the + // fitting math in applyInitialBbox.) + val worldView = (prefillBbox == null) + android.util.Log.i( + "BboxPicker", + "applyStyleReady: initialBbox=$initialBbox prefill=${prefillBbox != null} worldView=$worldView" + ) + applyInitialBbox(map, initialBbox, worldView = worldView) + if (ContextCompat.checkSelfPermission( + requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + ) { + enableLocationComponent(map, style) + fetchLastKnownLocation() + } + // Do NOT auto-request location permission. The host CoGo APK doesn't + // declare ACCESS_COARSE/FINE_LOCATION, so the system dialog never appears + // and Settings → CoGo has no Location row — requesting just strands the + // user. GPS is convenience (the user can pan/zoom manually), not + // correctness. Re-enable via a "Center on my location" FAB once the + // host's manifest declares the permission. + } + + // ----- Bbox + camera initialization ----- + + /** + * Programmatically set the geographic bbox + reposition the camera so the + * bbox fills [GPS_DEFAULT_SCREEN_FRACTION] of the screen width. Also + * triggers a fresh slicer estimate. + * + * When [worldView] is true, override the bbox-fitting zoom calculation with + * [NO_GPS_WORLD_ZOOM] — the no-GPS open path lands on a "whole world" view + * with the bbox visible as a draggable/resizable rectangle. + */ + private fun applyInitialBbox(map: MapLibreMap, bbox: Bbox, worldView: Boolean = false) { + currentBbox = bbox + zoomMax = ZoomCap.pickZoomMax(currentBbox, zoomMin) + bboxOverlay.setBboxLatLon(bbox) + val centerLat = (bbox.south + bbox.north) / 2.0 + val targetZoom: Double = if (worldView) { + NO_GPS_WORLD_ZOOM + } else { + // Pick a zoom that puts the bbox at ~50% of the screen width. + // Real pixel size in the bottom-sheet picker varies (~900-1080 + // px wide on the A56), but the math is the same: + // screenPxForBboxWidth = mapView.width * fraction + // metersPerPxAtZ = MERCATOR_M_PER_PX_Z0 * cos(centerLatRad) / 2^z + // → 2^z = MERCATOR_M_PER_PX_Z0 * cosLat * targetPx / widthMeters + val widthMeters = bbox.widthKm() * 1000.0 + val targetPx = (mapView.width.takeIf { it > 0 } ?: 1080) * GPS_DEFAULT_SCREEN_FRACTION + val cosLat = kotlin.math.cos(Math.toRadians(centerLat)).coerceAtLeast(0.01) + val twoToZ = MERCATOR_M_PER_PX_Z0 * cosLat * targetPx / widthMeters + kotlin.math.log2(twoToZ).coerceIn(0.0, 16.0) + } + android.util.Log.i( + "BboxPicker", + "applyInitialBbox: bbox=$bbox centerLat=$centerLat widthKm=${bbox.widthKm()} " + + "worldView=$worldView → zoom=$targetZoom", + ) + map.cameraPosition = org.maplibre.android.camera.CameraPosition.Builder() + .target(LatLng(centerLat, (bbox.west + bbox.east) / 2.0)) + .zoom(targetZoom) + .build() + // The camera change triggers move/idle listeners that recompute the + // pixel rect; the slicer estimate fires next. + updateBboxDimsLabel() + realByteEstimate = null + realEstimateFailed = false + renderEstimate() + scheduleRealEstimate() + } + + /** + * Render the estimate line. Driven by the slicer's directory walk — per-tile + * byte count varies ~100× between dense-city and sparse-ocean tiles, so a + * fixed bytes-per-tile constant would be misleading. + * + * No "bbox too big" check: `pickZoomMax` auto-caps zoom by cell budget, so + * even a whole-world bbox opens at a low zoom with a reasonable estimate. The + * 1 GB byte cap still applies and surfaces "over 1 GB limit" when exceeded. + * + * States: + * - no sliceable source → nothing rendered yet (waiting for source pick) + * - slicer running → "Calculating download size…" + * - slicer failed → "Couldn't calculate size — check connection" + * - slicer done, under cap → " MB download size" + * - slicer done, over 1 GB → " MB · over 1 GB limit. Choose a smaller region" + */ + private fun renderEstimate() { + if (!hasSliceableSource()) { + estimateSizeLine.text = "" + estimateSizeLine.setTextColor(ESTIMATE_TEXT_NORMAL) + bboxOverlay.setOverBudget(false) + btnNext.isEnabled = false + return + } + val realBytes = realByteEstimate + if (realBytes != null) { + val totalBytes = realBytes + NON_VECTOR_ALLOWANCE_BYTES + val mb = realBytes / (1024.0 * 1024.0) + if (totalBytes > MAX_DOWNLOAD_BYTES) { + // Over the 1 GB cap: text turns red AND the bbox overlay tints red. + val totalMb = totalBytes / (1024.0 * 1024.0) + estimateSizeLine.text = "%.0f MB · over 1 GB limit. Choose a smaller region" + .format(totalMb) + estimateSizeLine.setTextColor(ESTIMATE_TEXT_ERROR) + bboxOverlay.setOverBudget(true) + btnNext.isEnabled = false + } else { + estimateSizeLine.text = "%.1f MB download size".format(mb) + estimateSizeLine.setTextColor(ESTIMATE_TEXT_NORMAL) + bboxOverlay.setOverBudget(false) + btnNext.isEnabled = true + } + return + } + // Slicer hasn't returned (yet, or failed). Show only one of those two + // states — never an unreliable synthetic number alongside. + estimateSizeLine.text = if (realEstimateFailed) { + "Couldn't calculate size — check connection" + } else { + "Calculating download size…" + } + estimateSizeLine.setTextColor( + if (realEstimateFailed) ESTIMATE_TEXT_ERROR else ESTIMATE_TEXT_NORMAL, + ) + // While slicer is in-flight we don't yet know if it's over budget — + // reset overlay styling to normal so the box doesn't stay red after + // the user shrinks it. + bboxOverlay.setOverBudget(false) + // Allow Next while calculating — user can move to Step 3 even before + // the estimate completes; Step 3 also shows "Calculating…". But if the + // last attempt failed, disable until we can re-estimate successfully. + btnNext.isEnabled = !realEstimateFailed + } + + /** + * Kick off a debounced slicer call against the global tiles URL. The slicer + * walks the v3 header + root directory + any overlapping leaves and returns + * tile entries with real byte lengths; we sum those for the estimate. + * + * Results are cached in [SliceEstimateCache] so Step 3 (and any drag-back + * to the same bbox) reuses without re-walking the network. + */ + private fun scheduleRealEstimate() { + if (!hasSliceableSource()) return + val tilesUrl = RegionDownloader.buildTilesUrl(sourceKind, sourceHost) + val bbox = currentBbox + val zMin = zoomMin + val zMax = zoomMax + + // Cache hit → render real bytes immediately, no network. + SliceEstimateCache.get(tilesUrl, bbox, zMin, zMax)?.let { cached -> + applyRealEstimate(cached) + return + } + + // New attempt — clear any stale failure state so the suffix re-shows + // "estimating…" instead of "couldn't estimate exact size" while we wait. + realEstimateFailed = false + if (view != null) renderEstimate() + estimateJob?.cancel() + estimateJob = viewLifecycleOwner.lifecycleScope.launch { + delay(ESTIMATE_DEBOUNCE_MS) + android.util.Log.i( + "BboxPickerFragment", + "Slicer estimate START: url=$tilesUrl bbox=$bbox z=$zMin..$zMax", + ) + val startMs = System.currentTimeMillis() + val tiles = try { + // Hard timeout so the user is never stuck on "Calculating…" + // forever — surface a failure and let them proceed without a size. + kotlinx.coroutines.withTimeout(SLICER_TIMEOUT_MS) { + PmtilesRegionSlicer.tilesInRegion( + globalPmtilesUrl = tilesUrl, + bbox = bbox, + zoomMin = zMin, + zoomMax = zMax, + client = RegionDownloader.httpClient, + ).getOrThrow() + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + val elapsed = System.currentTimeMillis() - startMs + android.util.Log.w( + "BboxPickerFragment", + "Slicer estimate TIMEOUT after ${elapsed}ms — region too complex or upstream slow", + ) + realEstimateFailed = true + if (view != null) renderEstimate() + return@launch + } catch (_: kotlinx.coroutines.CancellationException) { + // A newer estimate is already in flight (user moved the bbox); + // quietly bow out without touching state — the newer job will + // win and set the proper flags. + return@launch + } catch (e: Throwable) { + val elapsed = System.currentTimeMillis() - startMs + android.util.Log.w( + "BboxPickerFragment", + "Slicer estimate FAILED after ${elapsed}ms: ${e.javaClass.simpleName}: ${e.message}", + ) + MapsPlugin.pluginContext?.logger?.warn( + "Slicer estimate failed (${e.javaClass.simpleName}): ${e.message}" + ) + realEstimateFailed = true + if (view != null) renderEstimate() + return@launch + } + val elapsed = System.currentTimeMillis() - startMs + android.util.Log.i( + "BboxPickerFragment", + "Slicer estimate DONE in ${elapsed}ms: ${tiles.size} tiles", + ) + SliceEstimateCache.put(tilesUrl, bbox, zMin, zMax, tiles) + if (currentBbox == bbox) applyRealEstimate(tiles) + } + } + + private fun applyRealEstimate(tiles: List) { + realByteEstimate = PmtilesRegionSlicer.estimateRegionBytes(tiles) + if (view != null) renderEstimate() + } + + private fun hasSliceableSource(): Boolean = when (sourceKind) { + SourceKind.INTERNET -> true + SourceKind.IIAB_LAN -> !sourceHost.isNullOrBlank() + else -> false + } + + /** + * Debounced "shrink bbox to fit viewport", mirroring Google Maps' + * selection-tracks-viewport behavior. Fires 1 s after camera idle (once the + * pan/zoom has settled), and only acts when the bbox has at least one edge + * outside the current viewport. + * + * **Naturally no-ops when:** + * - User zoomed out — viewport grew, bbox still fully inside it. + * - User panned but bbox remained fully visible. + * + * **Acts when:** + * - User zoomed in past the bbox extent on any axis. + * - User panned far enough that any bbox edge crossed the viewport edge. + * + * New bbox = visible viewport inset by [AUTO_SHRINK_MARGIN] on every side, + * which leaves a comfortable gap to the screen edge (Google Maps-style). + */ + private fun scheduleAutoShrinkBbox(map: MapLibreMap) { + autoShrinkJob?.cancel() + autoShrinkJob = viewLifecycleOwner.lifecycleScope.launch { + delay(AUTO_SHRINK_DEBOUNCE_MS) + if (view == null) return@launch + maybeShrinkBboxToFit(map) + } + } + + private fun maybeShrinkBboxToFit(map: MapLibreMap) { + val bounds = runCatching { map.projection.visibleRegion.latLngBounds }.getOrNull() + ?: return + val newBbox = AutoShrinkBbox.computeShrunkBbox( + bbox = currentBbox, + viewN = bounds.latitudeNorth, + viewS = bounds.latitudeSouth, + viewE = bounds.longitudeEast, + viewW = bounds.longitudeWest, + margin = AUTO_SHRINK_MARGIN, + ) ?: return + + currentBbox = newBbox + zoomMax = ZoomCap.pickZoomMax(currentBbox, zoomMin) + realByteEstimate = null + realEstimateFailed = false + bboxOverlay.setBboxLatLon(newBbox) + renderEstimate() + updateBboxDimsLabel() + scheduleRealEstimate() + } + + /** + * Bbox dimensions + auto-capped max zoom level line, e.g. + * "2220km × 2140km — max zoom level 12 (town)". + * + * Zoom labels follow OpenStreetMap's "Zoom levels" wiki convention + * (wiki.openstreetmap.org/wiki/Zoom_levels); for levels OSM leaves blank + * (8, 14, 16+) we use the standard Mapbox / Bing naming. + */ + private fun updateBboxDimsLabel() { + val width = currentBbox.widthKm().toInt() + val height = currentBbox.heightKm().toInt() + val label = zoomLabel(zoomMax) + estimateFree.text = + "${width}km × ${height}km — max zoom level $zoomMax ($label)" + estimateFree.setTextColor( + requireContext().getColor( + com.google.android.material.R.color.material_on_surface_emphasis_medium + ) + ) + } + + private fun zoomLabel(z: Int): String = when (z) { + in 0..2 -> "world" + in 3..5 -> "country" + in 6..7 -> "country / state" + 8 -> "region" + in 9..10 -> "metro area" + 11 -> "city" + 12 -> "town" + 13 -> "village" + 14 -> "streets" + in 15..16 -> "small roads" + else -> "buildings" + } + + // ----- Navigation ----- + + private fun back() { + (parentFragment as? Listener)?.onBboxPickerBack() + ?: defaultPopBack() + } + + private fun next() { + // Forward the picker's auto-capped zoom range so the downloader uses it + // instead of its z=14 default. sizeBytesEstimate is -1L when the slicer + // hasn't returned yet — Step 3 reads that as "Calculating…". + val forwarded = run { + TileEstimate( + tileCount = 0L, // tile count isn't used by Step 3 + sizeBytesEstimate = realByteEstimate ?: -1L, + zoomMin = zoomMin, + zoomMax = zoomMax, + ) + } + (parentFragment as? Listener)?.onBboxPickerNext( + bbox = currentBbox, + estimate = forwarded, + prefillRegionId = prefillRegionId, + prefillRegionName = prefillDisplayName, + ) ?: defaultPopBack() + } + + private fun defaultPopBack() { + if (parentFragmentManager.backStackEntryCount > 0) { + parentFragmentManager.popBackStack() + } + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt new file mode 100644 index 0000000..3db0000 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt @@ -0,0 +1,201 @@ +package org.appdevforall.maps.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.R +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Surfaces real download progress after Step 3 Save. + * + * Drives [RegionDownloader.download] on a viewLifecycleOwner-scoped coroutine, + * reflects per-phase progress in the basemap / tiles cards, and emits + * one of three terminal callbacks: + * - [Listener.onDownloadComplete] — success + * - [Listener.onDownloadCancelled] — user tapped Cancel + * - [Listener.onDownloadFailed] — IO / HTTP error + * + * Cancel deletes the partial directory (handled inside [RegionDownloader]'s + * catch + finally), so a discarded download leaves no orphan bytes. + */ +class DownloadProgressFragment : Fragment() { + + interface Listener { + fun onDownloadComplete(regionId: String) + fun onDownloadCancelled() + fun onDownloadFailed(message: String) + } + + companion object { + const val ARG_REGION_ID = "regionId" + const val ARG_DISPLAY_NAME = "displayName" + const val ARG_BBOX = "bbox" + const val ARG_SOURCE_KIND = "sourceKind" + const val ARG_SOURCE_HOST = "sourceHost" + // Zoom range the picker selected (its pickZoomMax auto-caps zoomMax). Must + // be threaded to RegionDownloader.download, or the downloader's z=6..14 + // default pulls 4×–16× more tiles than the user agreed to. + const val ARG_ZOOM_MIN = "zoomMin" + const val ARG_ZOOM_MAX = "zoomMax" + + fun newInstance( + regionId: String, + displayName: String, + bbox: Bbox, + sourceKind: SourceKind, + sourceHost: String?, + zoomMin: Int, + zoomMax: Int, + ): DownloadProgressFragment = DownloadProgressFragment().apply { + arguments = Bundle().apply { + putString(ARG_REGION_ID, regionId) + putString(ARG_DISPLAY_NAME, displayName) + putDoubleArray(ARG_BBOX, bbox.toBoundsArray()) + putString(ARG_SOURCE_KIND, sourceKind.wireValue) + if (sourceHost != null) putString(ARG_SOURCE_HOST, sourceHost) + putInt(ARG_ZOOM_MIN, zoomMin) + putInt(ARG_ZOOM_MAX, zoomMax) + } + } + } + + private lateinit var regionNameLabel: TextView + private lateinit var headerLabel: TextView + private lateinit var statusBasemap: TextView + private lateinit var statusTiles: TextView + private lateinit var progressBasemap: LinearProgressIndicator + private lateinit var progressTiles: LinearProgressIndicator + private lateinit var btnCancel: MaterialButton + + private var downloadJob: Job? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(MapsPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_download_progress, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + regionNameLabel = view.findViewById(R.id.region_name) + headerLabel = view.findViewById(R.id.header) + statusBasemap = view.findViewById(R.id.status_basemap) + statusTiles = view.findViewById(R.id.status_tiles) + progressBasemap = view.findViewById(R.id.progress_basemap) + progressTiles = view.findViewById(R.id.progress_tiles) + btnCancel = view.findViewById(R.id.btn_cancel) + + val args = arguments + val regionId = args?.getString(ARG_REGION_ID) ?: run { + (parentFragment as? Listener)?.onDownloadFailed("Missing regionId") + return + } + val displayName = args.getString(ARG_DISPLAY_NAME) ?: regionId + val bboxArr = args.getDoubleArray(ARG_BBOX) ?: doubleArrayOf(0.0, 0.0, 0.01, 0.01) + val bbox = runCatching { + Bbox(bboxArr[0], bboxArr[1], bboxArr[2], bboxArr[3]) + }.getOrElse { + (parentFragment as? Listener)?.onDownloadFailed("Bad bbox: ${it.message}") + return + } + val sourceKind = SourceKind.values().firstOrNull { + it.wireValue == args.getString(ARG_SOURCE_KIND) + } ?: SourceKind.INTERNET + val sourceHost = args.getString(ARG_SOURCE_HOST) + val zoomMin = args.getInt(ARG_ZOOM_MIN, 6) + val zoomMax = args.getInt(ARG_ZOOM_MAX, 14) + + regionNameLabel.text = displayName + val sourceLabel = when (sourceKind) { + SourceKind.IIAB_LAN -> sourceHost ?: "LAN" + SourceKind.INTERNET -> "iiab.switnet.org" + else -> "unknown" + } + headerLabel.text = "From $sourceLabel" + + statusBasemap.text = getString(R.string.maps_download_status_queued) + statusTiles.text = getString(R.string.maps_download_status_queued) + + btnCancel.setOnClickListener { + downloadJob?.cancel() + // Listener fires from the catch block below. + } + + downloadJob = viewLifecycleOwner.lifecycleScope.launch { + val ctx = requireContext().applicationContext + try { + RegionDownloader.download( + context = ctx, + regionId = regionId, + displayName = displayName, + bbox = bbox, + sourceKind = sourceKind, + sourceHost = sourceHost, + zoomMin = zoomMin, + zoomMax = zoomMax, + ) { phase, bytes, total -> + val rootView = view ?: return@download + val (status, progress) = when (phase) { + RegionDownloader.Phase.BASEMAP -> statusBasemap to progressBasemap + RegionDownloader.Phase.TILES -> statusTiles to progressTiles + } + rootView.post { + if (total > 0) { + val pct = ((bytes.toDouble() / total.toDouble()) * 100).toInt() + .coerceIn(0, 100) + progress.progress = pct + status.text = "${humanBytes(bytes)} / ${humanBytes(total)} · $pct%" + } else { + progress.isIndeterminate = true + status.text = "${humanBytes(bytes)} downloaded" + } + if (bytes == total && total > 0) { + status.text = + "${humanBytes(total)} · ${getString(R.string.maps_download_status_done)}" + } + } + } + (parentFragment as? Listener)?.onDownloadComplete(regionId) + } catch (cancel: kotlinx.coroutines.CancellationException) { + (parentFragment as? Listener)?.onDownloadCancelled() + // Re-throw to allow structured cancellation to propagate. + throw cancel + } catch (t: Throwable) { + val msg = t.message ?: t.javaClass.simpleName + (parentFragment as? Listener)?.onDownloadFailed(msg) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + // Cancelling the job triggers RegionDownloader's finally to delete the + // partial dir, so a discarded download leaves no orphan bytes. + downloadJob?.cancel() + } + + private fun humanBytes(bytes: Long): String { + if (bytes < 0) return "—" + val mb = bytes / (1024.0 * 1024.0) + return if (mb < 1) "${bytes / 1024} KB" else "%.1f MB".format(mb) + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/PluginChildFragmentFactory.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/PluginChildFragmentFactory.kt new file mode 100644 index 0000000..877f132 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/PluginChildFragmentFactory.kt @@ -0,0 +1,37 @@ +package org.appdevforall.maps.ui + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory + +/** + * [FragmentFactory] that resolves plugin-class names via the plugin's + * DexClassLoader rather than the host's. Set on [RegionManagerFragment]'s + * `childFragmentManager` before super.onCreate so that ViewPager2's + * FragmentStateAdapter state-restore path can instantiate the wizard's child + * fragments without throwing `ClassNotFoundException`. + * + * Without this, tab-swiping with the Maps tab active throws + * `ClassNotFoundException: org.appdevforall.maps.ui.BboxPickerFragment` + * because the host's default factory uses the host's class loader. + * + * Falls through to the host's no-arg default behavior on failure rather than + * propagating exceptions — defense-in-depth so a future renamed-or-removed + * fragment class triggers a generic "fragment not found" path instead of a + * full IDE crash. + */ +internal class PluginChildFragmentFactory( + private val pluginClassLoader: ClassLoader, +) : FragmentFactory() { + + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + return try { + val cls = pluginClassLoader.loadClass(className) + cls.getDeclaredConstructor().newInstance() as Fragment + } catch (t: Throwable) { + // Fall through to the AndroidX default which uses the passed-in + // classLoader; if that also fails, AndroidX will throw a clear + // Fragment$InstantiationException for the host to handle. + super.instantiate(classLoader, className) + } + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt new file mode 100644 index 0000000..2720713 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt @@ -0,0 +1,155 @@ +package org.appdevforall.maps.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.appdevforall.maps.R +import org.appdevforall.maps.data.RegionInfo +import org.appdevforall.maps.util.formatByteSize +import com.google.android.material.button.MaterialButton +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.progressindicator.CircularProgressIndicator +import java.text.DateFormat +import java.util.Date + +/** + * Diffing list adapter for [RegionRow]. + * + * Each row exposes: + * - Active/inactive toggle (per-project active state; only one region active in + * a given project at a time) + * - Refresh (re-download) + * - Delete + * + * Active rows get a green border + "● Active in this project" badge. The fragment + * owns all action handling (including the delete-confirmation dialog), so the + * adapter stays view-only. + */ +class RegionAdapter( + private val listener: Listener? = null +) : RecyclerView.Adapter() { + + interface Listener { + /** + * User flipped the active toggle. The fragment writes the per-project + * active.txt + (when activating) copies the region's files into the + * project, then refreshes the list. + */ + fun onRegionToggleActive(info: RegionInfo, newActive: Boolean) + + fun onRegionDelete(info: RegionInfo) + fun onRegionRedownload(info: RegionInfo) + } + + private val items = mutableListOf() + + fun submit(newItems: List) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = items.size + override fun getNewListSize() = newItems.size + override fun areItemsTheSame(oldPos: Int, newPos: Int) = + items[oldPos].info.regionId == newItems[newPos].info.regionId + override fun areContentsTheSame(oldPos: Int, newPos: Int) = + items[oldPos] == newItems[newPos] + }) + items.clear() + items.addAll(newItems) + diff.dispatchUpdatesTo(this) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val v = LayoutInflater.from(parent.context).inflate(R.layout.item_region, parent, false) + return VH(v) + } + + override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position]) + override fun getItemCount(): Int = items.size + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + private val rowRoot = view.findViewById(R.id.row_root) + private val name = view.findViewById(R.id.region_name) + private val size = view.findViewById(R.id.region_size) + private val meta = view.findViewById(R.id.region_meta) + private val activeBadge = view.findViewById(R.id.active_badge) + private val sourceBadge = view.findViewById(R.id.source_badge) + private val progress = view.findViewById(R.id.region_progress) + private val swActive = view.findViewById(R.id.sw_active) + private val redownload = view.findViewById(R.id.btn_redownload) + private val delete = view.findViewById(R.id.btn_delete) + + fun bind(row: RegionRow) { + val info = row.info + name.text = info.displayName + size.text = formatByteSize(info.sizeBytes) + meta.text = formatMeta(info) + + // Active background + badge. + rowRoot.setBackgroundResource( + if (row.isActiveInProject) R.drawable.region_row_bg_active + else R.drawable.region_row_bg + ) + activeBadge.visibility = if (row.isActiveInProject) View.VISIBLE else View.GONE + + // Source badge hidden — the source isn't surfaced to the user. The + // View stays in the layout in case LAN selection returns as a + // power-user toggle. + sourceBadge.visibility = View.GONE + + // Toggle — reflect current state without firing the listener. + swActive.setOnCheckedChangeListener(null) + swActive.isChecked = row.isActiveInProject + // Suppress the listener during isDownloading to avoid racing with + // the in-flight IO. The toggle stays interactive otherwise. + val controlsEnabled = listener != null && !row.isDownloading + swActive.isEnabled = controlsEnabled + redownload.isEnabled = controlsEnabled + delete.isEnabled = controlsEnabled + swActive.setOnCheckedChangeListener { _, isChecked -> + if (isChecked == row.isActiveInProject) return@setOnCheckedChangeListener + // Optimistic UI — flip the badge + row background the moment the + // toggle moves, rather than waiting on the fragment's IO + refresh + // round-trip (~200ms+). + activeBadge.visibility = if (isChecked) View.VISIBLE else View.GONE + rowRoot.setBackgroundResource( + if (isChecked) R.drawable.region_row_bg_active + else R.drawable.region_row_bg + ) + listener?.onRegionToggleActive(info, isChecked) + } + + progress.visibility = if (row.isDownloading) View.VISIBLE else View.GONE + + redownload.setOnClickListener { listener?.onRegionRedownload(info) } + delete.setOnClickListener { listener?.onRegionDelete(info) } + } + } + + /** Meta line: "downloaded " or "stub placeholder". (Size is already + * shown top-right in `region_size`.) */ + private fun formatMeta(info: RegionInfo): String { + val isStubSize = info.sizeBytes < STUB_SIZE_THRESHOLD_BYTES + val sourceStub = info.source == "openmaptiles-stub" + if (isStubSize || sourceStub) return "stub placeholder" + val downloaded = info.downloadedAt?.let { DateFormat.getDateInstance().format(Date(it)) } + return if (downloaded != null) "downloaded $downloaded" else "" + } + + private companion object { + const val STUB_SIZE_THRESHOLD_BYTES = 100L * 1024L + } +} + +/** + * View-model row wrapping a [RegionInfo] with two transient flags the fragment + * tracks per render: whether this region is the project's active region (a region + * is bundled in the project only if it is the active one) and whether a fresh + * download is running for it. + */ +data class RegionRow( + val info: RegionInfo, + val isActiveInProject: Boolean = false, + val isDownloading: Boolean = false +) diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt new file mode 100644 index 0000000..a614af3 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt @@ -0,0 +1,647 @@ +package org.appdevforall.maps.ui + +import android.app.AlertDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.R +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.domain.TileEstimate +import com.google.android.material.button.MaterialButton +import com.google.android.material.snackbar.Snackbar +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeProjectService +import org.appdevforall.maps.data.ActiveRegionStore +import org.appdevforall.maps.data.FirstRegionAutoActivator +import org.appdevforall.maps.data.RegionCache +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.data.RegionInfo +import org.appdevforall.maps.data.RegionInstaller +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.coroutineContext + +/** + * Host-resolved Fragment for the "Maps" panel. Registered as a + * [com.itsaky.androidide.plugins.extensions.TabItem] in [MapsPlugin.getEditorTabs]. + * + * **Wizard orchestration.** Three titled steps: + * - Step 1 [SourcePickerFragment] — pick LAN box / Internet + * - Step 2 [BboxPickerFragment] — choose region on a MapLibre map + * - Step 3 [Step3SaveFragment] — region name + summary + Save + * - then [DownloadProgressFragment] runs the actual download + * + * Each step swaps into `picker_container` via `childFragmentManager`; the + * back/cancel listeners pop back to the previous step. + * + * **Active region per project.** Each project's + * `app/src/main/assets/maps/active.txt` names which cached region is bundled into + * builds of that project. The first download into an empty cache auto-activates; + * subsequent regions stay inactive until the user toggles them on (which silently + * deactivates the previously-active one — only one active per project). + */ +class RegionManagerFragment : Fragment(), + RegionAdapter.Listener, + BboxPickerFragment.Listener, + SourcePickerFragment.Listener, + Step3SaveFragment.Listener, + DownloadProgressFragment.Listener { + + private companion object { + const val SOURCE_PICKER_TAG = "maps_source_picker" + const val BBOX_PICKER_TAG = "maps_bbox_picker" + const val STEP3_SAVE_TAG = "maps_step3_save" + const val DOWNLOAD_PROGRESS_TAG = "maps_download_progress" + + /** + * Default subpath under the open project where this plugin lands map data. + * Used both for "use in this project" copies and the per-project active.txt + * sentinel. + */ + const val DEFAULT_PROJECT_MAPS_SUBPATH = "app/src/main/assets/maps" + + /** Hardcoded Internet tile source. The source-picker UI is bypassed — + * users don't see or select a source. */ + const val DEFAULT_INTERNET_HOST = "iiab.switnet.org" + } + + private var listView: RecyclerView? = null + private var emptyState: View? = null + private var btnDownloadNew: MaterialButton? = null + private var listContainer: View? = null + private var pickerContainer: View? = null + private var wizardContainer: View? = null + private var wizardTitle: android.widget.TextView? = null + + /** OnBackPressedCallback active only while a wizard step is showing, so + * Android BACK exits the wizard to the region list rather than collapsing + * the whole bottom sheet. */ + private val onBackPressedCallback = object : androidx.activity.OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + showList() + } + } + private val adapter = RegionAdapter(this) + + // ----- Wizard step-machine state (held across step transitions) ----- + // Source selection isn't user-visible: always download from the Internet IIAB + // mirror. SourcePickerFragment is bypassed but kept for a possible LAN-select + // return. + private var wizardSourceKind: SourceKind = SourceKind.INTERNET + private var wizardSourceHost: String? = DEFAULT_INTERNET_HOST + private var wizardBbox: Bbox? = null + private var wizardEstimate: TileEstimate? = null + private var wizardPrefillRegionId: String? = null + private var wizardPrefillRegionName: String? = null + + /** + * Resolve the live [PluginContext] from [MapsPlugin]'s static. Read on + * every access (volatile) so a plugin reload doesn't leave us holding a + * stale reference. Null when the plugin has been disposed. + */ + private val pluginContext: PluginContext? + get() = MapsPlugin.pluginContext + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(MapsPlugin.PLUGIN_ID, inflater) + } + + /** + * Plugin classes (the wizard's children — [SourcePickerFragment], + * [BboxPickerFragment], etc.) are loaded by the plugin's DexClassLoader, + * not the host's. When the host's parent FragmentManager restores its + * tabs via ViewPager2's FragmentStateAdapter, it indirectly restores + * the child fragments inside *this* fragment — and `childFragmentManager` + * defaults to the host's [androidx.fragment.app.FragmentFactory], which + * uses the host's class loader. That throws `ClassNotFoundException` for + * every plugin-defined child fragment. + * + * Override `childFragmentManager.fragmentFactory` BEFORE + * `super.onCreate(savedInstanceState)` so the factory is set when the + * FragmentManager calls into it during state restore. + */ + override fun onCreate(savedInstanceState: Bundle?) { + childFragmentManager.fragmentFactory = PluginChildFragmentFactory( + this::class.java.classLoader!!, + ) + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_region_manager, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listContainer = view.findViewById(R.id.list_container) + pickerContainer = view.findViewById(R.id.picker_container) + wizardContainer = view.findViewById(R.id.wizard_container) + wizardTitle = view.findViewById(R.id.wizard_title) + listView = view.findViewById(R.id.regions_list).also { rv -> + rv.layoutManager = LinearLayoutManager(requireContext()) + rv.adapter = adapter + } + emptyState = view.findViewById(R.id.empty_state) + btnDownloadNew = view.findViewById(R.id.btn_download_new).also { + it.setOnClickListener { + wizardPrefillRegionId = null + wizardPrefillRegionName = null + showSourcePicker(prefillFrom = null) + } + } + view.findViewById(R.id.wizard_close)?.setOnClickListener { + showList() + } + // BACK in a wizard step exits the wizard, not the bottom sheet. + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + } + + override fun onResume() { + super.onResume() + refresh() + } + + /** Reload from disk and toggle empty / list visibility accordingly. */ + private fun refresh() { + val container = listContainer ?: return + val empty = emptyState ?: return + val rv = listView ?: return + viewLifecycleOwner.lifecycleScope.launch { + data class RefreshState( + val rows: List, + val isEmpty: Boolean, + ) + val state = withContext(Dispatchers.IO) { + val items = RegionCache.list() + val activeId = readActiveRegionId() + val rows = items.map { info -> + RegionRow(info = info, isActiveInProject = info.regionId == activeId) + } + RefreshState(rows, items.isEmpty()) + } + adapter.submit(state.rows) + if (container.visibility == View.VISIBLE) { + rv.visibility = if (state.isEmpty) View.GONE else View.VISIBLE + empty.visibility = if (state.isEmpty) View.VISIBLE else View.GONE + } + } + } + + // ----- Wizard step transitions ----- + + private fun showSourcePicker(prefillFrom: RegionInfo?) { + val list = listContainer ?: return + list.visibility = View.GONE + wizardContainer?.visibility = View.VISIBLE + wizardTitle?.setText(R.string.maps_wizard_title_source) + btnDownloadNew?.visibility = View.GONE + onBackPressedCallback.isEnabled = true + wizardPrefillRegionId = prefillFrom?.regionId + wizardPrefillRegionName = prefillFrom?.displayName + val frag = SourcePickerFragment.newInstance( + prefillRegionName = prefillFrom?.displayName, + prefillRegionId = prefillFrom?.regionId, + ) + childFragmentManager.beginTransaction() + .replace(R.id.picker_container, frag, SOURCE_PICKER_TAG) + .commit() + } + + private fun showBboxPicker() { + // BboxPickerFragment is a DialogFragment so the map UI runs in its own + // Window above the activity — escapes CoGo's ContentTranslatingDrawerLayout, + // which ignores setDrawerLockMode and hijacks right-swipes on the map. The + // dialog floats over the regions list, so we don't touch the containers. + // + // Hide the FAB for a clean modal; restored when the dialog dismisses. + btnDownloadNew?.visibility = View.GONE + // DialogFragment intercepts BACK itself, so disable our callback while up. + onBackPressedCallback.isEnabled = false + val frag = BboxPickerFragment.newInstance( + prefillRegionId = wizardPrefillRegionId, + prefillDisplayName = wizardPrefillRegionName, + prefillBbox = wizardBbox?.toBoundsArray(), + sourceKind = wizardSourceKind, + sourceHost = wizardSourceHost, + ) + frag.show(childFragmentManager, BBOX_PICKER_TAG) + } + + private fun showStep3Save() { + wizardContainer?.visibility = View.VISIBLE + wizardTitle?.setText(R.string.maps_wizard_title_save) + btnDownloadNew?.visibility = View.GONE + onBackPressedCallback.isEnabled = true + val bbox = wizardBbox ?: return + // wizardEstimate is null when the user tapped Next before the slicer + // returned; Step 3 handles that as "Calculating download size…". + val frag = Step3SaveFragment.newInstance( + sourceKind = wizardSourceKind, + sourceHost = wizardSourceHost, + bbox = bbox, + estimate = wizardEstimate, + prefillRegionId = wizardPrefillRegionId, + prefillDisplayName = wizardPrefillRegionName, + ) + childFragmentManager.beginTransaction() + .replace(R.id.picker_container, frag, STEP3_SAVE_TAG) + .commit() + } + + private fun showDownloadProgress(displayName: String, regionId: String) { + wizardContainer?.visibility = View.VISIBLE + wizardTitle?.setText(R.string.maps_wizard_title_download) + btnDownloadNew?.visibility = View.GONE + onBackPressedCallback.isEnabled = true + val bbox = wizardBbox ?: return + val frag = DownloadProgressFragment.newInstance( + regionId = regionId, + displayName = displayName, + bbox = bbox, + sourceKind = wizardSourceKind, + sourceHost = wizardSourceHost, + // Thread the picker's auto-capped zoom range to the downloader. + // Without it the downloader's z=6..14 default kicks in, downloading + // ~16× more tiles per extra zoom level. + zoomMin = wizardEstimate?.zoomMin ?: 6, + zoomMax = wizardEstimate?.zoomMax ?: 14, + ) + childFragmentManager.beginTransaction() + .replace(R.id.picker_container, frag, DOWNLOAD_PROGRESS_TAG) + .commit() + } + + private fun showList() { + val list = listContainer ?: return + onBackPressedCallback.isEnabled = false + // Tear down any wizard fragments so their lifecycles end. + listOf(BBOX_PICKER_TAG, SOURCE_PICKER_TAG, STEP3_SAVE_TAG, DOWNLOAD_PROGRESS_TAG) + .forEach { tag -> + val frag = childFragmentManager.findFragmentByTag(tag) + if (frag != null) { + childFragmentManager.beginTransaction().remove(frag).commit() + } + } + // Reset wizard state. + wizardSourceKind = SourceKind.UNKNOWN + wizardSourceHost = null + wizardBbox = null + wizardEstimate = null + wizardPrefillRegionId = null + wizardPrefillRegionName = null + + wizardContainer?.visibility = View.GONE + list.visibility = View.VISIBLE + btnDownloadNew?.visibility = View.VISIBLE + refresh() + } + + // ----- SourcePickerFragment.Listener (Step 1 → Step 2) ----- + + override fun onSourcePickerConfirmed( + sourceKind: SourceKind, + sourceHost: String?, + ) { + wizardSourceKind = sourceKind + wizardSourceHost = sourceHost + showBboxPicker() + } + + override fun onSourcePickerCancelled() = showList() + + // ----- BboxPickerFragment.Listener (Step 2 → Step 3 / Back to Step 1) ----- + + override fun onBboxPickerNext( + bbox: Bbox, + estimate: TileEstimate?, + prefillRegionId: String?, + prefillRegionName: String?, + ) { + wizardBbox = bbox + wizardEstimate = estimate + // Refresh-flow ids/names propagate (won't normally change at Step 2). + if (prefillRegionId != null) wizardPrefillRegionId = prefillRegionId + if (prefillRegionName != null) wizardPrefillRegionName = prefillRegionName + dismissBboxPickerDialog() + showStep3Save() + } + + override fun onBboxPickerBack() { + dismissBboxPickerDialog() + // Source step is bypassed (Internet default), so Back returns to the list. + showList() + } + + /** + * The bbox picker is a DialogFragment; we must `dismiss()` it explicitly + * before transitioning to the next wizard step. Other Step fragments + * remain in the wizard_container FrameLayout, so we also have to bring + * the wizard container back to visible state. + */ + private fun dismissBboxPickerDialog() { + val frag = childFragmentManager.findFragmentByTag(BBOX_PICKER_TAG) + as? androidx.fragment.app.DialogFragment + frag?.dismissAllowingStateLoss() + } + + // ----- Step3SaveFragment.Listener (Step 3 → Download / Back to Step 2) ----- + + override fun onSaveRegionConfirmed( + displayName: String, + regionId: String, + ) { + wizardPrefillRegionName = displayName + wizardPrefillRegionId = regionId + showDownloadProgress(displayName, regionId) + } + + override fun onSaveRegionBack() = showBboxPicker() + + // ----- DownloadProgressFragment.Listener (download completion / cancel) ----- + + override fun onDownloadComplete(regionId: String) { + // Auto-activation for the FIRST region in this project. See + // [FirstRegionAutoActivator] for the policy + the tested path. + // + // Branch behavior: + // - First region (no active.txt) → silently apply + activate. + // - Subsequent region (active.txt already set) → leave it untouched, but + // surface an "Apply" action on the Snackbar for a one-tap switch. + // - Apply-failed / region-not-found → surface the reason so the user + // knows why nothing happened. + viewLifecycleOwner.lifecycleScope.launch { + // currentProjectRoot() reaches IdeProjectService, which does disk I/O — + // keep it off the Main dispatcher (StrictMode DiskReadViolation otherwise, + // same root cause as the toggle/delete paths). + val projectDir = withContext(Dispatchers.IO) { currentProjectRoot() } + if (projectDir == null) { + showList() + Snackbar.make( + requireView(), + "Region downloaded: $regionId (no project open — open one to use it)", + Snackbar.LENGTH_LONG, + ).show() + return@launch + } + val result = withContext(Dispatchers.IO) { + FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = DEFAULT_PROJECT_MAPS_SUBPATH, + regionsCacheRoot = RegionCache.rootDir(), + downloadedRegionId = regionId, + applyRegionToProject = ::applyRegionToProject, + writeActiveRegion = ::writeActiveRegionId, + ) + } + showList() + val snackbar = when (result) { + is FirstRegionAutoActivator.Result.Activated -> Snackbar.make( + requireView(), + "Region downloaded and applied to project: ${result.displayName}", + Snackbar.LENGTH_LONG, + ) + is FirstRegionAutoActivator.Result.NoOpAlreadyActive -> { + Snackbar.make( + requireView(), + "Region downloaded: $regionId. Project's active region is unchanged.", + Snackbar.LENGTH_INDEFINITE, + ).setAction("Apply") { + applyDownloadedRegionFromSnackbar(projectDir, regionId) + } + } + is FirstRegionAutoActivator.Result.NoOpRegionNotFound -> Snackbar.make( + requireView(), + "Region downloaded but couldn't be located in cache: $regionId", + Snackbar.LENGTH_LONG, + ) + is FirstRegionAutoActivator.Result.ApplyFailed -> Snackbar.make( + requireView(), + "Region downloaded; apply failed: ${result.reason}", + Snackbar.LENGTH_LONG, + ) + } + snackbar.show() + } + } + + /** + * "Apply" action on the post-download Snackbar when the project already + * had an active region. Mirrors the toggle-on path in + * [onRegionToggleActive] — copies files, writes active.txt, refreshes + * the list. No-op if the regionId isn't in the cache (shouldn't happen, + * we just downloaded it). + */ + private fun applyDownloadedRegionFromSnackbar(projectDir: File, regionId: String) { + viewLifecycleOwner.lifecycleScope.launch { + val applied = withContext(Dispatchers.IO) { + val info = runCatching { RegionCache.list() }.getOrNull() + ?.firstOrNull { it.regionId == regionId } ?: return@withContext false + val ok = applyRegionToProject(info, projectDir) + if (ok) writeActiveRegionId(projectDir, info.regionId) + ok + } + if (applied) { + refresh() + Snackbar.make( + requireView(), + "Switched active region to: $regionId", + Snackbar.LENGTH_SHORT, + ).show() + } else { + Snackbar.make( + requireView(), + getString(R.string.maps_regions_apply_failed), + Snackbar.LENGTH_LONG, + ).show() + } + } + } + + override fun onDownloadCancelled() { + showList() + Snackbar.make( + requireView(), + "Download cancelled — partial files removed", + Snackbar.LENGTH_SHORT, + ).show() + } + + override fun onDownloadFailed(message: String) { + showList() + Snackbar.make( + requireView(), + "Download failed: $message", + Snackbar.LENGTH_LONG, + ).show() + } + + // ----- RegionAdapter.Listener ----- + + override fun onRegionToggleActive(info: RegionInfo, newActive: Boolean) { + viewLifecycleOwner.lifecycleScope.launch { + val projectDir = withContext(Dispatchers.IO) { currentProjectRoot() } + if (projectDir == null) { + Snackbar.make( + requireView(), + R.string.maps_regions_no_project, + Snackbar.LENGTH_LONG, + ).show() + return@launch + } + val priorActive = withContext(Dispatchers.IO) { readActiveRegionId(projectDir) } + val priorName = priorActive?.let { activeId -> + withContext(Dispatchers.IO) { runCatching { RegionCache.list() }.getOrNull() } + ?.firstOrNull { it.regionId == activeId }?.displayName + } + val ok = withContext(Dispatchers.IO) { + if (newActive) { + // Activate this region, deactivating any previously-active one + // implicitly (write-and-replace). + val applied = applyRegionToProject(info, projectDir) + if (applied) writeActiveRegionId(projectDir, info.regionId) + applied + } else { + // Deactivate: clear active.txt. + clearActiveRegionId(projectDir) + true + } + } + if (!ok) { + Snackbar.make( + requireView(), + getString(R.string.maps_regions_apply_failed), + Snackbar.LENGTH_LONG, + ).show() + refresh() + return@launch + } + val statusMsg = when { + newActive && priorActive != null && priorActive != info.regionId -> + getString( + R.string.maps_region_switched_active, + priorName ?: priorActive, + info.displayName, + ) + newActive -> getString(R.string.maps_region_activated, info.displayName) + else -> getString(R.string.maps_region_deactivated, info.displayName) + } + Snackbar.make(requireView(), statusMsg, Snackbar.LENGTH_SHORT).show() + refresh() + } + } + + override fun onRegionDelete(info: RegionInfo) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.maps_regions_confirm_delete_title) + .setMessage(getString(R.string.maps_regions_confirm_delete_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.maps_regions_delete) { _, _ -> + viewLifecycleOwner.lifecycleScope.launch { + val ok = withContext(Dispatchers.IO) { RegionCache.delete(info.regionId) } + if (ok) { + // If the deleted region is the project's active one, clear + // the sentinel so a subsequent download can auto-activate + // and the on-disk state stays consistent. + withContext(Dispatchers.IO) { + val projectDir = currentProjectRoot() + if (projectDir != null && + readActiveRegionId(projectDir) == info.regionId + ) { + clearActiveRegionId(projectDir) + } + } + } + val msg = if (ok) "Deleted ${info.displayName}" + else "Couldn't delete ${info.displayName}" + Snackbar.make(requireView(), msg, Snackbar.LENGTH_SHORT).show() + refresh() + } + } + .show() + } + + override fun onRegionRedownload(info: RegionInfo) { + // Refresh re-opens the wizard pre-filled with this region's + // existing name + bbox, so the user re-picks a source. + wizardPrefillRegionId = info.regionId + wizardPrefillRegionName = info.displayName + wizardBbox = info.bbox?.takeIf { it.size == 4 }?.let { + runCatching { Bbox(it[0], it[1], it[2], it[3]) }.getOrNull() + } + showSourcePicker(prefillFrom = info) + } + + // ----- Project / asset wiring ----- + + /** + * Resolve the current project root via [IdeProjectService]. Returns null + * when no project is open or the service isn't available (running in + * standalone test mode). + */ + private fun currentProjectRoot(): File? { + val ctx = pluginContext ?: return null + val project = ctx.services.get(IdeProjectService::class.java)?.getCurrentProject() + return project?.rootDir + } + + /** + * Read the active regionId for the currently-open project, or null when no + * project is open. Thin delegate to [ActiveRegionStore.read]. + */ + private fun readActiveRegionId(): String? { + val projectDir = currentProjectRoot() ?: return null + return readActiveRegionId(projectDir) + } + + private fun readActiveRegionId(projectDir: File): String? = + ActiveRegionStore.read(projectDir, DEFAULT_PROJECT_MAPS_SUBPATH) + + private fun writeActiveRegionId(projectDir: File, regionId: String) = + ActiveRegionStore.write(projectDir, DEFAULT_PROJECT_MAPS_SUBPATH, regionId) + + private fun clearActiveRegionId(projectDir: File) = + ActiveRegionStore.clear(projectDir, DEFAULT_PROJECT_MAPS_SUBPATH) + + /** + * Copy the cached region's data files into [projectDir]'s fixed flat maps + * assets, overwriting any previous region. Pure data copy — the project ships + * its own MapLibre wiring (see [org.appdevforall.maps.templates.MapTemplateBuilder]). + * Fails if the project isn't a Maps project (no `MapRegionActivity`). + * + * The per-project active state (active.txt) is written separately via + * [writeActiveRegionId] so the toggle flow can decouple "copy data" from + * "mark active". + */ + private suspend fun applyRegionToProject( + info: RegionInfo, + projectDir: File, + ): Boolean { + // Capture the calling coroutine's context so the (potentially 100+ MB) + // tiles.pmtiles copy checks for cancellation between chunks — closing the + // bottom sheet / switching projects mid-copy aborts promptly. + val ctx = coroutineContext + return RegionInstaller.apply( + info = info, + projectDir = projectDir, + logError = { msg, t -> pluginContext?.logger?.error(msg, t) }, + onChunk = { ctx.ensureActive() }, + ) + } + +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/SourcePickerFragment.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/SourcePickerFragment.kt new file mode 100644 index 0000000..b782446 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/SourcePickerFragment.kt @@ -0,0 +1,341 @@ +package org.appdevforall.maps.ui + +import android.content.Context +import android.net.TrafficStats +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.TextView +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.R +import org.appdevforall.maps.domain.SourceKind +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.net.HttpURLConnection +import java.net.URL + +/** + * Wizard step 1 — Pick Map Data Source. + * + * Title is "Map Data Source" (not "IIAB Data Source") since users may not know + * what IIAB means up front. The radio rows are "Local Network" vs "Internet + * (iiab.switnet.org)"; the LAN hostname field shows only when Local Network is + * selected. A binary reachability indicator ("● Reachable" / "● Not reachable" / + * "● Checking…") sits under the radios; the probe loop ticks every 30s internally. + * + * Persistence: LAN host in SharedPreferences (`maps_plugin_prefs / lan_host`). + * + * Default selection is **Internet** regardless of any saved LAN host — the + * primary path is iiab.switnet.org; LAN is for advanced users at an IIAB box. + */ +class SourcePickerFragment : Fragment() { + + interface Listener { + /** + * Step 1 complete. Host should advance to Step 2 (bbox picker). Region + * name is collected in [Step3SaveFragment], not here. + */ + fun onSourcePickerConfirmed( + sourceKind: SourceKind, + sourceHost: String?, + ) + + fun onSourcePickerCancelled() + } + + companion object { + const val ARG_PREFILL_REGION_NAME = "prefillRegionName" + const val ARG_PREFILL_REGION_ID = "prefillRegionId" + + fun newInstance( + prefillRegionName: String? = null, + prefillRegionId: String? = null, + ): SourcePickerFragment = SourcePickerFragment().apply { + arguments = Bundle().apply { + if (prefillRegionName != null) putString(ARG_PREFILL_REGION_NAME, prefillRegionName) + if (prefillRegionId != null) putString(ARG_PREFILL_REGION_ID, prefillRegionId) + } + } + + /** SharedPreferences file name. Plugin-namespaced so it doesn't collide. */ + private const val PREFS_NAME = "maps_plugin_prefs" + private const val KEY_LAN_HOST = "lan_host" + + /** Re-probe cadence when the last result was OK. */ + private const val PROBE_INTERVAL_MS = 30_000L + + /** + * Re-probe cadence after a failure — 5s instead of 30s so a transient + * blip (cold-start DNS+TLS to iiab.switnet.org can exceed + * [PROBE_TIMEOUT_MS]) doesn't strand the UI on "Not reachable" for half a + * minute; the next probe usually succeeds in under a second once warm. + */ + private const val PROBE_RETRY_INTERVAL_MS = 5_000L + + /** + * Per-probe HTTP timeout. 6s covers cold-start DNS resolution + TLS + * handshake + first request on a slow path; a warm path returns in + * ~200-500ms. + */ + private const val PROBE_TIMEOUT_MS = 6_000L + + /** + * Traffic-stats tag for the reachability HEAD probe. Tagging the thread + * before the HttpURLConnection call keeps StrictMode's + * UntaggedSocketViolation quiet and attributes the probe traffic to Maps. + */ + private const val PROBE_STATS_TAG = 0x4D41_5053 // "MAPS" + + private const val INTERNET_PROBE_URL = "https://iiab.switnet.org/maps/2/" + } + + // ----- View references ----- + private lateinit var rowLan: LinearLayout + private lateinit var rowInternet: LinearLayout + private lateinit var radioLan: RadioButton + private lateinit var radioInternet: RadioButton + private lateinit var lanHostLayout: TextInputLayout + private lateinit var edtLanHost: TextInputEditText + private lateinit var reachText: TextView + private lateinit var btnNext: MaterialButton + + // ----- State ----- + private var lanHost: String = "" + private var selectedSource: SourceKind = SourceKind.UNKNOWN + + private var probeJob: Job? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(MapsPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_source_picker, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + rowLan = view.findViewById(R.id.row_lan) + rowInternet = view.findViewById(R.id.row_internet) + radioLan = view.findViewById(R.id.radio_lan) + radioInternet = view.findViewById(R.id.radio_internet) + lanHostLayout = view.findViewById(R.id.lan_host_layout) + edtLanHost = view.findViewById(R.id.edt_lan_host) + reachText = view.findViewById(R.id.reach_text) + btnNext = view.findViewById(R.id.btn_next) + + selectedSource = SourceKind.INTERNET + + // Restore the previously-typed LAN host text so user doesn't have to + // re-enter it if they switch from Internet → LAN. But always start the + // wizard on Internet — see class doc. getSharedPreferences()/getString() + // do a synchronous disk read on first access, so read off the main thread + // (StrictMode DiskReadViolation otherwise) and apply on Main. + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val savedHost = withContext(Dispatchers.IO) { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_LAN_HOST, "").orEmpty() + } + if (savedHost.isNotBlank()) { + edtLanHost.setText(savedHost) + lanHost = savedHost + } + } + + edtLanHost.doAfterTextChanged { + lanHost = it?.toString().orEmpty().trim() + persistLanHost() + refreshNextEnabled() + scheduleImmediateProbe() + } + + rowLan.setOnClickListener { selectSource(SourceKind.IIAB_LAN) } + rowInternet.setOnClickListener { selectSource(SourceKind.INTERNET) } + + btnNext.setOnClickListener { confirm() } + + renderSelection() + refreshNextEnabled() + } + + override fun onResume() { + super.onResume() + startProbeLoop() + } + + override fun onPause() { + super.onPause() + probeJob?.cancel(); probeJob = null + } + + // ----- Source selection ----- + + private fun selectSource(kind: SourceKind) { + selectedSource = kind + renderSelection() + refreshNextEnabled() + scheduleImmediateProbe() + } + + private fun renderSelection() { + radioLan.isChecked = selectedSource == SourceKind.IIAB_LAN + radioInternet.isChecked = selectedSource == SourceKind.INTERNET + lanHostLayout.visibility = + if (selectedSource == SourceKind.IIAB_LAN) View.VISIBLE else View.GONE + } + + // ----- Probes ----- + + /** + * Probe the currently-selected source every [PROBE_INTERVAL_MS]. Binary reach + * state only. Falls back to "enter a hostname above" when LAN is selected with + * no host yet. + */ + private fun startProbeLoop() { + probeJob?.cancel() + probeJob = viewLifecycleOwner.lifecycleScope.launch { + while (isActive) { + val source = selectedSource + val hostSnapshot = lanHost + if (source == SourceKind.IIAB_LAN && hostSnapshot.isBlank()) { + showReach(getString(R.string.maps_source_probe_lan_empty)) + delay(PROBE_INTERVAL_MS) + continue + } + showReach(getString(R.string.maps_source_probe_checking)) + val url = when (source) { + SourceKind.IIAB_LAN -> buildLanProbeUrl(hostSnapshot) + SourceKind.INTERNET -> INTERNET_PROBE_URL + else -> INTERNET_PROBE_URL + } + val reachable = withContext(Dispatchers.IO) { probeHead(url) } + if (view == null) return@launch + showReach( + if (reachable) getString(R.string.maps_source_probe_reachable) + else getString(R.string.maps_source_probe_unreachable) + ) + // Faster retry after failure — most failures are transient + // (cold-start DNS / first TLS handshake) and recover on the + // next attempt. + delay(if (reachable) PROBE_INTERVAL_MS else PROBE_RETRY_INTERVAL_MS) + } + } + } + + /** + * Force an immediate re-probe (used when the source selection or LAN + * host changes). Cancels the running loop and restarts it. + */ + private fun scheduleImmediateProbe() { + if (probeJob?.isActive == true) { + probeJob?.cancel() + probeJob = null + } + if (!isResumed) return + startProbeLoop() + } + + private fun showReach(message: String) { + reachText.text = message + } + + private suspend fun probeHead(url: String): Boolean = withTimeoutOrNull(PROBE_TIMEOUT_MS) { + // Tag the socket so the HEAD probe doesn't trip StrictMode's + // UntaggedSocketViolation (same fix as RangeFetcher's range fetch). + TrafficStats.setThreadStatsTag(PROBE_STATS_TAG) + try { + runCatching { + val conn = URL(url).openConnection() as HttpURLConnection + try { + conn.requestMethod = "HEAD" + conn.connectTimeout = PROBE_TIMEOUT_MS.toInt() + conn.readTimeout = PROBE_TIMEOUT_MS.toInt() + conn.instanceFollowRedirects = true + val code = conn.responseCode + code in 200..399 + } finally { + conn.disconnect() + } + }.getOrDefault(false) + } finally { + TrafficStats.clearThreadStatsTag() + } + } ?: false + + /** + * LAN probe URL = `http:///maps/extracts.json`. The `extracts.json` + * file is the canonical IIAB inventory; HEAD on it is the lightest probe + * that confirms the maps role is reachable. If the host already includes + * a scheme (`http://` / `https://`) we honour it; otherwise default to + * plain http (IIAB boxes don't typically run TLS on the LAN). + */ + private fun buildLanProbeUrl(host: String): String { + val base = when { + host.startsWith("http://") || host.startsWith("https://") -> host + else -> "http://$host" + } + return base.trimEnd('/') + "/maps/extracts.json" + } + + // ----- LAN host persistence ----- + + private fun persistLanHost() { + runCatching { + requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LAN_HOST, lanHost) + .apply() + }.onFailure { + MapsPlugin.pluginContext?.logger?.warn( + "Failed to persist LAN host preference: ${it.message}" + ) + } + } + + // ----- Next-button gating ----- + + private fun refreshNextEnabled() { + val sourceOk = when (selectedSource) { + SourceKind.IIAB_LAN -> lanHost.isNotBlank() + SourceKind.INTERNET -> true + else -> false + } + btnNext.isEnabled = sourceOk + } + + // ----- Confirm ----- + + private fun confirm() { + if (!btnNext.isEnabled) return + val host = if (selectedSource == SourceKind.IIAB_LAN) lanHost else null + (parentFragment as? Listener)?.onSourcePickerConfirmed( + selectedSource, host + ) ?: defaultPopBack() + } + + private fun defaultPopBack() { + if (parentFragmentManager.backStackEntryCount > 0) { + parentFragmentManager.popBackStack() + } + } +} diff --git a/maps/src/main/kotlin/org/appdevforall/maps/ui/Step3SaveFragment.kt b/maps/src/main/kotlin/org/appdevforall/maps/ui/Step3SaveFragment.kt new file mode 100644 index 0000000..dd04ca2 --- /dev/null +++ b/maps/src/main/kotlin/org/appdevforall/maps/ui/Step3SaveFragment.kt @@ -0,0 +1,281 @@ +package org.appdevforall.maps.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.R +import org.appdevforall.maps.domain.RegionId +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.domain.TileEstimate +import org.appdevforall.maps.data.RegionCache +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Wizard step 3 — Save Region. + * + * Collects a region name and shows a summary card (source + total download size). + * Back returns to Step 2 with the bbox preserved; Save delegates to the host + * fragment, which starts the download. + */ +class Step3SaveFragment : Fragment() { + + interface Listener { + fun onSaveRegionConfirmed( + displayName: String, + regionId: String, + ) + + fun onSaveRegionBack() + } + + companion object { + const val ARG_SOURCE_KIND = "sourceKindWire" + const val ARG_SOURCE_HOST = "sourceHost" + const val ARG_BBOX = "bbox" + const val ARG_TILE_COUNT = "tileCount" + const val ARG_SIZE_BYTES = "sizeBytesEstimate" + const val ARG_ZOOM_MIN = "zoomMin" + const val ARG_ZOOM_MAX = "zoomMax" + const val ARG_PREFILL_REGION_ID = "prefillRegionId" + const val ARG_PREFILL_DISPLAY_NAME = "prefillDisplayName" + + /** + * @param estimate slicer-derived size estimate, or null if the slicer + * hasn't returned yet. Step 3 shows "Calculating + * download size…" until a real estimate arrives. + */ + fun newInstance( + sourceKind: SourceKind, + sourceHost: String?, + bbox: Bbox, + estimate: TileEstimate?, + prefillRegionId: String? = null, + prefillDisplayName: String? = null, + ): Step3SaveFragment = Step3SaveFragment().apply { + arguments = Bundle().apply { + putString(ARG_SOURCE_KIND, sourceKind.wireValue) + if (sourceHost != null) putString(ARG_SOURCE_HOST, sourceHost) + putDoubleArray(ARG_BBOX, bbox.toBoundsArray()) + // -1 sentinel encodes "not yet estimated"; Step 3 reads this + // as the "calculating" state. + putLong(ARG_SIZE_BYTES, estimate?.sizeBytesEstimate ?: -1L) + putLong(ARG_TILE_COUNT, estimate?.tileCount ?: 0L) + putInt(ARG_ZOOM_MIN, estimate?.zoomMin ?: 6) + putInt(ARG_ZOOM_MAX, estimate?.zoomMax ?: 14) + if (prefillRegionId != null) putString(ARG_PREFILL_REGION_ID, prefillRegionId) + if (prefillDisplayName != null) putString(ARG_PREFILL_DISPLAY_NAME, prefillDisplayName) + } + } + } + + private lateinit var edtRegionName: TextInputEditText + private lateinit var collisionWarning: TextView + private lateinit var btnBack: MaterialButton + private lateinit var btnSave: MaterialButton + + // Cached args. + private var sourceKind: SourceKind = SourceKind.UNKNOWN + private var sourceHost: String? = null + private lateinit var bbox: Bbox + private var sizeBytesEstimate: Long = 0L + private var prefillRegionId: String? = null + + // Live state. + private var regionName: String = "" + + /** + * Cache of existing cache regionIds, loaded once off the main thread in + * [onViewCreated] (and refreshed on resume). [refreshCollisionWarning] reads + * this set synchronously rather than walking `/sdcard/CodeOnTheGo/maps/` on + * every keystroke — the latter trips StrictMode's disk-read policy and janks + * past ~10 cached regions. Null = not yet loaded (treat as "no known + * collisions" until the load lands). + */ + @Volatile + private var existingRegionIds: Set? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(MapsPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_save_region, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val args = arguments + sourceKind = SourceKind.values().firstOrNull { + it.wireValue == args?.getString(ARG_SOURCE_KIND) + } ?: SourceKind.UNKNOWN + sourceHost = args?.getString(ARG_SOURCE_HOST) + val bboxArr = args?.getDoubleArray(ARG_BBOX) + ?: doubleArrayOf(0.0, 0.0, 0.0, 0.0) + bbox = runCatching { + Bbox(bboxArr[0], bboxArr[1], bboxArr[2], bboxArr[3]) + }.getOrDefault(Bbox(0.0, 0.0, 0.01, 0.01)) + // -1 = slicer hadn't returned when the user tapped Next (the bbox + // picker forwards null in that case). The total-size row renders + // "Calculating download size…" until the user goes back or proceeds. + sizeBytesEstimate = args?.getLong(ARG_SIZE_BYTES, -1L) ?: -1L + prefillRegionId = args?.getString(ARG_PREFILL_REGION_ID) + val prefillName = args?.getString(ARG_PREFILL_DISPLAY_NAME) + + edtRegionName = view.findViewById(R.id.edt_region_name) + collisionWarning = view.findViewById(R.id.collision_warning) + btnBack = view.findViewById(R.id.btn_back) + btnSave = view.findViewById(R.id.btn_save) + + // Default name = prefill (Refresh flow) OR blank — the user types a place + // name. Save stays disabled until the field is non-blank. + val defaultName = prefillName.orEmpty() + edtRegionName.setText(defaultName) + regionName = defaultName + + edtRegionName.doAfterTextChanged { + regionName = it?.toString().orEmpty().trim() + refreshCollisionWarning() + refreshSaveEnabled() + } + + renderSummaryCard(view) + refreshCollisionWarning() + refreshSaveEnabled() + + btnBack.setOnClickListener { + (parentFragment as? Listener)?.onSaveRegionBack() + ?: defaultPopBack() + } + btnSave.setOnClickListener { save() } + + loadExistingRegionIds() + } + + override fun onResume() { + super.onResume() + // The user may have navigated to the Region Manager and added/removed a + // region; re-load the collision set so the warning stays accurate. + loadExistingRegionIds() + } + + /** + * Populate [existingRegionIds] off the main thread, then re-evaluate the + * collision warning on the main thread. Walking the cache is disk I/O and + * must not run on a `doAfterTextChanged` keystroke callback. + */ + private fun loadExistingRegionIds() { + viewLifecycleOwner.lifecycleScope.launch { + val ids = withContext(Dispatchers.IO) { + runCatching { RegionCache.list().map { it.regionId }.toSet() } + .getOrDefault(emptySet()) + } + existingRegionIds = ids + refreshCollisionWarning() + refreshSaveEnabled() + } + } + + // ----- Summary card rendering ----- + + private fun renderSummaryCard(root: View) { + fun setRow(rowId: Int, k: String, v: String) { + val row = root.findViewById(rowId) + row.findViewById(R.id.k).text = k + row.findViewById(R.id.v).text = v + } + val sourceLabel = when (sourceKind) { + SourceKind.IIAB_LAN -> "📡 ${sourceHost ?: "LAN"} (LAN)" + SourceKind.INTERNET -> "🌐 iiab.switnet.org" + else -> "Unknown" + } + setRow(R.id.sum_source, getString(R.string.maps_save_summary_source), sourceLabel) + + // Total size estimate: the slicer's vector tile byte sum drives most of + // it, plus ~7 MB Natural Earth basemap. + // sizeBytesEstimate < 0 means the slicer hadn't returned when the user + // tapped Next; show "Calculating…" rather than a misleading basemap-only + // total. + val totalText = if (sizeBytesEstimate < 0) { + "Calculating download size…" + } else { + val vectorMb = (sizeBytesEstimate / (1024.0 * 1024.0)).coerceAtLeast(0.0) + val neMb = 7.0 + val totalMb = vectorMb + neMb + "%.1f MB".format(totalMb) + } + setRow( + R.id.sum_total, + getString(R.string.maps_save_summary_total), + totalText, + ) + } + + // ----- Collision check ----- + + private fun refreshCollisionWarning() { + val rid = slugifyRegionId(regionName) + if (rid.isBlank() || regionName.isBlank()) { + collisionWarning.visibility = View.GONE + return + } + // Read the pre-loaded set (populated off-thread); null means the load + // hasn't landed yet, so don't warn — the load callback re-runs this. + val existing = existingRegionIds ?: return + val collides = rid != prefillRegionId && existing.contains(rid) + if (collides) { + collisionWarning.text = + getString(R.string.maps_save_collision_message, regionName) + collisionWarning.visibility = View.VISIBLE + } else { + collisionWarning.visibility = View.GONE + } + } + + private fun refreshSaveEnabled() { + // Save needs a non-blank, valid id that doesn't collide with an existing + // region — a collision would silently overwrite it. Re-downloading the same + // region (rid == prefillRegionId) is allowed. existingRegionIds may be null + // until the off-thread load lands; the load callback re-runs this. + val rid = if (regionName.isBlank()) "" else slugifyRegionId(regionName) + val collides = rid.isNotBlank() && rid != prefillRegionId && + existingRegionIds?.contains(rid) == true + btnSave.isEnabled = rid.isNotBlank() && RegionCache.isValidRegionId(rid) && !collides + } + + // ----- Save ----- + + private fun save() { + if (regionName.isBlank()) return + val rid = prefillRegionId ?: slugifyRegionId(regionName) + if (rid.isBlank() || !RegionCache.isValidRegionId(rid)) return + (parentFragment as? Listener)?.onSaveRegionConfirmed( + displayName = regionName, + regionId = rid, + ) ?: defaultPopBack() + } + + private fun defaultPopBack() { + if (parentFragmentManager.backStackEntryCount > 0) { + parentFragmentManager.popBackStack() + } + } + + /** Slugify the user-typed name into a cache-safe regionId; see [RegionId.slugify]. */ + private fun slugifyRegionId(name: String): String = RegionId.slugify(name) +} diff --git a/maps/src/main/res/drawable/badge_active.xml b/maps/src/main/res/drawable/badge_active.xml new file mode 100644 index 0000000..1fcd5e7 --- /dev/null +++ b/maps/src/main/res/drawable/badge_active.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/maps/src/main/res/drawable/badge_internet.xml b/maps/src/main/res/drawable/badge_internet.xml new file mode 100644 index 0000000..7f2df15 --- /dev/null +++ b/maps/src/main/res/drawable/badge_internet.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/maps/src/main/res/drawable/badge_reachable.xml b/maps/src/main/res/drawable/badge_reachable.xml new file mode 100644 index 0000000..19948f3 --- /dev/null +++ b/maps/src/main/res/drawable/badge_reachable.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/maps/src/main/res/drawable/badge_unknown.xml b/maps/src/main/res/drawable/badge_unknown.xml new file mode 100644 index 0000000..dc3025a --- /dev/null +++ b/maps/src/main/res/drawable/badge_unknown.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/maps/src/main/res/drawable/badge_unreachable.xml b/maps/src/main/res/drawable/badge_unreachable.xml new file mode 100644 index 0000000..839838e --- /dev/null +++ b/maps/src/main/res/drawable/badge_unreachable.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/maps/src/main/res/drawable/estimate_bg.xml b/maps/src/main/res/drawable/estimate_bg.xml new file mode 100644 index 0000000..da90199 --- /dev/null +++ b/maps/src/main/res/drawable/estimate_bg.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/maps/src/main/res/drawable/radio_row_bg.xml b/maps/src/main/res/drawable/radio_row_bg.xml new file mode 100644 index 0000000..75dc514 --- /dev/null +++ b/maps/src/main/res/drawable/radio_row_bg.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/maps/src/main/res/drawable/region_row_bg.xml b/maps/src/main/res/drawable/region_row_bg.xml new file mode 100644 index 0000000..5248af1 --- /dev/null +++ b/maps/src/main/res/drawable/region_row_bg.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/maps/src/main/res/drawable/region_row_bg_active.xml b/maps/src/main/res/drawable/region_row_bg_active.xml new file mode 100644 index 0000000..38f6573 --- /dev/null +++ b/maps/src/main/res/drawable/region_row_bg_active.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/maps/src/main/res/layout/fragment_bbox_picker.xml b/maps/src/main/res/layout/fragment_bbox_picker.xml new file mode 100644 index 0000000..56f0391 --- /dev/null +++ b/maps/src/main/res/layout/fragment_bbox_picker.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/fragment_download_progress.xml b/maps/src/main/res/layout/fragment_download_progress.xml new file mode 100644 index 0000000..b4cd3f3 --- /dev/null +++ b/maps/src/main/res/layout/fragment_download_progress.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/fragment_region_manager.xml b/maps/src/main/res/layout/fragment_region_manager.xml new file mode 100644 index 0000000..2d3b1db --- /dev/null +++ b/maps/src/main/res/layout/fragment_region_manager.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/fragment_save_region.xml b/maps/src/main/res/layout/fragment_save_region.xml new file mode 100644 index 0000000..2664cad --- /dev/null +++ b/maps/src/main/res/layout/fragment_save_region.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/fragment_source_picker.xml b/maps/src/main/res/layout/fragment_source_picker.xml new file mode 100644 index 0000000..57873df --- /dev/null +++ b/maps/src/main/res/layout/fragment_source_picker.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/item_region.xml b/maps/src/main/res/layout/item_region.xml new file mode 100644 index 0000000..ed8d16d --- /dev/null +++ b/maps/src/main/res/layout/item_region.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/item_summary_row.xml b/maps/src/main/res/layout/item_summary_row.xml new file mode 100644 index 0000000..738b8ee --- /dev/null +++ b/maps/src/main/res/layout/item_summary_row.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/maps/src/main/res/values-night/colors.xml b/maps/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..a7b0d40 --- /dev/null +++ b/maps/src/main/res/values-night/colors.xml @@ -0,0 +1,21 @@ + + + #8AD6B3 + #003827 + #005139 + #A6F2CE + #B4CCBE + #1F352B + #191C1A + #E1E3DF + #BFC9C1 + #89938D + #404943 + #FFB4AB + #690005 + + #E1E3DF + #2E3230 + + #202320 + diff --git a/maps/src/main/res/values/colors.xml b/maps/src/main/res/values/colors.xml new file mode 100644 index 0000000..2d51c20 --- /dev/null +++ b/maps/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + + #1F6B4D + #FFFFFF + #A6F2CE + #002115 + #4D6357 + #FFFFFF + #FBFDF8 + #191C1A + #404943 + #707974 + #BFC9C1 + #BA1A1A + #FFFFFF + + #191C1A + #E1E7E2 + + + #F5F1E8 + diff --git a/maps/src/main/res/values/strings.xml b/maps/src/main/res/values/strings.xml new file mode 100644 index 0000000..4e1600d --- /dev/null +++ b/maps/src/main/res/values/strings.xml @@ -0,0 +1,114 @@ + + + Maps + Maps + + + Choose area + Drag the corners to resize. Drag the box to move. + Save + Cancel + Region name (e.g. Lalibela, Ethiopia) + + + Map Regions + + Pick map source + Choose region + Save region + Downloading region + Cancel and return to region list + + Pick one active region to build into your app + Maps + No regions cached yet. Tap "+ Download new region" to add one. + No project open. Open a Map project to apply a region. + Delete + Refresh + Use in this project + ✓ In this project + + Download new region + Delete cached region? + This frees disk space but any project that has already bundled this region keeps its own copy. + Region applied · rebuild to see new map + Couldn\'t apply region — check the project layout + + + New map region + Region name + Where from? + Choose Internet in a Box Server: + Local Network + Local Network Hostname + Internet (iiab.switnet.org) + Layers + Vector + Natural Earth raster are always bundled. + Vector basemap (OpenStreetMap) + Natural Earth raster (low zoom) + 🔒 always on + Next → + ← Back + Save + ● Checking… + ● Reachable + ● Not reachable + Enter a hostname above + Pick a source to continue. + LAN box unreachable + Couldn\'t reach %1$s. Use the internet path instead? + Use internet + Pick another source + + + Step 1 of 3 + Pick Map Data Source + Step 2 of 3 + Choose Region + Step 3 of 3 + Save Region + + + Allow Maps to access your location? + The wizard uses your current location to center the region you\'re about to download. You can pick the region manually if you decline. + While using the app + Don\'t allow + + + Region name + Source + Center + Extent + Total to download + A region named "%1$s" already exists. Save will replace it. + + + Natural Earth + OpenStreetMap vector + + + Downloading… + done + queued + failed + Cancel + discard + LAN download stopped + Couldn\'t finish from %1$s. Continue from internet? + Use internet + Cancel + + + ● Active in this project + Active + Inactive + Switched active region from %1$s to %2$s + Activated %1$s + Deactivated %1$s + + + Maps
Offline OpenStreetMap: a project template plus a Maps tab to download and apply region data.]]>
+
diff --git a/maps/src/main/res/values/styles.xml b/maps/src/main/res/values/styles.xml new file mode 100644 index 0000000..b8345c8 --- /dev/null +++ b/maps/src/main/res/values/styles.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + From 9c5acb47f24c4192abae698fc24d512ede9b9d09 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 07/10] =?UTF-8?q?feat(maps):=20project=20template=20?= =?UTF-8?q?=E2=80=94=20emits=20an=20offline=20MapLibre=20region-map=20app?= =?UTF-8?q?=20(Kotlin=20+=20Java)=20(+=20unit=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/templates/region-map/README.md.peb | 77 ++++ .../region-map/app/build.gradle.kts.peb | 79 ++++ .../app/src/main/AndroidManifest.xml.peb | 23 ++ .../main/assets/maps/fonts/notosans/0-255.pbf | Bin 0 -> 75568 bytes .../assets/maps/fonts/notosans/256-511.pbf | Bin 0 -> 126627 bytes .../assets/maps/fonts/notosans/512-767.pbf | Bin 0 -> 95136 bytes .../assets/maps/fonts/notosans/7680-7935.pbf | Bin 0 -> 129999 bytes .../assets/maps/fonts/notosans/8192-8447.pbf | Bin 0 -> 26330 bytes .../main/assets/maps/fonts/notosans/OFL.txt | 93 +++++ .../app/src/main/assets/maps/meta.json | 1 + .../app/src/main/assets/maps/style.json | 133 ++++++ .../PACKAGE_NAME/MapRegionActivity.java.peb | 384 ++++++++++++++++++ .../PACKAGE_NAME/MapRegionActivity.kt.peb | 344 ++++++++++++++++ .../PACKAGE_NAME/PmtilesHttpServer.java.peb | 170 ++++++++ .../PACKAGE_NAME/PmtilesHttpServer.kt.peb | 168 ++++++++ .../main/res/layout/activity_map_region.xml | 61 +++ .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/strings.xml.peb | 4 + .../app/src/main/res/values/themes.xml | 3 + .../main/res/xml/network_security_config.xml | 6 + .../templates/region-map/build.gradle.kts.peb | 12 + .../templates/region-map/gradle.properties | 4 + .../region-map/settings.gradle.kts.peb | 24 ++ .../assets/templates/region-map/thumb.png | Bin 0 -> 7352 bytes .../maps/templates/MapTemplateBuilder.kt | 208 ++++++++++ .../maps/templates/ProjectMapEmitter.kt | 144 +++++++ .../maps/templates/MapTemplateAssetsTest.kt | 283 +++++++++++++ .../MapTemplateBuilderCoverageTest.kt | 127 ++++++ .../ProjectMapEmitterCoverageTest.kt | 53 +++ .../maps/templates/ProjectMapEmitterTest.kt | 188 +++++++++ 30 files changed, 2593 insertions(+) create mode 100644 maps/src/main/assets/templates/region-map/README.md.peb create mode 100644 maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/256-511.pbf create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/512-767.pbf create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/7680-7935.pbf create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml create mode 100644 maps/src/main/assets/templates/region-map/app/src/main/res/xml/network_security_config.xml create mode 100644 maps/src/main/assets/templates/region-map/build.gradle.kts.peb create mode 100644 maps/src/main/assets/templates/region-map/gradle.properties create mode 100644 maps/src/main/assets/templates/region-map/settings.gradle.kts.peb create mode 100644 maps/src/main/assets/templates/region-map/thumb.png create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt create mode 100644 maps/src/main/kotlin/org/appdevforall/maps/templates/ProjectMapEmitter.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterCoverageTest.kt create mode 100644 maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterTest.kt diff --git a/maps/src/main/assets/templates/region-map/README.md.peb b/maps/src/main/assets/templates/region-map/README.md.peb new file mode 100644 index 0000000..873ad75 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/README.md.peb @@ -0,0 +1,77 @@ +# ${{APP_NAME}} — Offline OSM Region Map + +Generated by the **Maps plugin** for Code on the Go. This app launches straight to +a full-screen map that renders **one bundled OpenStreetMap region offline**, with +place/street name labels and a recenter-on-me button. This README is the +project-local Tier-2 doc; for the longer "how OpenStreetMap works" tutorial, +long-press the template card in the IDE and tap "OSM + MapLibre tutorial". + +## What's where + +| Path | What it does | +|---|---| +| `app/build.gradle.kts` | MapLibre + Material dependencies, `noCompress("pmtiles")`, `minSdk = 23`. Bump versions here. | +| `app/src/main/AndroidManifest.xml` | `MapRegionActivity` as the launcher; INTERNET + location permissions; the network-security config for the loopback server. | +| `app/src/main/res/layout/activity_map_region.xml` | The MapLibre `MapView`, the empty-state banner, and the recenter button. | +| `app/src/main/res/xml/network_security_config.xml` | Allows cleartext to `127.0.0.1` so the in-app loopback PMTiles server works. | +| `app/src/main/assets/maps/style.json` | MapLibre style. Tile URLs are `PMTILES_URL_*` placeholders the activity fills in at runtime with the loopback server's port. | +| `app/src/main/assets/maps/{tiles,basemap}.pmtiles`, `meta.json` | The bundled region's data. Empty until you apply a region (see below). | +| `app/src/main/java/${{PACKAGE_NAME}}/MapRegionActivity.{kt,java}` | Renders the bundled region via the loopback server, forwards lifecycle to `MapView`, drives the recenter button. | +| `app/src/main/java/${{PACKAGE_NAME}}/PmtilesHttpServer.{kt,java}` | The in-process loopback HTTP server that serves the bundled PMTiles to MapLibre. | + +## How the map renders + +MapLibre 13.x on Android (OpenGL ES variant) only reliably dispatches the +`pmtiles://http://...` URL scheme — `file://` and `asset://` don't work reliably +here. So the app starts a tiny **in-process loopback HTTP server** +(`PmtilesHttpServer`) on `127.0.0.1` that serves the bundled `maps/*.pmtiles` +assets with HTTP Range support, and points the style at +`pmtiles://http://127.0.0.1:/maps/tiles.pmtiles`. The PMTiles must be stored +uncompressed in the APK (`androidResources { noCompress.add("pmtiles") }`, already +set) so the server can seek into them. + +No internet is needed once a region is bundled — the app forces MapLibre's +connectivity state to "connected" so the loopback server keeps working even in +airplane mode. + +## Bundling a region (make it show a map) + +The app ships with an **empty** `maps/` folder, so on first run it shows a blank +background with a "no region configured" banner. To bundle a region: + +1. Open the **Maps** tab in the Code on the Go bottom sheet. +2. Pick a region with the bbox picker and download it (from an Internet-in-a-Box + server — over the internet or a local LAN). +3. Tap **Use in this project** (or the first download auto-applies). This is a + **pure data copy**: the plugin copies the region's `tiles.pmtiles`, + `basemap.pmtiles`, and `meta.json` into + `app/src/main/assets/maps/`, overwriting any previous region. It does **not** + touch your code, manifest, or Gradle — those already ship in the project. +4. Rebuild and run. The map centers on the region and renders offline. + +Applying a different region later just overwrites the data files — the app is +single-region by design. + +## Where to ask for help + +- The **MapLibre Native** Android docs: https://maplibre.org/maplibre-native/android/ +- The **OSM Wiki** map-features index: https://wiki.openstreetmap.org/wiki/Map_features +- Long-press any tooltip in the IDE for category-specific help + +## Credits & licenses + +This app bundles third-party data and fonts. If you publish it, keep these +attributions visible (MapLibre already renders the OpenStreetMap attribution in +its on-map attribution control): + +- **Map data**: © OpenStreetMap contributors, [ODbL](https://www.openstreetmap.org/copyright). +- **Low-zoom basemap**: Natural Earth (public domain / CC0). +- **Place / street labels**: [Noto Sans](https://github.com/notofonts/noto-fonts), + © The Noto Project Authors, [SIL Open Font License 1.1](https://openfontlicense.org/) + — the full license ships in the app at `app/src/main/assets/maps/fonts/notosans/OFL.txt` + (OFL §2 requires it to travel with the fonts). + +> Labels currently bundle **Latin** glyph ranges only. Regions whose names are in a +> non-Latin script (Cyrillic, Arabic, CJK, etc.) and lack a `name:latin` value will +> render without those labels. Add the matching Noto glyph ranges under +> `fonts/notosans/` if you target a non-Latin region. diff --git a/maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb b/maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb new file mode 100644 index 0000000..12070c2 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/build.gradle.kts.peb @@ -0,0 +1,79 @@ +plugins { + id("com.android.application") version "${{AGP_VERSION}}" +${% if LANGUAGE == 'kotlin' %} + kotlin("android") version "${{KOTLIN_VERSION}}" +${% endif %} +} + +android { + namespace = "${{PACKAGE_NAME}}" + // Trailing comments below are load-bearing: the CGT/Pebble renderer trims the + // newline right after a print tag, so a line that ENDS in a substitution merges + // into the next line. Keeping a comment after each tag preserves the line break. + compileSdk = ${{COMPILE_SDK}} // IDE-installed compile SDK + + defaultConfig { + applicationId = "${{PACKAGE_NAME}}" + // MapLibre 13.x requires minSdk >= 23, so this is pinned (not a build + // option) — a lower value fails manifest merger against the AAR. + minSdk = 23 + targetSdk = ${{TARGET_SDK}} // IDE-installed target SDK + versionCode = 1 + versionName = "1.0" + + // The bundled offline-maplibre-repo ships MapLibre's native lib for + // arm64-v8a ONLY (the team's target devices + the on-device build host + // are all arm64). Packaging only that ABI keeps the vendored repo small + // and avoids a missing-.so failure for the other ABIs. (ADFA-2436 / E13) + ndk { + abiFilters.add("arm64-v8a") + } + } + + compileOptions { + sourceCompatibility = ${{JAVA_SOURCE_COMPAT}} // resolves to JavaVersion.VERSION_NN + targetCompatibility = ${{JAVA_TARGET_COMPAT}} // resolves to JavaVersion.VERSION_NN + } +${% if LANGUAGE == 'kotlin' %} + kotlinOptions { + jvmTarget = "${{JAVA_TARGET}}" + // MapLibre 13.1.0 is published with Kotlin 2.2 metadata; the IDE's embedded + // Kotlin compiler is older and otherwise rejects it ("module compiled with an + // incompatible version of Kotlin"). Skipping the metadata-version check lets + // the app compile against MapLibre's Kotlin classes. (ADFA-2436) + freeCompilerArgs += listOf("-Xskip-metadata-version-check") + } +${% endif %} + + // PMTiles assets must stay uncompressed so MapLibre's loopback HTTP server + // can random-access seek into them via AssetManager.openFd (ADFA-2436). + androidResources { + noCompress.add("pmtiles") + // Glyph .pbf fonts are served by the loopback HTTP server too, which uses + // AssetManager.openFd — that only works on uncompressed assets. + noCompress.add("pbf") + } +} + +dependencies { + // Versions aligned to CoGo's bundled offline Maven repo where one exists, so + // these resolve from the bundle with NO network (appcompat 1.6.1 / cardview + // 1.0.0 / coordinatorlayout 1.1.0 / material 1.9.0 are all bundled). The + // genuine gap — MapLibre and the androidx/material transitive closure that + // floats above the bundled versions — is supplied by the project-local + // `offline-maplibre-repo` declared in settings.gradle.kts. (ADFA-2436 / E13) + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0") + implementation("androidx.cardview:cardview:1.0.0") + // Material — provides the Material3 app theme (themes.xml parent). + implementation("com.google.android.material:material:1.9.0") + + // MapLibre Native — OpenGL ES variant for low-end device compatibility. + // Using android-sdk-opengl (not android-sdk) because many devices in + // ADFA's target audience (API 26-29 low-end phones) don't have Vulkan + // support. The OpenGL ES variant is identical in API; only the renderer + // backend differs. MapLibre 13.x requires minSdk >= 23. The map's + // current-location dot uses MapLibre's built-in default location engine, + // so no Play Services Location dependency is needed. + implementation("org.maplibre.gl:android-sdk-opengl:13.1.0") +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb b/maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb new file mode 100644 index 0000000..1286bf9 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf new file mode 100644 index 0000000000000000000000000000000000000000..78e18c8c0f17f00964eacbd5b0a46f23ef8dcafb GIT binary patch literal 75568 zcmeFacTii|x-Te)cGKNTqoQkJbR%|fU{EvXD8w8 z`oY#uZm3F+*pNd)Lqh}I415y`3JVJ}LmYzgxHb-YJV9#5v2sEPNWv%8l+ zk5$LH({j3&c1J5?T>Y~;7Pg1WqnxRk?ekkhWsy#P>8+BDfs$|sa$1Xcy}y`gPfBT; zRR{`0ZGDm(XI6VTAvVOM`YCx&ez28SVx3UN%L}sfh_9Jg?#>M~cZ;p;>ub*rFe9_d z+dArU7!IKYT+zaCb)r{9>EOmcW}8xdnMHl_Enx%8o0icyFw|KT>FOSySyEn*KruIV zr$5 zwWo*~Q#q=bYvW24eZ|bE@}VtJbJr5;ad^qV#!O@TJnD2w=?o$Zsp&zr&sbs@@k9Ewf++r$5wnr+9`_?9E6TA#w z>AS$wlj?+PeZ`d{TSMhhbfTGEa9VzTev4Sj&94`(_7>*nM0t9|RrNqynu8DV5`45| z`grv)N5E@}7daNq9XrJ15r1(7}(M)iJ-&k=-I* z=H>;_1HCQYXj%v7^JKG4xm`;Vm@Er(gEu$T9m7ldr4zM<0_ju(%SZpoy(>Qgt+<=Z zE#q`?xfQYQTGxIc5Y)`6No+PdIRQRW>9(&g5(tD_ns#J5o$lug9~39OdnbW*@n=|n zM`8V)zC3tLQ>}XCNtQ}`02&d#|S@Md1&Ar$0;t(WjgD;d3^1s8?OxQ zXxSb8{q;$H9(KkWH`FbNA@Q)+rWN?UU*;nMD>5e7RnIyow{w1PZU_k3@j>n;uMA17>VdhP^|k4~_KK_s;-`0x z!I@Ru@%i<=RncG*o9XgU-z|t$SlhKUv$V4^+K}p}=jcbHFj3dJ!tEt)ey}Y)IVU9= zdl%p)eMMpJu@zknVF1ovC55EbJ+)ARil@2ak139T2-39R+@2 zpmFCcY!r1P-=vbt%7RE&t!pq>-~Dp+ot=MFObnA`_WV3d-A~ug!qlBsKXrNFxXNwS z*5fxc%-v}b(UA;qTU||i3h)rR$ET-f39rn3qcTb=%5via9n7gLPGMm-(^>N>;iFeH zr%5m})KQV_?-p6a6A0>A-XH!rZWENkz{35X%ImCH-rY zb%nhv(~T*<1l1?TzDd|CrL(!qlD3+W?a`_@55hG~XJ$e7{8CR*WP0+i134Rh5&_X*2jvvcEp zjqPc%*+s?qtT0cbr|?`uw*c7fK2{n!)?V~5W(e8Q=*5+bs>)4&_HylUl{>2W#|dZd zYg-con9(s&!DMH{=U0vb8z2yVxUFR$5SLY0QCXIk5(;-bhmkM5wDM<_w)KvT4tCe( zgt=E0)zxt#oZlG`V+%VbSJswxXSrpp^cJCT9CQaQo1mO_ z;nwm<|KhHQS6E_JzYQg-T zguk#cT$SK$aL+fjS@Z>TlHjze-uW+cJCaT|!%qD-q+@jXHo>`_vpXAev!ca~O|d|-H!lFXouN-cY4_y3 zVq;A%8SSd*kw_N=m2n;#HvUP4^__hKef+kX{N!?wI$R*%(Xt~)Cg&8D6cuD8h0<6c z7}%*ncE8+xXXZkphlPiQ1b92xBLP9DI(@i!M!7`JTn<#ZtLlksCEn(^MiT9bYa>~rWKv08du0Pz?b_&S^RVagT@ug)zY7J;YRdM(9J!49H7o~!-m^U``td{BR ziH3BVo@X5F%Z2U&&|(5ITW3E{)F=Cz*oPG&=3NxwtchtFlD5nM^Jk>whN-W@dX~?} z+n^WDZ4Fn%xN2Sv$nIRkG{ZL+>ABs@Yaj#cUY$qEC8iX+P}U4*De4Pi*QuG z1k6ja1H0N?`#P)z;a9Zr4ez-|mk(_&zzVT`X&cIcH8fBb$O>3Ikn{G-Eu!KER z(z`6}EsgPdVd0iBgHn}EcOjEn>- z;nW>%TW<=5;%#&ByX)sM5&k_Iy8AG6j{Y!gL0-mc7YP9=!0mIw-Sr>*98tm-2pY43 z?cZKya=7#I!Ky^kr^jSg^^jt&D?iNXy#phsb#`;KHrdz6z$>nDXk(!(k74`9mXX)B zurX8~?fTHj7ay@C?`zubv6Um+FpmBg-`F#Y1gl6tK7(y)`3k=v7)^Wn9)kTFsgi6rY>Ap7GUVyW-Xi>Ju2H=Gnc8+C<_zM*qukUC~ry#*NJ>1j3KFk{w&UfVo(sDW%cQ<9? zRnhXsXEc1k?S@AurbIjQQUa~aS@jdWjhtvV z?I*_GVac4jlDLAplC%h4^B2FrGIsI{PmG~PCPw?anrYln`TdEesk4{6m8+MVg^t?w zlc06{q|`cK$R460BOJZ<4tf(25$a?1;sRpgU<*KhOH1=%$Zo)xp1Jr8@$bPw7&3!9 z7fxM&s;*&)c9aODtbxYshi^?CoSgOvPENLlmSkpRWF&qC956h+VmW2d&`;3+IboEH z)*;knBJ98Fc%(gSLEGC#pTKj0Fp8Q?g#A|&PmC+7gof^c{%13-DG@O-G5Ay9AdL9l z+{x8-zaOry4xetSy?y_!r|;Dte}DEeyh|Q;co=p4&Lt3WCm$O7u}aFzInf?EH<92x zcKyA5Kx9-D)7ShZSVqbr`xO+m)FUd7R3jc6IQwE-S5wP5Bn9?!Jk`RGoKlN6d9*uh zmX5hOP;$Mo4YD~@5##O{Q8KW;%*|)mdcd6SPB*7fjLE4@GZ;J34b16WKs&V=cnZijrJf zfwiJIFBpIktbwkB24e>r(tF@|0&8$gO{=WIT4{o}b5LA7)}sAwz+5~6&$zAr{bdi8 zC#qIDHr~_#UuUBi_O$rSY)~Q${tFiK<|HVghb-o-lCpvXKQr87CSexyF|bF`OZ@9m zm8Ys(n5y#AT|MUj#PvLl(E|MSl{GyL1V&D{E1Vs_spB3~-YXC^B$MFu^kWlBTGO;* zyf(=jPA{w5hZgXbK{|vx5eVPk{@@i4dZDNlBm{wQR?UKz*(%x`sfzddKp+VS1#CeW}RpC%BnhprEngIqy+81~SGE_SfZD_DC3GCWkOd?z~y%14~0`# zW+tcsN&XgUXW@+S$sK8nWIL@}-=ouuKY$WI0OL^Qa@R4HXR6i5j{pABz|K23GA24a zz|CCy;aS2ZHDj;f1hCM`OERMT9d+*h{LsKXJg2f_aAa7}RGdPyeRo~Mo{`l!BwpXy zmI?XgiDZ)}hCYc^L-I|@_{7p@ad!dJ>AfW^UCH)jXLTK4vOQLpOfmqAuurkX#zh93j830Y4;h>Z_uU0;FC68*E<=fM<+az?Cm2h>S7Ur-?TkRpfp z0*3u~vl^zqPSz%P9)Lgy;Q14N?gh+5^4Dt+EVMgT73)q04Gy~xF|ls)i?BY)8}{eI z2I!#9E}->nidxdCp&*rqumYL7fJq=C$3;26|@Q5kv zUHH5>JtN;1VekIZik4c<7s}Sx7RR||XngMII0mN`H}U)Xcy%0B0F2?;hhQ^CrgMsm z3R0MU_S$!TCj9zX+uSW6gc-*0b2fQ@UzKq2q*7@+b$R%xikfQ2QNjsdlee=Y?#m|5LCtHgr4{9z%=kbT{YU1ch^(?E9xQc!Yk7LG zleSw_LDRt0^5)K#Of0BQrI>=EKOx_kn;a7^eOc(?FdaSP%J>T-o%L1q{KcKI+9V&F zfRyTW5dQ{S}EmMn^9_#mbGSS2Yc-!eeyR2*L%Vgygl~t4%W+eu>89uWQOf0DF6b!+{v{hz> zy6KTv6+nlMt}wMOtiHkni!j0*p&)OqFlW}o0=80)>hSVC3`{uL~UrP zwQXgY39-qb%FO|0rQ;luni)$Y(-JC2cK|-6fE*`Br=_K37q^S|5Rbk1#v!brxuvC} zcWPsvUlQqjNy90!Y)CAgUs(|Hs#5$da01|x8;dgoE%^~{moz}O^Lly&L*g~Xcw-vn z;X4a|R(^g#VP*U1CgS-Y9lU+~f`b`h@%deg2nuy(B(WlBBrk6|SSpBXQBrH_in3A? zlQJv%S6BN=!U0H{=xuMTt8WuXzRtF0fqvPwv@$m%oRY})7W*sWyg=ccn3u0@tgp-o z`863qwxoojx{jW{f&Si(`jS+J%Lf}uL~?dPQAu%OHY>{C`QwxK=FTJk)TWdimcm_Vt z;caJeZVp@vKv`9`uWPHTK#8mBRW|rlZ*NaAlaax}8+K<-L9)LqEd*<@Zcv{?DE4~h zHmISMBt>sws(JhA3usE*7r;})yDGn&g{DM(Gnua1H_*rvzE@(t0q&okojW8R{`aFQ zuT=l1qxzUx4{8&%5Q?3qEoR<>-UN-4L_q=h`aAas`LcFy$UdQtFiOH?0t0*h0?G)}J zV-;ZNq@s-9vYpO-LpvfRD2xd^#@os4-MzCHe>{*euw!0h zI|ih%&NYAy_McB3Y^V0Ox{JMM?!53;~6jnJ;2va4W9cs)8anii0 zX&;bO+{hF3_qJ4KhkF`4{^h>59W^p7uc){%BaY#2{Nge}^`54MD>XQb85-beYoPY~ zi9XFU)E(AYb0nTvw zjcqW}xIqOW89n@&K=|q2M`C>C&^qjcFvoXS;gXABL}j;$K2J8J_?bL8Lm;T!*7Jxd zA5h2uNORG;0mjCK7nVrB{W{&8?r*LJ=%R;$sI9L^^t=Lu)ge=IPh;SceCjh0F3pm0b`0zC>z*-ZvsrM zg7doOH^y2@%DZNFCJ^g3A|=%f$`n(Ae$m!UcVUF9_B+RrtcJn)J^AYLcvnfHpXoDQ z=aB5?LCN;g*6MU`O-6|0dys{-L#xYU-6N8n#s1o4KU2@Ra>3Gae_cU!w{Uxb%V9de z9-R3+UZ25CD(sQ~`-^b}rDqQKMWm-+5+Iale}NlZyggczo{*T+D%wTnl6PYD=+<0s zV@+)vD3AQYa3|#2mh4KWCnjeVYZFarR5N2TtAZz-UtM39iAP%VBi(e~+tZSZ8af31 z{ruLN+-M)O=l69SC{f8dg~i33%!E*)`I~EJ?`xX7k^&Jw^d~wPzPj<_q4@%xTLV

)1#?i_q;SC#}?Fe4UP=bR?*DMgS!b%ITN`oIe}`lTe%h0Qj@pAUfa*7@wNNkjR{N2{Rp-d$fn)ZvGbuYViS{6>&JGc0Iqm#LrctH#j~=j_{%GR z1i1n3THaP)Q{Ub{zcJpF&9KF6DB;AEcvUgoo*&`y5hJXDr*(Ruy(HGh)gXfRNks~95Hn9 z4+#q*T57n4rg4gLV|`8i)9UzrZJcnIu%g~&#RM3}X#M{KL3*KuJ@c|r0B$KMRo%R% zyf6UwSh;x_Aag!Akm7D3KOiU%l>RUW z4Zze_09XQyC%l-)?+Ki1mTp8hi?|DGrq7XLp{9uSi``^lNOa$Sl2Zy zMwFwcf4l$sgQXj&RC!hH{SYSv?|$;qo#&u-`GzFq6laAv;Nx@t=F|6|?1^+H-Q5s4 z(@7ZB^MD5Y4@XoqRX-omu_clYHFtAcT0+X97ESVsFK;^33YbV8o;_$_s3C&vpaH=< zsh0PxaZ4hdLNoFXHC6!f9eit@#S!Qp`xf@nlJcfX!vp>U0x_Wy879isS{QCeX9383 z(Bk~`97wQK51N-1?Dy~Cb+5qd!XnawMWkcvNpv>Re0oC_#5~sQ1LKmIe$J-fXg*05 zbsRvWzR_S}#{scAL_<@(h|$9F>&{RzTE`Z)4$&TZ#T3+bkIrxG(;W3I!Jf~r=^B=- z1FfEuLbuT{aUzE%)CnJqtiiA9-3W|&kn*= zJJ1ty%9{8P0kSLYLJ)id~*9a=ZJ4!*L!zV5srD+Dp0d}u_9D{AX(V1vIA zbWGLfpb<FO1f*y!HP$PB{O{E(}oL|4!V3U;q`uIS2$a}uc*m~MIQsgYY?cvKkKR`V823TAL21pCL!3E!#csQ%Ar zVt)ATv6hLWCz;L&Wzfl<_C^}_Ae;v1*Iyes`!VBFa|;R!a#Q0&NVa;9;2V`|ug$ze zQ#jSlU3>w*tEnn4f$jv~e0TM=nNKvkjyE>DxT08DoE`4005`tgy$fn4-ck9jBTF00 zqRDaLtZYNvTa`+;dwWOEEh4XFd~JT1+frLy*TSDznHN+h`WnBq3e0F6Q;4|Ld8L&# zRi%YZBP)`w{7?s7_n5LinV45r+SWfhF*?}O+%&lblETY`n$bMHI#5~OGrPJ9aMDs2 zxCj7?rdS8(@ucE5z(Llh`g;17Ao{IEwA9TG0>Lw|K3Q8iygA)gn$MNaw`Vp?qku=x zgqrcqp|Ymg&7pGS3zm&0{}*OuY9H{mFyl@HM%`sTQ;bW4+_Q=V{0RQVp(5V zj0e)1q;u^#trOCXRoQsY{DxwAq#=`L1yLi-Gpl`-1x@^s;ojyd?$9v5K9}jPrwdUM zL#uQA>ViVh;WKheYRZe!!o5DJy|!fl01B}(J^-W#<6LpFHF2*l^dtl%hS5_HDt!aZ1EMXxV*BL_VePD3DE(ZKvzGC+jmJoS+21xxpiJ~5$sWNTt=7VJlJ za4kVQ_uMitt4+K{%5r7$dB7& z(i=NQUiYG+zqE56_d4DB?)%#ib^!}(t|JE&Ylv&0nm+kO74u@v?u^wG7x#iwgcIiY z_BU{#AzzlPA2reTSWTk$$GZ=3H`>lRY8(Ga(hko37q4uB*`4znO7SU!=S8_{>w^6` z2(OKa*_PQanCA^!DSMOkm@uvw+5qodsJ$bz5Tc9vN+LmKqW}iXndXyNGqxjc%MP^s z2%!x1lb^vTr3He@FCDE;AnM=IbOG<4R0>YHz??QNw+n)mG_HULFslV~nh|4iAjm3# zZ1VV*J0CowvcVcn@;4_20$5G5d3XKzrI%)22)riST|5o?GvYg^k>^Yg3{hiGIu04Y z+8{cczQ1?YB|Ha*3}6{0hWi0rmO4%n&Hf!0lZ=o9Se)bB@+99+i1Jkg zZ4SH)lCHcEWaiC&nP_fqY2%KpY>q&v9!{xAP0Pq{nfco+@J=Nqo=Sra`V}a?N=jyC zPGOsPcd|Co3#YWVw|DW!74y8p2p2^8a~|4~Y*E-#l|nH?l)o)QldUd`23rdv-0)v@ zcX#syld?4gVB?gU+}!-)hB1iv$`4Ue;sb*t3VP%Su-d0k{lhRyS)`JZnVD5kH@XcA zlZjKHKOH^8%U==xfhbsi7G#?{bG?9nAPVkVSehN>R;C78`>^Vi&V{b#s=_3?qb{ga zO7B8WdOWz!^d1_z`YZhlG>WH#>AMH#F+&tNe@inex^rzfYy^5vew@OMsO`CT)+vaz1Vx`x)?vBoqiY=YXc&DFsM zPFiAeW>GaK($#_v5w3quHD@y^UOoZLxNwr0Jw#TmqG%isQ(b*?M|USvb%dI2qu>CW zS2u4zeXI5E*)`s{1y3^4DVvB0X99vhm7xGS3NqV^7Hx2 z7tb7b1*a35GluP}+jk$oe5?NSx_uZxg6N5!79R~wZJZrUHO%}oTg87)wPZ&GkjVZa zkxa7X2Vz3a*!JpBV{v9mT2?_BC)V?=Bg8O?zsM&AtxYZMJ%b?ApF%8C1z)`JSuU9s z&*QTQ%hgkJ*+b)RWVf#t*(Z%D0ZyPT!J(l6; z^il1%@4|4c~zL zQOT}sum%uFoQAd_rw0N?ASW;Q99AlpQG!Ih;aQTb>oNh|>JD_P}gSxZS zl@s`l2L2*2vr)9wH`*Hq6nBTB{=d2Mi@=rSvpv-* z0XFK0hHcJ8@fg3gG$qJU`_94k>};(r%#8DgFf7%B4Vsgg!1VXB)_Qn>aIi&bRHB2~ zhc~wYe#Bzc(I!1}8Pw^IpiUpmE@G_=AB%&TMT|8shIlZmh^@9|VGLv+hecTdsE1$o zaaiT~mkA{dTX(?9a1=Hr)D?g8n)m?Je4P!I=h;|GqG71_mx)fimBnDp@y|uPA#4p@ z|97P`bpQOzgpywYxTCxu&aP4dZT6psaVtMZ-Tiay`BZmffTs8Q59RaYJ+$xKxpV8%kKfL(oj3tP1)WRL_1f2vDn`I(0A+0;EsQ|G zXJ9{LgP`08``H>wg9hz^lSrF1#I(tiH(!3V^`ZnJ7ZNjw>Sb^Al5p~thJ{x!XsO7H zRK(7R5B2)=<1GzKa!gJ|E4P0bux@T^Rc@H$6$pxsEo>bUEw63wY%7*UBd|^%8A3>O zyHK&WBAyb?N>*gO5HY0T5R%&_++3X$wAI%&wF}0ZL8CMPx5DuH+Gul0R#IY0c5zJ+ z1Rk2xayle`n`vc7(uhP7Ej&KT&+?^RC`7|=4p+ny$w9$bqq!ToMuWo#L{|tUDGzdc zpk;~kQ#n8p2&*CUrBwdO#80TGzb!k>r;B5VW`hoJ@S9UUFx zdx!`^tVf3I8{gQhtSk_FhzPEv7SW&6Ejdwie}5zk5m67qzeYC|!%d|*DaolyqPl%p ze#h+Yim<<=~(m8{)6C==0aK=Y-{rEu5#48AxAI$~leHjdUb#2!Oq*C;$_aSzxILu{#GQWQxf_E++)8FNJReWd%uo z*1imM6g*URHP$OAG04eTzP`Ui&sEVm1Uu^~uS%~Sgn+b$tPodyOtps1 zUDdaw7;6IX)8Of^%By-8HYQrw5uQeBmyX~ho@7hdRuDxrd2!_!CR4vmi1rqGz};o` z>h}}4nEfi+SlXLu&ttmk-3RwD`r$tm6GN*TV+|Q}yLUHLls{}~p4^r}7=xer%PY#; z*FfaN*Ew!cw3p%IbIML6l=m*}Ott2QI_um~lH${9hZTyEx-^=N#y%-Drv(xT1?368 zru!uDPj-vHin=(Fn1Y6}6LMm?U|BlRoIyEYC${ygL1%5bX_ppH7mmXfSo`_ z7cyRHo+bzE`KYsk^HfE4v{WF7R`r+k%e;_)MM)UXFA8;%>F()(96aQZ6J!!#> zp1SaR`7FSoCoDW1v3#YBR=_T&S*W4dbS}h>C`II!Yy}`K7fn1yUA8aB z)`b9nfz=5}RjWuL+$V1gDlo$n^MB7x?~}KNBJebu}a&uB|F&u9pr&@3j|PY0L_PDb+9VQ&&(QS)&VkrS@Qq*mw9esr2AiJ zYl}ORO_@P>_15@0RrL==t{ua+Q9U1AMqhv=ZZDF##)*p{Px?n8W%j^J=P`#XST*X55e>+Av za^a!2nUhaYL{v;vIL*uMlg4cjKEFQs;7DaA<(8ILmKSG%8_`AS}XJ7skZNL-qdsmPOsvR&#i6mZm)=jJ4=!%7O&LIDXgl#g`IWC2b)^l+MMAQ z$9NgMgPqNr|2jX^RbSh{9g*&Wd7F#v8yV%7JvU+~~ty2VqGeOj5 z*nwU4>Hb+f(GR6b9vEhZcYnckOa$;ASZ2l#&%J_Q3i| zZ(%3`7-bNJ80BW7Z|)7DIeU`!EEow7$J8ni0uAuS~WVM7V#r zreQ-%F6$bc0lZPMBpj5*GAOV;ZFR*gy2}S_gM+reDw9m|D$|271PI|1;-$6EbA1)D zN|^>|fA`?R-=O*c#uGp&rlzy2hd1OfP3Rs-O8SLBmds(Woy36DOPI2K^gSUt3z#*8 zcqxBUTZ;l;A%QRr1CG8-PEJnE$#>^^A@&5_^dH;X+nX!%LSAJm%@Tjg@bDnSix(x+ z9d(qCEiZ+5@kok;-oyQVM1;{uuI8E#_7$mk4|{V1&F44I9Fp%(@1i8{LxgL{qmB~} z!zKTOm;4i8@=u5f9?OJS@Xwe3Jg#D;x^n#g-=sX*e}0pq4gJrXRLM%X;R&&haafAr$-2CQYvFTlymDDTjb61K6tHbX6-=7Y!5Mnbvt8A z?c~Kddw_{yea_yl9CGlT~hnx5eQLO462hZY<@fk zTNRm9)(0jPSoVwnA6F~gr19OvDi(_+i;Az(L5S(qzl(3KmX_A8KFQxOkLGjbeoITwg#rbuP#MVn zwUQL?2g$d~DB#`cz)qos6>z0%z>^)`D6h)SDXbgWT7Wb*>lZkwv$LyrWErIwQ%oNt z62g*~R<@<%kVfvHc@rZceR^Yw-eB)&UnzeWPlaGe&>c!Z;O&z5J1v%4iZ2S|8Q1tM~U{6OK;Cc{7 zUCI?g@bJ!te0~CA2~i;l(eCQ3a7wg zYK_@+$6yw61mQ_}aF80|sqaVvN5p=fo}VAt&E(;G10y5kMa5G0jEoF*)XrSJbMO5( z(!IMkAgJj3-wu;huxO9Jk}yv5-w+AQJwicQsQvcdB^s5s!jg}0e=xyVZBiG<*kul8Up;vq9BxgUV&73AfXur+zgSBwV)&+DDD44qQbv=xKW`{<)vNLp!qmtdf;r=>D4mSU-X z@tL7BEiyT`5Q4+g<3hbH-`zTP>9MY*i*HaUREVK^+I@O`>*oVDc?KD^_F&Xn`J%KK zs-yNFmtI+*qHv)BEOs}=%p0_ zXRww|A zAWCTvLUFGQ2xR*8l?@7bf;tRr_(Z`~y0=a~K#@^GV48r6;MD*w9lfsQ8dVBqOn6Px ze@>73sh?p{LvepDBL@Z&uLtZcx{AXHdEdHSek##Loyfk`mS0LgiP zinF2O16gg7H3=FL7tL!&fQBNv(BSN$A+dUn$Btcr${vu7B1J=Da1YAAK|Avl2B#U~ zmS9NU;R!PkE~)1ciSliesLEI{xbW8(c*?D>o#rjj!ErY`+N3{U=6t8(pql)hG3H`| zTqh08VF4+Ln%WLjm}G{pwJR3I1jv>V=D^^vsAlH=D91cM9-@wr(+PqVtucoN4`SXS zsUK3#I}z3f1@=(9q6?UT3dJbMXegUDssOE$<{AYth0EMLkT#HV57UrNAwz`xpGdc~ zVdV3q>jR~cux#e!izvK^o(p-gP`k$sVj^215w|vx39iO@5pws1gY61a8*29fij+<4 z!2`j&w=+Gq3)>16nn49@OmR~b9+-}DJp`+ZUAa(s=F21m$m+ka4u-;0@W6cTvYg*B z54JR%qf!Gd@1gn>UZJLZa6{5o4@g!WJPwP*d3jLC1^})Jh`kR(b(gLzT$%ZmFt4C4{cUjX`BMW_CuQ?iH$D_V&t;_aToB zR{8d9p8_}2&mO&jSL6}Rz)-}76+#x@=q`%9G1cfS5^<0d56-UVw^($s~OF{rcKAq@a__ZG+NShQ2Adm&^@^oqv98LXHRQEGNd> zK$+78p$=HF3aFg&)|vzYBamqETAAHNan!vFv4|HhfO(Bp03vOX6BNQWA-okm-SwaEe*lb%JGVJHq?qRxMS1Aohnfw~&HNBXwKR`C-RuQq zNT0a=4q;TnT~L{!m+x=DEl0k{6cu!A=qn0ZME8@KaGn=Qr%(mqO*;>&uny2=}P{lA7 zz62u`ibm+?_m#98a+cThCw+B?V4yiUo#Y>d>fpacjOs{6|$BN9|f*;?q#3w3yR4GSGU z@;g>k0BU1q`J3RO$6$;eqCJG|g4+LcniPA+{{1d06V976sK)y=Rb|R~R-}jCAMh5a z?I)dsj|vrrJtR?1p=elVh=#@8xgbIC2BHOb>cX7j4Fn42+Jy;KHi*o|9J~j*J6^Y? z4cFY^Q?Y|6zR5M1!VVw89kiKUofye@09V!E3TzyfpV>zzUr^iq(B_2Y4+#7FN1C9u z+XrPePr`3ttA2cN=GxuoP%EHn0Dc2(!W=g0&-e8)(0Nw~s61@b@4vhL4&V0ZH=Z9K z`~utC6zU6n`^|R*!~{6q@Eq(nk00JT53B;<>}|-mi1GwKmWl2&Q!+3V`6{p@%#%fR zGjfO09+F*P2Y5>lbD?E6O>8gl_EUlCQ^^3%%m2Q#-%uDQP!8N#QYoSYvB7uotu?0k z`NRV2{nj$W-7F8B@30p^SBH|n-|EOfML8F!D2G>FhRO1G#QYOzl2T~F$Sna4a?+8f z0L=`fOrk^S)YR1eA-BH1o*jZwx$LB*aI7XEa<>s(Oq5i*h32z^!>4Dz9iIO8JpBFO zW`-L3hcr+qP=C4XoSXzSfNd-3;r)~-RUAt?V~qR1VKslH+u>K8Pfq}GJYWw;_L|oD4z$uIf%Yb zgb4c|2oN@bOb2ky1zLjEcoC8%K&eFm@<_47BmIS-Nn?*zsN5iS|(Na)*QE@K( z(XeEp1QeVi1v&uLs>}$(U^*xTiCdIzhKH(C!76owRqBE&fI(yUTJ0CI$dB`hslYi% zM{XdPy?zu-JnEmR(={(Bx? z`*yQ;MhB0u_oeMWEg&f;NBQ8WQs4r3ci=5xjczDk2wSD_LXpLI?q6eO03{w@q-o*q z)cvSO#R&z!8h&7Lu3*mA~!Z>bjz%_&|FD zM3cQ>*puh>rufQA1oCd-{!ssljK}^f6Ka^7n(LU~B$tmAjLZF?Iv(KTtRHB_OfMrKP zRel3WZ+=%{rwj16TE%TlQ3&*Q40CL)v_cK;9!WkYs_pzPW~A*%jm zIYh!xoPslI2bLt!HWUXXiy?B0)93F?)j zDqgD7N6$ahGIb#K7E0<8GhC!d&t#_s-pK}QuN6an`8#)1@)!Y&4=GYR6~ zpaAbpbrV-|Fw{f{^Z^tamEQg7`Y9}-`_$!@V=6?|(qq*}gpY6G90HsP-xr1NidkgKMgwm>m$T%u&YbnFB%)%#@QU z%#`wqoV@(pWG2Ny=RQuT>frTs*A*rQI%p!oDio@c$rr|ZDv~MYh#(iPFNnmeP#~u$ z${i7QpdwgvYybQ%Dx!r5P!>5aHLGf99qj5L>{>)XzF`Mc90*nJLj+WK z-3}FHKtUddu4t8@Kj(nmW27>c7z`dxgashN-=-R~6VoaN)|a~Tf)U}XY_P4akuL#| zCe05KmS$JQ!l~uY3%!7SBf>0KxV*M5o$9Mj3$Ow>3OohPyuN;ZQ)ybTvo_`^NK4Nz zD(0j|2RiFN#@qx{3Iob$`@35~Jvf*tRS51l4M@qvQ5A1h-ciE2`D!m3{2+KboBJ{I_e8!J>OUdq*nHf&B<3~^AoV!^@(w% z{J9;)9JqQANM#1b`s^1;e|u|ZACj&P$gr39Rb;2YZ#Ln5y=%rU;?f7Sef%s5DqC&hrAz%Nh zj9-$LoK{fIW;$7-4~0|8zlsWFv!={TmvGg@U{Na<%we| zKB_#-!h!XB>b|}!Rz%F(3@vA904W4#;$?WT^Y2apHXdEkkJaG?ipu$0kDg(eI;5kf z`Vx*p`2rmiOB)P$N0g%%pXxywfS{0Igw7+%uTKqJp;SN~CofA$x%a^}B%`E}*UM{F zQowG_s1-~}S7oyUBRl9<20jUu0Io`BXO~w;Hxb1GMoIET)Z5(56Rv+n6ckpmDif5Y zr*XO#{u82LWsOl4OG--P%-<2^V6Fi78vBeW$h(Ej)UIIjYA?D7j^&tNG3UQ(Ew0L%(huoJ(%FtGIs1S~iRajO6~ zBM^hCz^%|801-d}5kNTmKod$U&>0N6ualYP{j;hM&?aE93kuk*aH_rTgFm#;E~xF| z^TB(?3bfaJZRwu^GGJc5vH+?5EULu^uh6L>B0}F;x9N8APpu`OX5SE0avHKf@PN24o{^koB-oK;dMXUh{@ z#9CKzI0}H-6}ROoTOt+au@_=*%QBN#=>=8AabAF0w~4-rdck+s!W)5XI(=e%dGGvZ zIaCP~OHq0ql&MRt;!gn3zP>U)0R*UI7nD+52QYg-zoV`w*7NM64-WoN(*_(~$c5-< zNx1Mp+rrrwIlMw>Bxmz?heW{tdQ`<%^Tm`G+I$%X*6?p%Trb4tp~ ziok9G#mtVLUe(b*JR|_yMdj{CpTx?(ImPxCXxR~0o*{%!@_BB2e5NBi@TDPuu!0pC zzq-1qGRX(Zw?;$$;9Of)Rz@P-9%@~eH^TOS%!#I~05cy}!{F57mnBH90B4^$O753P zJ}-c|jZVE1Dq36NVlcMrGyF|$1Cx+@Ow^p7o)YG!r)%${+-ZKkE=FqiafcULYKB@* zuP8lR=+xlOxubtMJpulugB6eR{{L^BVrv@x_R2f>yA@ziz=Hk=r{|S!Dp<{$5cc}R zzKhDu=m``k0HZ<`bf?o$@>YIKg{(St?8wC@x)vDQ2=Ju(`};Y6yzu0sO90L`Vl%R` zQW=gg8H{v{ZM2j%b#&CQeAIz$)Ch#YHfH*H3)oBgKxEs}o=hZ`u8yoB3Pfaeh!NXp z=@G7BTiFpN24frPh28T9Y-0!5292812&)->b-*@2OTyXM0o!niuE1H;0oypBV2mCr zKu{3EyNB-MgtLu87F2!`<7|TtAnuN^tFE?vMDZ`^8c?%)1k+VG+*hUc>xk$hRu8%zO_2rzryb2z2q{O;oiCU9Gy*%EK z`12wvY>iPiw^n4bjm^cuniM}{eTZY@4Nc96p;kJtCX3;u1(mVV3M%RwTUweb@>3a* zkNL>Jg&Go*l$@595*tEsGElpC>4}bsts9Xn%aR<) zN{;cjgS@nc#?mA|dQRKqG`BDe{jrcO@cBYT_PIqtNjRWyo!zzRw17<5hi!Qb7>tDl z=oK0iGzR|>|D(6wf7$(a+xAtF(fRgX<_X3~b{;U1+ z%wqrT&fHeGk`~<1v*vsOF*!%w1J-nla`hY!H&(ePX z(1pefQBvvY#RX6+8`ThrEbHsx{U7bU2Y6N2nk6WKgB-C1wlOxw1_L%=a?VMBBqV`y z&N=6tbIv*EoO90kDn~#B`@L6HJzdi^JyX+N-90l^z5adf)xDCa`rhj*SNYX&K0b8M zzUQWM{a?-;o|&EVbE_-8DC6Sc$oOzaMWVa5Q*3T&IhwJ(O=R_*{X)Y- zeH@JxEJ#Q^%^DAX=8y>3gAn}If6hgP-=j- z+nJpoeDDE)3-l%F>nNQ$itW%>@2?NsmBl7?;lTQUp%pg6$Cm^HX4Fa;U&iwdtX-Pc6+G=BgMDlbQ>62TtFAE5S>Cw(PRj%qY8 zixULy-PzgpTz>;*>QxDj&aqVQ!2oCTzNQ9@5d-{;obek!=GZVm&$xbHf4B$twKeW* zK}aizWCZ~%ecG>epZ=-u`$yi_|8cv7jpW*P8C~)YOn?ZqGgH?<2|{VS6rldG!({i{=>6U!*vp&(07Je%*RE~)H(y13L+TUycoWU)Eb zO@p5JWw0bJB%yZtFUKn4LzAjFT6^{i#L$+J6+ngO2bcvH53$}FdV-b@Le&tOhDdziK4>NGoZQ1*c@NjVw;4W zV$?;(8xfFf?0r1)@eJAK*n`_>aEjrAoNG44jPr`J;`3$sIQrSU+a z@{=eFW1~|~7JEvf?3K7Q%0x?fX>HfS0#HCYYJiB$Lv|}NG@*2WXoSoVpo7&NsT4Cp zpP=&LuS{-(Rc2zLh*Bm#|yb_>U(xafn-n59$G?!Msu#=rJ)_KDMcdSiLEuhk9$}fk=sX}r(Mv)Dkofd5 zbpoZcXy#AV3UiPkuZJ1twV6NR5y%nx7z$88+^3X82ZZMUwpAO)W&Y;dD+`LDw}x}g zGJkU{^EdYjnLlrq`CDx2MV6K_e*iepdn`2<)U9X!xPfFY^Jjy#mOtRkGJlVd`4bGN zsw&CdJadR!i)nIHzHs)$fzMIt!x7`-G;ns;4nkFlSw(=~xir>6@eGq%fl!M}2w2gH zZkVuM*dA!1EN!L>Mq+NCIu~}bdFNvaMja503UgS3U{oZBwH}PF=dk#}D3`<99E|dF zSPPvi6xfO8uok&sbUlZaT)QEMg{l&mh5z&%7P^YK08u1|1sY;uFxtR!SQDZ-tUAhJ ztq5~i3;Y}w7mTjwu*N#MU{o}R#eEjbVfjT<4vQZyzq1!PEJHhIH)lZ}Q0$#OM=sob zq^!1{bHD}oXwTtOXN6e^c1hm*luJa6(|6`yq&;YmNa_O=JxQZtc*0|Z=8CdYv&t=L}Dxm_N~LFwtFKlpJ8K^vEhWv$kgvon(Er8Ga_$ z!A_NsU)?dXv^I)LqLQVTpDXC9%G>8aljUjXmzI|t9_SYX_H9%zP{rtJECpk8!{l;% zrY99O=0*qm2B%((RmRw3x1Tq^vbr=iSfA-2cF8f2sV7{K`Gq|~&O z*icVP)tgu3wasjtT-{vk%yi|i{eWDRAbCEtO~P5Sd7Iehr=+#bEiDZnUjc~h?eA~t zxQ0bW`kKp~{C@XWKRvXJD5|VTc2k$VbLQ-QV%DF8e`jT>CkMqlzwAzgnNd-Zo(4z( zW>-MR0V%+;7+b7xpj>@H^eVvr)0#LwwML*D?CJYum6Ny<%)dCwRvA>)Olk?(csIS9 zDg@Ud57OLBTZUsjpgqm|H%^J{UEcq>$lm>%r-gf_m+aRLd!G&sqz_wg}lBd^4gy4Kc)@;C?az31gkuui6? zCPsQ0gXr@DnmU|ZU7f7;a#h*0PT+uc|7IwncgvC#fKWusA#(D7lD0n#YIYED%Q*4IsD5usOfp088@; zK84YM5pb3UP*0v;Zb=IaimMp^D;hxldhF$3bzxz3_ww&(fY8KH6&)OyT<%+;0d?1e z%3i37E=~1-nG*vt#y$z8C)!+7goqCqq6-SfZlold92Ww3A0R}iVR9Ox_GaKeBm@!F zFv(+P&YdfqJ6S!PR4b#$m(R)3>9sO^r0;>aD9YFD;XOl7oZl!P6Pu_~#Lp;m0W)2O z^bJ9Kt0#FqD1;HRn#ZG7MG{;iOgD$W2Ka@#+vka_i~ubR9mCYq(CQk*!19D);riv# z+zmuMwaS}GbB*;t^FsQKL&0zYZGgZ4Jq{@az-0IeZd0}8qa0-XFiE_jBDUJj^~9r{ zABh_#$2QnajpoFADYlo>YvYY_^q3H30~IC#)tBa=zre9*@bTCdHkK{OU+fnW@3J$`;8bOMIh(=xBLz7U(^XL`?72wnUr|@f0nIc+PMU+jL zzG`H}*h2d%+%hy8Q(0D;G)drp7on`ltDl{&i!U2x$mCns7zli_3iZvw@-ZlX)Xgk_ z6jJTtS@Op5xNKy#J+J3ExJg$CGcJGPm@G)dN1t`k+aQh3tL}S=FK=@1@PXT`t~1#2 zWT*tvii^!-OwIJ#H(y=WARP8&KWM2?OOL+v49c`1R~8LlQ!+7gkE+Gn1(Oc>aAPX^gAi7u>N*7y_A|`j- zRM(VfhQb6cc<@l(!XH-^lehwVfJqC{2QFt>tdoH^J;1D`>6y{O<8|hTIK`9?^00#0 zE82z^_N-1driJ8nF3o_v#N@WNYdnQ06=@AqE8WC!azfh3C%tiMrK@7-#ZX0@i`K2Z z7am)M^t!Z$>ECg$Is6gG9kuAa zU*N46bIQJN!2lUv*u65@zCfRiM$gkk6WIcoGpJd{YS6WPS4t0RHe+l3rO;}6ioBGA znyLc;*Uu(ulLN9_=NEv{v_LT&4S>L}*hLoi%>rNP%E`VJN80PSC(@mTOdQjGi*nPE zYiBQf|k}Nb$#UI|O$$jU_r$?VLyWJA(1?c(7jFVm)irxIs9-mn1RC3EQHng;+NjM=fA`B}15n zLW${?wU$7<5;~H22<$)FfuJ=>LX%Y!Pdc-Kqk{w}-IK{oU04^wYM(U9gR6^=!SbGjZ8D;hDoa_R@JPgzyz6`LAASFwTORJe6 zhFXS56f+GiZmeq>0$ds_wgg!eBj;##;l*@IHkfXYQOXu0Lt|roP5I$=s#m@tXp5nL zMp0Q=erlML*6kxKtE*(~gZfXPyS3)MpBSi8?7E_^v6-2Xrozpim;xD_D0gC&r->4- zr6*j=yBcP=ru@x7LCt}tMQ)4qHZcsy#m&NP6$y@1_aqO9Hb7IjGAKDgD~u=GKN}uU zd3gnd9xDJkLm3nsS#*7>w;{@)=H~v$r@}~sEfjvdET!uXwt{EAs1Wgy9 z2LkN)th{+pPTTx!OICpC!|SK-8F)d*e6^=A+}6lQ`yt(2gU`Wf6ok#g*fIpE-KoYR zwk5b=t)E&O;x;&1*j+~hW7j^8&%K2y2totKK79HuOgwSuj1~m}o2iPpmVdKN!b|cM z3ecddBl#95V&VGm@C_AGcS!^q9|EdM)`8@=qAl-zQW464Hp~JbW1pR60z%K{N3pZ# zvZhA{w1Zm93KLxT^<3Z>0^N=IJH(!+{g8ujJf|J0Q?i-{6r-`7gNAMaA$c4}pVUF; zxPp?p@fA=)7~I+qA8UAOVW1+xP4ntr>^xcMYbgM5|J)ZI8O`m3OEWt5ey852> zrl+AM%|r5}oK<*f-_o;r{L*(0+|u<-ENkl@!wzwmnRrEK6qHq@xQV^{?L~Qgd(XhI z2w!tCvG>0_E2XGqY-VBjc=x;f2J-jz{30&lEx91BX#)*CdtIBb)U>o{C#YJtwdMMP z`gIl54#WWaoBcC^Z^o3j)4iDLw!alq+%{6)EslE4hp5+lvJA;LxfgUE`T!Y{p zTF@Fw3X|Nvyr||JSJt~QGXh>vg!C2x>GhzH$Ie%mHQiHcM^}eBT5^2$-GCx<@7hRZ zMrx$v`dbIM+Ul(D`0GEqbMfA~)alK`ipDkq5!9RCn)!i&8#3pN6Vy4Ws$pOSlHKSo z`x?J=?%DDH_kHHHb_2N*0R$bnCIMrJcQvsrF+jO-p-A%{zBBZN0+d;-iB-ZH*nn z;=}A!G#tY+O6%&YiqfNlqx0&KF-K2D^U&-ZZ=kiZxT2lDDTQ|PV`C^yPYjJM@<+y7 z%FF7y0B@KXr~~;e`ZwycBEu7ND*&e|P7Snv4D5SikgbKYe+7AIK-F9*xjN~JUeDU34--==0K7$(HD{+YeamiV6Xz$oN+UUt$&~yxr zPmJ<3maz=Yt7|MxaMf^0Zs5)L6a*TsD-l7PXnLU+`B|&5{QAZ+G}{1eT2ljVTv=+m zCjuZjDr8WzyF3NA6tppN6AGgq6MrPD4pO>+(j+Q`Ql!MMDlzb_wxzqPL&^;>QE)3z zf`pomwTN_)@|oHceXS9#EaF25YU9LNhGt}CWdT{IVN2TE&W6&vw=;$)((m!#E>S2B z9yhem9K5AzN6#BbAK$w}?1Q~06|BQD@j@4MsmnipaNwe}hI4Xr7r0t&jaA`@Ju-F) zNp4>1%}tFCv{gQVh%Fw_iKX7o=Bg}zgIjRss=BA1ym;|!aiA#3SivT|sAqL)tfyyS zlKN>04>Y^dU73?pScQ(g$JB_4RCt`fPf&D>w;`A>5p=_sHr6q*0qxm&IvMQuUB4$U zFLnMqvIh3UuUxow_vYFC?D?KtKYe)>ePdmPOXTDKJW?9wXzT17>}4jW?FHTBna1q+ z*u?DOWOohp3f4}(8txll`9ot>F~O8ZyT93t}SddF9%#`{}vT8{#i z3?c*k-txk<2nS{4K-J9L*zXVau+_f1ZTGRO%FfA+ZEZO|x{q$30IBK;Im=*7q*0(p zJ%o1KcMe=vvvQ^R6^t94N(eP=e6*)_yF`&V8kEGu_nf?|YGmW)N#yVP%2Frc$==s- z2#8M21owM{uY=BA$l+-_N9I&EcXqbcm1jlTDN@63L_yp5!n0>9Gb3ai`#{5;ic;!$ zU+>T~l;E6{58vaQzv{fa;<}FfAXDihGJNQ(zh7uVMl6(3ZYq#<5k-rhnUe=_na9%kk|3k3(lC z?@1?ZO)Nua9h#m=?~{owJro)dpKI89aG&Mr>FHssA$#kTydDbp(`5Gi1|?}@uLy(+ z@JYD_r3hCYr}(m_skv@JgA#-QX#RRNgZ>1zL2kqnBkL~7ED4ZUx)3%*xRr!ku$YLP z5^e+cp^_=}C9Q8neJE^DYS<=GEG}8F?T72i#t3&GDJ#kASXk-HU%a7W9e^;`-r334 zH9XSGL|)N4Frz9bEDo|AvBl+y-o|=98TF&hMRiS$)g@hh6$#FUfd$>bvCb?mOmr_y z5&XtAuC!8dyJ2oJL^(ie z!49YyQ9~M1R;8od5v|KTAme=i-?HmTe^qXN-RN8sB%d@~Vv4&KR!4gKCf9h)Iic3b zk9W;a4a_brPWCKLHl@1i`DE6QwV?9TR9o8JUzy;nWD}H8oeS~Gg z@~mm!N<+&v8m(REtGs$wz{7yM7Y#J|L1r>acG1Mckl~46(7D8`O?1&P@Xc&noI$Un znp0fW_|u;JU^DyZijlS6!cfZx0LpeO^Xd{^eIc_qRR^iKOJHQFgzQH_a7@MM({9o& zJaW$vPcPzWh_Fu7B*6T?xa{H(X2a%{CTH_%k(r`>1djL@XHFkRoVJXRIwq$73o zz-{UoEFD_w8(VJAPA2AKWDCQ~@MY(wS4Qv$9nLqn1me=tlRvyd=MP(D*$~o^2?4QXL@A{{|R3>0S=s}^Z1Vq{eX__LXaEdg+y_j2|p7A zk1pZS(ZE_k@i96&$X4k*REe}LtpUHHhDvK|GqqcXk=ni~!-qzJWdOXaJH@ttb^yK6 z1dgJ1${+VJa6g(1`S2)oQz8Ho;n4VeM|oh(~&Y1?hm+Y-UDw zaV==n6x0bn$7?Q5j}D2C@zlSg1bNhvXR`w>71^;~=86~Z8~LTx_C0^PINDL3;%h9e zgb;jqsjq8vae1%`+Qb_0IhF^@a?0DF?}_pZizs?>L!)!rR!2$z33tc)k2e-3XS6H< z6KMi@o63==(|wIKBdkIBzL9Twb>H-}xiPqWXkEUfXyu<+RNphPvOH7?1x0yNcVG9= zr2Ohm>dOv9Ml=Zu3PFK!`Q2;OCZyatt!rdv zesO^}+>#$=1+9emoU+;mOn$}b;Z9mNWsRJD!($VY5@SNVZM5zkKPRoKZ;3_*M;lW# zI~@NKP<;;Q{ox)jiE?pC>+1?y`Z~(0x_X*&S5+-N{Cyl8ef-?bl`Mi%^RlC3a`KY> z&D>M!``QZ%J9;6dL*dqBC2Ohi^vr0T94IXxnrKRO15mcIG&!ZLGAGPh$0;Z(%+o78 zGSETup1O&ZxvrkMrLpQ=P!@0hL4t)2?Qcm`h)b5EAO5|4r|vw~Gk0)@oSMD4-s9UR zzo8aOWcMHH*m{Q%M^jo{n76GCa0Bm&Ng(NO>>im>N4j>d8?Dd6SGaJ+;>y+%>T4rb338UkD?oLEqe5cR<2{f$B_;}Ts`?91O&BT)GO=@a zqT>i}PU}r?E}~?r2|iX=CzeLa+SBm^h1~HLcJ6MLAabDNL9p0AQJIAIuTF@FNlOg? z`2!_&0g`T?Z%#+9wWk){CN;5`Z7HX#MD2XT6_43g)&KkhywW8fZF(AYmQKff?P(ch3C>!vS#`ir;so|QFl2}>!gY=Wq4WkE`) zv%bukuiqAXXYbhunpPe`Q6w!J736NIai4~XQt9{&c`ajWXEak;gE#8>PhX+(zFU&d z%eZq-ySPLpOb1eq2G-8*&`K~=x~^j57nz)%ks9M?rECLr(Nd8SV1mTc$o$;Q z(kL?a`ry8Wwskl1`JP%X(fQ@Ijm-=2?=3*g5PuzMsc)ijxH&7Gxn++%whv*T{H5uzbw{K(=n=u zBHoI@XTw!Vo(2e|+UI6kN_!#km*#6KhfoIG4+W4KZq5#}1oknyv~P914&udw6%d;x zVYSMkwV}%T2~wkV(t2p(lUh5r+E>~(iY^Wl2 z4ztkvI3;_L$9pa=Q7tYxCH~0F)k9=+)Hm=+$`ILNT%b7FA+qKAQNZ$aeZy*p0dfVn zZ3P2{zA0#xp)e7fyZK{_ zV9aQ=rT7})x*(v$2Du~(H`lO@bC3JjhR2p+*?DYB_czkC_w{3&mz5ICN*o_@5IyRc zOlyA=lPMaO%L{Y1Or{+Z6BFYDtp%GVQ>@%$W0T9XO(`2EQ;heQRrLYSxM4Dlr15+x zjSLiQm`tIP`Lw^bX%r2@8zxgt<3`~T_D{gVBdUGW^&ay zDXCIAZ@MNHKqv}hA#d5a)LES0I0g1xGXN24$L9w6CTCmIy>z6_0<#(hrjZXQjIe!t zMb$Pqt+=MPEGyDk`_2!yfZq#^iH-2F*SUXU7qENU=C-z0`pS1t>;W)=G&`imD)f#JrH?rFU!pHZjaOM8aIx)diRE-k~o)p;?J3 zb`WIg-qnjgv3ZN83>whZR+HvtHf{j(n~NcT?xQ1U0*rGN%#L%TY=UId09`rZX<*}J7(h1zB;DM9D9W^6UQ^EHjEhTmPK&UKn zN8o{vg-K8jb@SD>x6_}}R8M>lYUbl_>|-k?gb4)Y$lL>WoSw1>$k%CMD{SCE~ z(X(F9B;bLC2BzGPahfSEo)ed76bCr{nu4ySi>IfHt)9YVRST~$EGH?EUS^N1f>KKw zIyzcPlYI=`)9Qz27Zw%_oz*%uN)tQ~ zMPTDzWE;vj-dRoD)|la8AD+bDLT$CIo%oxT1}1n~B*}01&*BnI;*vj$pSlZeWH(O_ z7aRPh@9*oPcQ6&bgOOh5k1wb@M`c&FbmDiV_!`~=T~^!p(!#?0Aik**G7n7?T}_Q6 zgk91^Yhzw@ZW8---r6@c9KmN$)i8>}v70*M{+>k5ydyux{t1k z9jK=B^2^$n;Jg~^`(;<Mky;*{RjhURf5UCE-B<@u%A!RiEOH9ep7vWB+K z_WIHkKU3+)R=yEQX_@H>5uWCXm#!)5TROXWx;t9x$zS;%H_Q9D>**(3NHmK}Y96|! zW$y)ZcG5fd*d{y^b{yk~7MqNg?(WVkFAYY0HBp!9ZjF-nGOsBs(83GVZC-P3sI4jz z`n^riJJz`68lRh+K}yHRj9h(vy=~O*9Q#~^^q^zAM2olpQEO$OaPblKh*;>zu<~#u zCKMCu5eawGWk5dkBOw~7sH!TCutKt!_3BVl7LSKCv;bbmy0j=S{i_Zz!9c;MPz}?F za=wCrryaRr0nxyC3I={Z)m&6i*)jjKU|{XZ=4bT}a)Mv0swg9;pdc@GRxqG^_So?gCypQ9 zg8>1e_TwEpc1Zj)5w-tyyF{zF+8`1?x_!PgIl1-7|bsROKO>J}KAf@X8tkX$_j zKqn$^l;GF~7!u@PQ>qCiwYrK)fKH&CbyV5F)Bo_dfA@D#%xI+%WE>jj|K=b5;UE47 zaBpmc0cZZZ=m?b@eun|!F~FD)|6#)jRhu`CAeQfc_4lH$LiEr8x_G2YXW`cm+hzy#oTT`Vwv8l8>d-Ed*7h5LA&o zxGGXZDe{l6sUjsePAyVh$tpCzp^2(Unogk!Nzq;=GEy3rj?T8oH=~ZT9gw$usN*Dy zOSFqi28-`H$y!OQwC)`{aD%m*ggdEUIVEQqhL#jCD&B?^OiJTScV3W{xBAoq=CwNnt6|Qzt7J99|r)jIm|#gOMHrjOzPll$4j`Ck5FkDOvl5 zhKB^WTBzQzE*p~hv%iWZ2dvg z8tb8}VB`{>S<~DM|HarRuCRG*iQ)+)sk&z-dGydRp=NBjVVWMA24po?*06_AElQ2& z9zxODErxpt%2<@e*a5qKk`p}q=pTI;UD?!x4@Tp+ zGkA2JRJIt8f>w=;flP9 zVd@8g+FQ@kaNqE9FZc(5VJhnwnHcFPPjJ(gHu8+kLbf9-&dXTlJjm^Q!Xm1R<4?G>IPWCdA z)pmgd`jfHR>XD}dl#?Et}ZpVYc>fCyS<7w~IuX*xv}_pD4d zR1Q5Ku1<1Mu?@>_pGN`!Q9eGw)I2b&d3vFvpmT8wYow*3Lqgs7a!+x~%xqg;sIAI1 z+vwt!f%2sCj;_K;M~z!Q=(|N^6eoMfXssv z1XT@`&9&~Xa5pv2u62AOxeg6q@TkTu}IsAShaIpd<^d$6&kojaDEp$Q$Y}O zexK0Rp&m0eQ3KH&!-uDZW*=EP0D{5P)Hn~Fo5DK)y!S(8pa4P+_}==>5MMnu-B{Ys zS}*Rc-xmelPkIX4=jWNPzCPMGJyu;m*)JTWyQOS!p$)-r;{qKk&A)L#?m1A}wb0H+ zUG>>LW0SsqU?2mjoTS!lC_AkG6T*=T8mmoXcKXnL}{nWd#e zd`(kRafqor-hn~x{LLTA$e8*kBqhbTY2VlZE_!ONgKYYd%SvXBfPv`CoDz{xfid)K zG6tb7>=H2sp)KqaF$SS6927AIp)GtbVhloC_*!TTBDSzYXbkJNaOcc^p)qi_z=aFz z#=zMEKX6z#24)L_5MtdJ$QIUviS^44TM&j7f>E*sVSquSWDKx{EMcg@83VJ0D7*Eb z<2_Kta<-7-DGWc>Z6QBIBoJA*g*a!CkYwE!d_{wkbz86%4O3{8*#d+MgaOOCEhvkI zF6*{%XG0Jp)YCzhs+Y1<8-p03J!}kTgch+euo2qC#t=tn6&r#bkvU>R*dt;Y8v>wp z+c>{ZG!)t}Q-}mbqH~34cqBSohz3fc^My#rv|+{&37$608Nx7Xt+uzvDeQZV&bo#LHOfa<`DKiubD-VN?o^|4f6=4{3#fe z-1FbIN%V;`{+wOM?`c>#I9lt-Qh%KU?gl3~ei815_raknc?$TN{Q8EHSV;8k5&Hsy z>Z#4s^PRbVdN;on19XwvIDUuV705xc-4_u(HP6g<=J^3=CbskRBb$iAhQ{I;XLSI+ zB@TjWJh!H%95C;bpT6_;srx4I+@=Xlbn4T+XQXvqVk*W!*l7RgV7xJF|EABUo+ z+)p1p(07k5>X-!)Y=F_-L+@(3pk6Y!&|Mmhb?R#|i_n6uh1t#$2w~nkefSIKr26T_ z&cYaX10}V`cTRX^c0L`eh;h+3b#$}SzRSTTL*2ZBlsz*$07lJD%_^vA&Gt0_ z*m&~ASZ8ZzA8`FqwkAP&ZM@ZIE6<+JkD}8bN&$Iw9RouHosAXQ;nq?*j=>3;1w{o} zNl|_dS|_B`%^kf0f&#pqtqoM}?ml`=MoGuO$UsL$?(T&j8GjCOt-QbI`CAhG$j`qe z_UUnHAq64pg@ktw%6cIiA!7-~;5XNy8-^ScBPRTTfqUV{U(*(H6kgrWy#d4~gmi`c z<_NH1-w-kvYT!DuP4bwK#8B%Ru*qygSwY7uqq(&ek|okdKjH?FnYRcn9$aC(r$9Qh z4K_^B<~fpX0U(-*T@VYuKhVv6%fL+oGsDQ5cb$G@i|(40wyF6hz^hpg$F^?)0D>~X zRPEsNUT7TL{}JTO9OY^MOY|-*qbG&y2toS?HLw6dT|LP)jvRRV-Gdx;>O@rxx?YA# zh;H>0G?Q3ImXCnhH8r(qo=8{KFMs{bRZUP;&Cj*c27m=)eLZrYb<3=+(6;)Q-$fV)^BQ30#V*qO z!h8=Qy0{ICn;`Y0a-MHvlE;KuAgwE0r!7cjg$W^n-7pIi2LPv;&+5bKu4y5M?%Hm^XFqV$v!6XXo(?4 zWL>vd)L6%cpAA+fc^Q%1$4AE=T16ChEe_Y!3@-PhX^|SmIr^U7-m0|J@~&QfOYH{` z9|_JxVv@`&H#-6{v7drB`Kp?Yw=aM8@^R3@~!h*L(ZU-dEKzGSHCIF*Y_-y>pbbHNHMCr|;ww*nRCA$6SQv`>%RQq&Qe7k_cwb@Ki6XWx ze+x7fd|+tI3ZYxFj56!XOX{K+E|v2%f$T+}gb0{R^D=o_=!y_Ub*WvT&MWEwB6wG7 zDuZ_^oFYf*gTuFh^J3;jI4_7~U{LJylfuPO_3lp`NyZ!Bi=40DobNUEmc)p-WE)wH z*lyr5K~ryQt|@)~8#aQl?1+@EV^BIFbu$3 z$l*-RPxll@+9+^z)>sLxqpBtcn|kH}QCDH|SYkU)DA+;1wmqX^9%)PUi>$8?`44M! z^bWW6L-xRheM(#Yg_rUi|fS zFP@aj?*EAHnvai?2G~|F9>xF7Ui=+>U#>5cym%&>@Q-@&?m{n~({A|TANAsE{*1l& z&)~(s2QU6F-;x*=mt5i7&7SiL#v~-SzS$edu&ejM4^o_j9H$P0|B*#x&+I-&J%#{# zuW!X6CiKjEhwcEk*G75|{EafD^voAbTwwUc`1q4K4sODK1`Qp0Ug<3hvrVll(={CI zBqzWKFZuj56B~dwGCdQ+04Ra>s&M5F++?By^UZ1O85`iFt&P=gi;3+zC2tj8GWc?+ z6x9X11~}?yGz`m%y}kd2wns|iER+k_GlUzj2J3;U*v^v=El>q|F;X6dXBL1l$A&6| z8c-jaZpjTZOKq8{cxH*iJkT?r9+x!_DeQYb#sWX|3$v{z*;RGlRSnmqx~acfWzioB zw+sgn8(<;#lZE(Bb~cVmbMQ>Cj+-hubDn}vbG-(#UA^}r-Bulp&lHUZO- z@b^TAI;pEV#Y1O`fd3{`N13i+zo$As$k-UY601{y|7#E;Ze@}CJ#?<^Vynhydkcci zW$76Kf`fYoU8*%nuA0|WL|a2324X}(VjR-QC%CqG_H!61wF2v1MWp!M7134^Yev|=2ta#bal4YSLDPxe{)IEG9aTGY;XRmof2As~df~HZ#;eG&Vm2 zwbLsaE(tZm&*yr;?NwCX)KeU0i6A_`=gDGwQBqhyaAZnul83ee@ml;cSQ_nWVPI_S z>g#4Ar|JUE0^%jmdwA=vyt=N&!;46rc0OZja_9CRJaPH@m6Q9{CD>(-e)R6Ad%pQ< z=i4Is>s&Z0CMFbLKlYKt$Di%l{pnkTMt=r21F#z$Jp9wy%NI|4OAOlc^ojZkQuiM! zX{tTEL@e85zYLZ|yIY%>Svh&QnmxK>2r`Ffiyg(Oks)DGi5W>g`j^$+(2M_SzQ4J= zq^z=`y)4}P1U2k8j;+3!A0HW;oTD1yK6LemWY%*2*Vnc$E z$max88PWPOGCBcVj=%q^j}MkB2(zH)LEOlJ&q8>)t|2_X+73{z2zQ}(+?!JVgAAjGEO)lo@w*>p_^DT8{ z`1{=FN4T-~f7QqTd^Bj%^CURUHvaDuOcnI|iBo!gD~q(^B%b-xLbQiIr|--@oe(Au zQEL1g1^Rah=(Sy9f-Wg$_6><6>~F{!iM_uuUG(J-r#EDZ&PY9076ORm*(DX^ht@AC z0~6uOzPY5o@+F;QmvmFMW^eMD+K zSA``W3l-8FW-De=AhZsFUZSl&$(eA)Y^_Af)IP4dt#59rzr8NW3CE`sO>0JCpt+){ zBOQe!%#|-Oh&1bwzRIe%Y+U*5ztrPv!uP+SV(H3#F8a950#mZ-JLIH>SlY+eb`8!i z4|St=8{eN>>hSICV)?`^;L_)uV61VV(dXP=THj0*f%yF#?#jD>zv2{6(Dwh{#3jSGZ(M3NRs=Sd0yfqFHYWc! zX8nP36)rE%$ny(~8hL(#53HNDHXx8f!&WBw?RC?BcuCZ_uYNCT-iHZ1$v1oubp9H9 zhegf($Q4n8zX9n2p~=hPn+T0wnZDP$*()PR5*ohJeNoewxgcu%mjV9fs)Ee=XUY0S zbDpB9PSFgfXkt?|pDDUJh&HFbnPAui^S9e2=ENm`zx}9`jt!Tn0dcLKvh=lMyWbMK zsOlI2Ul&_qe7KLJnYz@eueO6CCx@GwL57|cAL6Jhee#PN6w9(XdSQ0Bqc$(fRp;(e zHu!2B9Uc>NYpcBWqDTkztK8slX=zzSWB2%rxwhP3(+B*);?S^&$PgI}{eAw|FP=YtjziERzDmR}80^K7z1V7EJkZVsK!=?}bS`_(+}zyEU?O;% zsEW4MbLM|K+6P<^^`kc}(D=UKd)hepL>F}|6MLnG;1@;3BxIFz&b^`;GL*N=P#onofNjn%})(m+k_&iR9=L!sRMDT{(X-P8Y; z#5};m$e+BfV70Yg^}2G^mp^Q+Rw0DiRIK7h#-g<E zgskjTXc1R(QSlP#qnkT<`2d=Ti)KSU>R+7*4k2 z$&a|rRIh)3OiIg&OvykF%I;j$4J_7M5(~g$Q3`HzE(L-2KLQDQJtk-~DClDj)w2;5 zbm8bXRP7S~{HO>l$UsH<(m`M}zBqnI-O@EMmU)j+eom%}*ADOabB1A z&gSy$ct1jE?44MC_WaqxXlHd=poPLYiaK&ycxx+@gZ+b3PoE6erFt9O zy8<@a`pKUsJ8BB^i|V>&pAVKpz*GuKR(&sKnsVZT{6i87I+nmY>!}6(uS#fd6ofjO z=$kvol#jmZ2VW^k2QCmjzUG6QGDKYZVzN5M4h6TK=Tl(Oe|-8|5RQYjl(Yuc(}B`I z?&O*+R*@7I$q4dScfz#`lY&Qgoq@t|!#wf%ATQX39AmtL;@R)7Xp&;VLQ}Fc6W3d5 z%kb2Zy(z6F!n zC3RPD9KM|HsV^-qYwBBA?JJ42RX8DQ>Yv##3Lfpz;nDdQ3;mVg_Pn$2l9EkuMos(h z-16$u^gv5_l8=e(sSgj|RI?6<%PMUI^Ke~JT9lW`qf7h5cK&!n*~GyoA`$e%2_YUf z2J#mVAcObm;mZ${^sF4Ad*EQM{pjwwZ$A(NoS5_2nCUHYrbmXhA?G6i3yJ#3>u4b{ zQEyoQ7ZQ6dUP$r)CHMp$GzS036xy%vG=dI*x(YcWW# z2O)`XMj@^HdMuF7%qAGp8(;6=wXc`IO=1zSJZsCZUJ$IJ}+!}2Cx;lYkt zcV!IR!a`m39+-F~mgPk_Y8kl478gak=(r_T^nv5i!9BL5yQ47D7Ayb|W6TT3?!q+k zux<%uG>}WXt@#l)U=1tlU7Bj)b{)0ljMxB=J?p3~b)Fk|zJPO6hz#1tj+Kr5-0kmbfCsp>h6+}9D#+UVU7Dd^b zdMA}*SI5XRzO*RXRae2(Dd@5 zE?VD`cp@%YibzaM^1z8J_Y~DNwY5|q8(CXw%UnFjA|^TX#JhWXd%4*7Mu)pWqjf!E zO3%#BN{&vdEK2Y*f4CuHYN{&f>qQf(>c)s^vS)z@5ll7Fh-sMjWNon{FW6FEG-4{Q z9elM0_B-20A`w$4NY2NXNyu7L7%}<6r=DvCZ89Wr*CQs^gsPF%o+3m{px9rJm?FS+ zJ5mKD3QF$_BBp?>*174%bhNOqvIf+P{V<4yt8>hzUrFI_Mru z)}?rDh?tgJK|kD<6J)U=VtUa(48?*-`;8IPv$>Vg>cou^6HpbuEOyq3MoibW!4*ID ze1{(7#>O~`_OR9E3$tAfVkqN0H5z~*i)yzFZ zgB+|JT}2|M&yHM`(=|0#msL_#Q&yC@e)`atSo#r1k$&x9GAxjR2(d9XBZqYf_N(Uui0D*(dQ@;|d|~%9v0zNU>waBOl~N@BRLv(cj~2eyBSo>nbWduKNnCrf=5 z>GKCZ24sfI^n6NY@{E}Yt@W>owl{z*z7YFx=dN8F+j71>a{BDq4NwbaCdwjci*+** zgx9c`bu(eHb=U?oVN5Pz8_k3$T{fACuCDeLo4EelvWa!`724{bt(mNGX0kOf-WnBe z35hYAY({U;Y_hSs#+iv|V~xN}MCxq9*(5i?*E=>hccYmY8-f~j(`@oUZHt)*FUM=H z&~IYs&(=(yb7rzNlz2@Xv9*uk>m!>XAvBw8>|x**9nsR?`fRd}4H242I>%DG(M*V} zcC(q>`pr)ruQAL&PcuP<>IGM!di|oaIl;T_qbR<)?J7UI zd+IUq=QxY3JRngw1{MVtB3ydFq7Ixd{E|$z_JkLs1`53zo2c^PrICtgEIwXoO>>O= zS>HROWqzqW%g@L*vUKp(NJWgj@?)Eb(!rNwm9dUsEt_9}hK+&LZC%gQrrG6=9Dnem zzZ@)u$j$jP3RYo7{m;j%<3OW6+mz;|cjwT7o7(Ql_0y|exfP?&`-{V^70!II>y#WQ zv3nr5)V@3o#&&Hq-n{+Ybxl`@FFffU11Womh1@9!C`lfdH4Dn?TIH=w)`IHj`hJk> zZr^uB-8sH`{12;Lc|m5f$63SkC&wO`1mtu)iXo~O9{0aCMr7(%vVzy{h8|C9ZCzbygr$sN3{!z+AZk-Yr~W-r1Y>*m zz%>;!s!i#GPn`x>g^GHLKiZSFUE-y7`fGZCSns+)GMI%4*vte~+#JR>{PhhJ?jJ?s%ak z)s;H}#D|V(j-jAMFo5Rlx>>3Q@dpAzqO&_!XBv{6xq*U$;;NPjGXLz4^7 zCmPc{8It!epc%0`Ki*%L9b(P&P+s-5x3__ZIXTEy{U-lS)69B(q;OeRGJ`v-Jh*==WKYnQME}T}bf*5M2e110vT;~A078nO(isK}F89)|$;GE@Pv*zl3PLU9PU`z)wJ*M$?Ca^Dd@g0g5s@}P^vAQ%b9sV)Sz6nLGyyt`92jGwRIJiaR zwl6`|#*Q1w$c_#2LtIPX1#YCOqq-=s3>pYEf@7+9lhCDIdWx9Zl#ZEgt{R+KTwR{( zuf_@DWPBk%-UM~p;v`=)Ilh!{XguPAxDXGs$CvqXz7`Y~*qiA*y!<_Q*CcTXiFd7h zyTs4plIh#G6o0n_jvK>96l=FoKx{1ZLQU~A(HgJD^&>lfxUFvC>d%FXURLTie>ksX z7m}1$Ufa-6U6dALt8)1|lz{4c#=!H-8*Io5G`Wv~><-xc@YvkT@oJQ9FDN_4)$}!0 z6qmKkfy2{N>&OGspzMN_$l!>Qk(Xp6=M@Z{Lle^Ti%QA{ekS3mdq&=I`StC6LnCA3 zY(T{^wxVl#b#7{6Y?KWc`R8;j&-FCcRPbMd)pqyFr2%PU@5Ew|PBmBM zfN&w(SMP#?iF-&~YHD(HAo^$^Ee*|9Z8HZ@rP~^VXu(qB4(>LN;Qu4KRKL)rqUmu% z_(lU8f;a4MERg~qB8n>1xFja|-NwS`XGgC;6sd_`-OpVrZryxyss8j!^*ioTZH}Qf zM^Ibhr!CY3TWATkP!b5rhkW?EvohUPnf$woQ4zektNy9G>i2Z1w$$Rbpr^KU`ftHa zZRzmeQgUO3USYNE(OK5j|7HUGV<*5ru)At=#JMHj+!AeWsa9?2mfu{Xx_}8#6lbb% z>djj9$FEhB+a>-&T(T2umBhDarBrkb4YU<+o&1UbVUTvwckl@e@Uqc+t)aCsT8ZE$bNQZ-Kz7;@Qu(eRm7oaGi_8BeN}#V!875JBRL>f{%Bo zw0e>`fZx4ykaF)s!L+{1scxqKcZkyBhLB-#8wB zoa34?>y2rv9foxw>DDOx3YJ#x6}p6*_Yc-$>`a521msw z#zzG>`9?rTGTh5n``*dVZ-VJPy|A*rv92;NCabKzslFsV!b#`Oj}O2J-!d>cw=g^2 zS=ur(yEr@4S^&@|vUOo4Lr+1h9_5NTEP=5byL?Xqphp1ytuL#RhsUq z;u=KPmC-f|BxhTYlNcVHSU>xRvC6p6l!lqb=2SP03y+Lk!xJ+Lic70_FZ+nB9>BgH z^h`d05MyCO$H4ID*vuNoTR*`(I8{dw>&`xzn;IXRc|OD6$Z>;v*tt5}(@T+&kQBrh#-OS7Wf>>?~u~X)|i+*9K0sMZVIw?3XG%hP6$V{5Kjs4|$B@N>< zJP2JB6_(a?0M04L+{eY?z9DefbZ4Q=A@X`as}TCPfE0Sb0Sz!oiFS^|DsIB5*IAY>iYE^lY5qXxf?SJihTG zQ!yuoxLtelw@dsLDz)3icKvwmp}H}|rW~wIH03T!9=)Yz;TjN=kdzP|>~8+(;0<-# z;H12&hNk+e!ek%)3yRjE*$q7t^NVwneP}1YYlw2-#M;ut_|y^+j>!S$J@RV2wGui; zphr_vBO>nkmaORTn6&f|%ZHE!N0Fp5%Ff)*-NQ!xI+1YyLi(1{k2EwN-#kjOE4xZJ zPhYxx{@7RC^Uz?rE32ZZ^5CpNKz{Fw@rp<*V+$uJ8AuZzb8)UQEi5=JF%#OJkBIv0 z9WsH z-qW(>w?vb)*|t4sSYGFZR=BjO8T$0*PUy{T&|7+-H#b0U>V8J0mp_XE zaL2$Wt#N#9W@Kdc`FMS*r?wn=*dbisP*Tz`_F@1`%?w)uUf;0r?Di!u+B<$SXIXXj9u4Dke7u=OI7~m{Ga5vVZx)Ct<`B(-2E{qMZL=n1aVZJFN zGB7ASF(=j6K;Am4V)W;xza(%a7edXMM? zD4!La0Hw2n6CiU;Z~`vxW2b=9%xY^M92lTT*~!R>u@z1$L);S9rrIpZ&Le%$+&W^Co)MM9&$z z@&z|0>R11n>Q}(rUr;o6_p~$6uyAoPS3*Y*XXw+5MOYetd1)LEXJT~t5z%X~wkBZ921rxTU2e%>LO?aNGJ3SrjI{Qzug+v8@S z?lbbUQAhFB#qCiuP4g;I*#A6G6l|*RlH9Z~jm9)xVy5QRBnJCOZ<=ICSsM8La9KiVcy1R0;7C?3TU%NK3)I#Ob3)d*}%!e}OvsLlz3r8o;W>{}0-Qp(=ms%XcHfSLVKo{j+$#SnB7~lBSRBP z2Z);8Oy4iJdyNtpW)N*&C2gYh6o!&x0uhQNvob$RVz8x$uBj33tzQO^#(*>!9h2fh zl9nuyVMsV;qMBPzWduYQ_r1j3X7R{2x{~7Q;)2Td+2u~$ZO~{+Z<<(}85|f}S{(o~ zTIKv>O2`b(EYaOok?dt~A2mR`U=)j+aku3pc$+Dl+j;CZv@XLEl2enT{G5#yF7Frn z40@M376@kPZj-*SU-WLng!#AJgt;l2W&N5PYLJBD*jR)Ft^1}bBzWI8MYKC9>$D-H zwp} zMe-1LAq#fj+=YMAUHD(vCET*;ZKWvR%1^$vXK*Vs`PN=R?h=YFeHF)E&4iN>l)foY1GRHpuSnS&q7q4l1 zXSL0Y)s8;WVur&m7Vu~B;azPt>?8{GZHa><%JTs%EsY?qdvj+<ttBEDSZ zFuL0~|D2U92GQ9zTq*G7K*O;%+B%01=KFH$t|&>&jib#@;LC9}iD&(#>%N@4RY+dP z((|dIbze@^H3c6$G%NJwRGbqly1Gjmgua}NsZU%%Sv*Bfd|&R;BO~XKa8D6m?!fs6 znx^J@k5$x^74BXf_)iSYjA<(9cot?Fwj7Thnu=js(wTg{UK=Q*k zx#90@^1{D72vp{}(l&&Z&fbv~{4y^x3{?4n^|z*+uTU?^=2O26Y?8*3hAyAr7B#%t5^fohP%q%aPy`5Rc@j! zZ~7a*tNvfQR9l>~&E&RkZDzOKvALJ&^G*D=bXSS?G4UrrK^K$g1nB7%zVWWI*L!oR zHcx=nZ%O=r;*#0Szt{kVeqAQw?GHB1fnR)obmJuW)#=+Z8)iYN3$nVFRwC1&mEmJO z_XtdX+&oB+d`+z>D_)sQ6Jc~I9wj;x$b^T!USDqr4DxtQDXzwWATp%pU((=vIa(fR zt?ruCFbhdRPfD3S?aJ{pfT&-|(5r!>P)e&p(a%ZcrmlBJ+cF-877wAXIMiI`tfGBv z)dZAM66&Ux+A_R!Zu|fU3*LLQoRcwujNsnO?7iok%0`~|g3|Eb51$@q@4bdqXRZ4j=t? zC;FAv2NV^gubtfg>H2_!qn(+i9NHZCN5mu~#s#}sq7jE1C_uWSt}r>oR*m0B(hbDS zR9{0@umwv8F7~bRM#mPOk2j=x>e5Tr7P^ooZkc#N=|vj&-B4+KXjC>tA!}moY2Xlefg^nk9ROfkX-oH@ zBYrnj5*-kh)3!2Q8^`)mex7fyEGTZ6ctweRI$~~Qacp34<^``Q-CK_a7Mps}=rA|f zk{fEpZ;2^DQopez9UK}r=m>ldc&16=UN)L{j`0VaU0j{4bRXUN>2tw=imH;_tuu!{ zTR*4D7tWqI@Hq?zM~siN%dl$)yLMI)h2`a?u}qPVu9KLKOGtQln1|tg==QPgftIPc zxtZ=ml9pu~9H`x_@8#n?m_Po3%^zFl`v0W>9P`J<&Q&&lh)(!|`9nD43+In8O!=GU O4p&{1deRuEOzj5!*9g&tjLO}w;C3_f*Z*RPO>*7fcrm4d1qYMV) zo zB^lG8g5IscweiYmd*$20AY&Z8tLhkAJ-OaFx73>Msrv+dO@T8{^}RFOSLPZm!6bm+ryY3JHZU4o1Hy<-fWQ9bdh60+2O1Oa z`U`{2WG`QPX%Pyq+g?Xw<8P;G;+@`z-BWi-tee>wC&nUto{zyZ>9>A4UGTHZ(Wo5% zI8qj2{aVpBs$%TJP)Rr%x!oJ{jmd6WIv#1wtd;gmZwm_xW>Wp^R&RcwiFt5g|Mp~c zoTI9&yk%I)(EEw%SO>@W+G$o_L6E7;rKkE{8Eq_9TZWfoe05`EeX6_m!;^PZ9AYaQ z8yhmbO#R{$6BB~06mEQb>4mXRTw-Ekh@G;jqmz@9h5F+&drpWenK;0OmFDq7XO3QB z+}Jvb6Z<(oy^j`?$Kft?rzlu+)Y*EU=BPp8POeRajC# zw1x$yf%(1btK);iv#j2C=wr3qlB);j*49_1JKI=D@XFjTrL3W&qpda{W+yM;?E@7H zukgf-jI_8An3)J?&2th8239T}9&V22Fe|pYFOKg&^X(OZ+eai0i0teU3K0;V1N0ua zBCTa^ZEdclZfb39p(}d>!9qkr!!aZ(D#Xz)Fe)mHsV8|xfFXG9xk*rNRaIV)Z&p=R zQMi@-6(Po+3o_=RrK6Ko+1X_yQ`OP7udjX0V4RV98<5|#GT7ZcxYS=9YA$dwBt&g!gSYlWNW)4w?NNZmFd8{s_O-%jK48L$!z z#-SSuHYCoO8j_a}AsLt%3eKNj*oS1hIRCTZ{CmLpKEQdEpiroQaF!sSa`7n^pHlHD z6rVEjDbkhaIyN*VqFRE?a7WEYyOw~@5(tAOI7C>2Fv1cr7++t0uIJ$4;o)NP8ZY7q zO^olw-#A8QWMm}3A`WH%k3DCf8Tw*?hFiV5et;0t0Q%qrA>0M%zj+~k_L+rPOw}&3 zq?gr8TZo?;_-3{)3{_AU;&aj_!9_!B?HO4yF8Ae>Ra8{uA6^8|1s4x(^tE;7`kuUU z3&zz`2Y!5N;Fr@nwXxcp>&y7+5RCm_Fpk|*cMQ*M>g;UJ_8}$|{@s6BTE{UoH8V3M z+<_xx9K9~BW#j7U$+Xph6(3oG!-ORWN0#8b>ynB(rZ!G4E{>K4s*=}^AkcaQMJFaF z$A|ga=}2AqD*|n4gcW!BM$-ra&hm|?Fu}u+y?k?~Z~y5#lCRXXxKqdY^1CzF#HiB- zd)IIHri_2Os8hB746+0#z!H@05sDBHPJ=xG8edW_XzN` zP||WwZ(G^u&hdS#C@rI`W2CDnVd$6J^KPLrnW=v7+{IguBp=^C4SUYPkJHuBHm|QA z*mqdu)X5`X8V3~(eVVL{w0e0N??2!-8wC^$e8A5VJs@=OJCWmu_Zax)^}SnYN@i-@ zyLkDA_#=s1XSCc?Tb9r#8mq{@R8-f~R(z;r7gII)(@15Um%W9RovXKpmFf%Apu+xl zE8S%oG2xN1DcPz1#*cJ7Q|l)`E)BI+l~>j@cU4AN-%_*;%59n4c)v6?J~^{ERTX7@ z`jLiXcusxy_~P2e`tsZ;hz?wq)^-R<%&lx`@9b!;FV7A#V;sHqOw*F-ADxn(nUR_h z;cu_OUnUzgUlf%^z55f|oVt$Oj9j$>GAVQq73Q*B9_zwrY#`-r^e z{^_Na#hJm12um5$fUK73?bYe2xm9$6QL>Mz9{)JiQBzgd4(BaxZTGa6rKR@VxTyHd ztZ-XJ_u-@aq9=*J z7l)Y1C}`?w%8P?t9e6)g9b;#1fBRp2OmE_UnhO( z%Xnqpcj|$PO;~nbUQU#w#-np^mfiD%xPrM~MpIv3drpv*s>E5Ci#xAOypyX(c6KJ~ z)BG)z?=$vZcy8pGP%*GRIreU(Ce_zWo^e1_(Js2IcV(=xeq?Q^D$zxaap@7r9jA#;}pg`1<@KB?B`HBUQ;O#OW5!xA#@8yhFqM>@=U8{SsMN#@AP1$xSOQ z%L=hpy!9;>eS2HQDZYAWv@y+H>;6gf(r-?Qsk^4Ou59(>_~^+|Vvuc&9}Aq7Z|3T2yWyX7EmphWJqi@ARmP9c75g@?cl=0OPf>lN$xRsuHL6 zF!Vj+)6&w2sS06Q>&o8wVUKxOX&W9bO%=IG;Y_1fcaEb2$SNAktg-IK!dMR@Sy6l_ znSe39_-PrK4jh@rTX#5#zaK0PF=xvhT>axpyVsT>@}x(~ zV&ju@%X=0-q7an|A*-#eYw8(Y{4m>+9cU_pWIxT$&dx1tZZC~D7lhlZOCT8wjk(GG zma439XPsx4%tMPC@u;gRNQ()u(|K`SSl2BYf>$u&V}d;FOw=T=f6sVnU>?7oIe z3n#kiICe|Uz`-XnDK#k;J+ptLHpxx<$_oS6 z=*+UF)}|^XtSHG2G=KF_!zsF`ZG^S5INsU2Jy8Lte02#q^Ps%W`46n|@r9MXO%PhD zNnTQcQ|r{niPnmWrs4HZNGNydu#P9VMi$$$qasu5XaANE?llf9=-+|!v%O7t+1PJL zA?FYc0igc(q!3L6K1mb52*2lE@;DuAN)^JHdhq>ZRkSV4)6hrq<$7M39cv3s>0u#> zHB;;u-5G_^ z><#5_9$|oklklG(6#1`$LP-L`zY)AHYv|yH2h-W+t;VaTqR00zB(#{(fOQxtNm2ex zD;?Q8Ck3Cuz2O1-?PDl1KFC3P1EAed<~kOBoWgM5-k9wwjd9U=LI}r5yuV-UEC9Td z!ZHAST0zsqCk%Kw?5r9q3j@T5l884nVj>R~2>twgz(Ybn-Z?f83&FRS6$0)ijCsI$ zA3U=UMtwXR^yvRP|MC6K&JLIbHtIn*0MA6h*!p-j>|Npt@Ou^%6y$^<+)vlW*}ruo zKRqD8j{tsAi1|zQ_oN_Sm;H-q&?kj#?9X8A6T&$H{EOqw`3U@2NTRx1-|I8>L zj|>0k06T4f{{s!D2m=3cF+uJK|JS}_Tz#Qy!!`gWU;yr8190ffVQ_*S1_SVqf@<_~N^Zx5UIq17N0f7v&!ST9wt+N&Nd5J-^Lh9K0rL;Na@&O29wD!B7r`L_t4N zVIcHZq{g^vGroX0cR+qC8i3x`*1{m;FOJ`oLU(8djh|OnhKqwuzP~A}ZwE3wb|wY~ zL3ZVZJ~Jw#w7#jKybvM35d547_=T-Q^NR~3?R^ONUY1IL``nIsa1>50F86F9KqyHd z*iU|%Y_FW zs^gl#274*hmMP&jxyvpiqi{aFHNywhM2f3(`Dl zj>~Ifm?utpi@6Fc|J<~NBtKKm(vNIHb*w$Qb=VNVKXY|)`1;cnImqjegH^;RjyLv3 zY#MWk=fmd_kO_C>Y%4HataYTx&m%tH*61~PO?-Z|Cw~8rgXHIzN86&m|4e>9`}dL` z&qCLnB^!N(${0J&a*e)0oVPJ&sm9B-i6Zz#kHRv&A0ir%Zj$=PN;kpzC&k$j?}tmntxyDDk=2?N8I{+yItQCHfKczd$;t{CO|13KOiv6hwBduXu+^RIji+aU zJwQM#qz&Y#&+s$}Dje9EtWF?Duq`a#t;>3)E1}v8y^vU zp>5|CmIzUUl(y8(@c0XOn8$^ zc=JjC#FUg0jIA_wHc8qpwtfk05nJE`D8(cFPnY5$%$p~4K@vrS2=t|gI%+-LMIRsf z*eM=>z6^vua31q`nE#i~!!J%;xl3^@U;j}|_KgO|tE~1)0v&GMNSCr3lR#fu8gd@O zhIbczzcBhT5&B@S$vdZfF#}$gFoV}8Om_;4fRp$d*DI{^^y(4eYY>X{2t_j+hSO z{&yE{+@j>l=ri^nq^8Q~1C*$lGH>9Tn$si=`%1L-9fo}+tbN%SeE<|iT}vk>)5%g- zi3^m(l$6BKzbc@xcs7Z>^Sz~%O(K!g#N8xvX9aT0$Lgx7Vym! zeX6)gO|bUmAoTIZXm|rOD2ss_o#745@Wy2T&0N1Od+j59y1QupebD^7pXw+(&s>B) zKL0(R^PbOh&*!%14XeE3jjKrDwGT3jP^>LvckTP#&VB5)56TSk*vZ8Vo}?3mYX(o| z3FIbyCOhdX5c1}r-2Z{`nGB_C;EU(YMo|-Id~^Px3X!FhLuM(Ny!!h5Lscuk_}r?- zj-KA0_QtCG1a}>@ANEhH>Yi9wd$;{=bz!0x93rAGA>pECc=f~b;W%bPv9{-##WE_gT?$wO$MXA9B|U2Q^=lJSUf*TcS1luGEH29+&n zl!TdcQVu;bI+oBFFAuliBprefLLsw1AtCtMdT|P)TS`rBZSBAZBm}oZ^WtWAj-RD} zY-}u~Dk34Ic2!RNG+G(&<78)t@)wctxd~*+ZLao|XT^tyMUX-XE%#)IM=uX|)Rvc3 zknefpwRu2RrEiP@#!pqHPLcw-lmS=%`>4~~SjrMdBb5bnPysc9P+pIz1j z&V=^nx{88SUn9ohD^K5;JNZT=r)6fOC&xzydRQt$ZP^70IW@;n*#Z6WO7pkQwPir zq&K06OJzZMZEazwxdigMwhcqLWvruV7JDnP&-KIN#3XB_cMVBY9O7yxQJ|%=d3X(b zT(Q3uMOmWK8fI{u1AAK`#NuEZUN-tOdKKa9fh0w!WI{gP2utJ-!od}N-&`EpA)iwm zY|4>UvYpWPm=FD|O-UBw<&D20M(%I3__ z(A3HR_7gwRX0k&my~JnTKyj4ZDu|=h6%gO&3G&Z{K@cT9v2+;48O*_nQ`}gRlbV#2 z+cdY?o$GHTi@x+wcWYBq_vHII$gkDFlFhB<#l?-yxvs)+>(?@7Aw^ByBV&`3qdiT9 zu}odb$2zW&DY+$Om6heW$q`J5hKkDR+q(LNghfV%`@7oc%H27B;--{>hM}p2m6e5& zrh=sC3BlvvoH>q59S)+RrvlO%@fD_gzUqnbxvD3yP<%xyV0b=L^#qAn=Xk1~UfB+?-t_ z@oPRnMO6+B_2$wKP_VVf^cKVc3c$VpG-9~uSRF;DR(a`%w@!ob%Zsw+kf*cIn(1rw z{OUQ8!>AH)?t#KGJlF zuk7oCx+tkLNOIu%OY`9T?$vH!I(UYy(Z%~f-cUD<%JVcxYozklKeu~*qB4qVjfx>& zU%YFL{#~8aprEacEQ{eg*?-1o^fhli+gA{}P=J)4R&3dvCG+1woTadIxI zXCu$|X;IEZf=)p}a)4h#K^Y|J*`=(poclIgH%kc_UB^}q{RfjRgkv?(R-@s z8DNMsiJk+-@ z3`99J^e++kRz-#PtG@lr5BgU{EOc{-XR+Ys5NSQ&43LzCtYGj0=lBtsJrPbC_n+#y zqnzAuMpNdiyg12 z@sv7>nFpm(6vItJ+%dkcv$G`)sYL;lwW-_zALGI*)OMJ<1qArJ7(Bnk7HDW285wHI z-1?p^aArDL8>v6Pb%GScB_$_D_&FKK-6RA>HBHS8<+;&r`qG4;cWQBIezd(f%0U$g zmIv48C#DuYEwpEP=^??^N?&6|RqM=$LEu|Kg5Qi)CPzdS^g@Yij4cvu_vHszLB{4f zN~%JF^^Ob=ZTIx{HMRhKbatv_yUtqLZGFB zRZs#-?|Q3bf+>a69;;ix^g9?ULR!%=h{Js;aZsddk5GkxaMhlpw~64YgMsX|I|?Sw z5M_1Hm$`c2yd;rpAI;Q~cxK>*((a?2HSV8&s!U)yKfp*w$1mmoBs|yulJ0&)4Hr$4ggWHKLN3aE0bJ)p@cMu9Hpnt?x9&SNOxeUNVi z5A`KQ0Ujyoi_Lu*jyMP^EzIl;DpK)vVn; zJw5ESCGXx9J&Ot|#a{x_Gcz;7Y}M4%6rNmx)PDtICj#qkOeWJ>^Xd7$3`4K@6pZ$i zl$7L9TV*H|v5G8jX#oS!0;4J2L+k$c4C|<>spXCBWi%k0Py507Lm(O)Tv)}KyC5=E zeR9DpD60^?S7BjsX>n$}zs(y7bvs{x=xSSI@9^kgdrd}w#cRno2(_ynGh1sLJFBC$ z$!?n18NIMHGu7ALIRxvdX&7|*4C8jrZ1)KoYb_4Gk8&%pSY@XVqNFp;_6UWW2&o$xV%MRau)o(EjtA& zcyB&bvt**D!&9v_o}9h#P!+!#z4FQBL*L%!UP9H;FL!zQDffe(T@!w+Y3o6JD^Gi! zXXjsAg{0@9FPWbaZmV<)p4!kmva~kZ*FyZjq$VO^7yn+M)~G>2U2NCY#b25sqBu0b zs=pR}pn`I%@vu;Pbo%&La6~|yQ9>gBEFe@ZApG|Nr^RT~c2ALK+MuM8I&BFRJkz%F zdrVtFKDbD$^D-isFl~;00SOJWvu$af_PVk+rPa_h0|MbEN{Jr7d=F&^5SK6$L^aAzG#W=5 zeIqTUotn>XM&sb~OlAWC=ox7+pn-q}0U7{k>Q7VtKkfxh-D%2BQ+1l6!|&m$IZerF zDo#`Iz5Ng+#^#glYwZ+XU!jnRJ#+dg%ZP{c;Ki%;1W>Z-Df3%!G-Q7c*f3 z1tzJxhywG0f-YNE^^_i-x<%34vvkG%GhLDTN>^7NZKWyeaUozX`~sK@_ljf*3e^b+ zM+wqd3+Z%)be=*wK_O(=QRe^gl%V*_Ik^aRa)=YF4g9DygfDCRkG< zi!?RFhN`ku`%5*yRO?GMzEs;wwYGn($5fk3HMvxaOEtJug~a9-C4RzL5v(nd^a&dZ z7075H+3hr_S%lO?PYQ*dK83mN4TS|w`uJYbRVgIWA|TWV#Kr>LylQT=gv-6c4Y+Zm zB6m2^kn4an90hT>p=ck26_ttA5-TZJLH`twp6PqQ#sw)I$r%W@kR%F(8wjZ(AcI6QFwmmt^y>W`SxVKL0{v&RP@(JiP zSxi9aG`fLlf9X0+?vtrI2c5&M9~-deJcV#fu}900$}3DAcBBq-D(AiN_;u~IHzuI%+FrYaF^R-_Zf zURAWUwdVLzj8^qDqN1c&uIlM+5OB>`G$G*HFjBxZWTb#=!TzKIuE8TemTUD$0oU}A z0*d`3ejLRJ5(3J`vu^es@H>&JlUI~m*a203X&!|B#MTb=4$S>D2^FQ7_tP%6c6R&2 z`b=AHkSXH)RIrP!=^YyCs?Upb)BwKEOR}Z`srki4xrw1pdeYzqIr-p?m3u&Nke`dC z_VXJc*ni=Xf|ikqv7x&BlWX6B8;%I>-~)FnI&hQhRV0TMNnAzJSCO1mBy$x>S4EOm zku^mmUlp02K$-&5j*`}t)OFILk{VAg{iME=hqPbY6(EQFW+Z;dD-uQI6~apfXL@9g&Grv9zj`UGc@M|wUvJxhxl>jR}>mc-Tg)L3_QikHE$`x^jh6>RXy59JgcS8VKi4!i;r^nN+D?JNzHtPG^&W{> zhH(+B{opvjm4jd9TZgIWmASZ&id~753@UPg^O1^Mh{RG+3owRr|H&(Y;BwiEJZ0?& zxbzqsoi38j6iE-N(c@|KcnN*#QHCr^j)mhYL`u-a6R1TK5A#l<)eYwT62;N-;M$*e z`+n&T<>-9$xvCcXd&0p&-@(I`L@iuVPzzTS)V{fMn1Wj9Bmq}!)JmTIf`ZyRM;R2< zp8AqOK`rFPW8f0*8iM-+3sR18l zQLrgHd}-{JR5iFd*OnJ(j6^7W=^kI+zqZg(5b31J7R8qKEH8EyN4w}fCq+?3T|gaC z8tZBxgBgk7h57CCtNrEi9!7Gvj*-u5o!uC$O!P9AzjNXXLew<1HC&zIW2zu_QjkH2 zCO*tIXZo8f-TM(*1R&9;iH-RWV>L-02G6eol_3)S&FWzH%0_=_w3Fsz=;T4Xu(D$4 z-6E7^SSyR4B;UR;wSMB$bYr@&$;%r@iDwlC<+RRkk5nZYJx4G|GH`e!Jh zB*sY3(F&w4b{)p0lV5WLG72zcADrh1Okp!>XZq$LTVRDs3St8+71#o(ov5v?NpaI8 z1-_{@!?RP(5Fa50$<;#}GcCDcb{K5%v6X}Cvu%0dj@pvMGb;Mm=GqG)oOGnG5`w;! z`HsRUXWi%5k08Nv*WyxVQ8ZKUh3I$qt1rziei*Aw_A-*aE{p{qXWHjC2g+kzbR;kC zC*A{eV5zGh+)hp6G$BX@4VZ1o3b0VPOA28AKaAI=c)gV+1!(p+2g_rb+DO1^O$~}H z?q#vM3W7}Vs$PrK0otQ9f`C}qaWW3*LL^fg?~^&IV5={CPga?t2#QiSMJ`j-;Kuo* z^yHfZUw%dfcol$*j-vH_ML?)aKzLMuI53#0shQ)cQhkU|4zU4NHi2lW{4HPOZB%k?JKL4zlTtf6JI$V|&91D3%E8U~w!AP~JXIhl?OR>!DvER>rfRdRuxoL( z7g)*ko?k<^XtS$r4vatoP?z2K4hhyfrguhbQhZE+-we;p>iFh|sRj($BUtchWpHMD zxDo^Q8~h%<6`f0~Js7Tds^DYG;cK)au;Qr#K}sF)S=6O^6H^6(!0guft-*>oa;mzr zydsKvC{xvt?&%s&ovOqTTNB*HhCNkImYPHd8<;A%tHjyn8xbfd>_!EJRO;*N;9RFt zU*89!O&T*g&fekqyv2O3RGyss&ZCdScJnJnks2S5RjPY?3= za2fBpME6{NdoHa#m$aNqSI(U+k0hKGg7QN^;R#3JzlTSJTatacR~KiW?_Z0uPxuh7 zLmnTLd$AYcpqE?LjxsL*1TZ)|5DB6K2uE)vxq7;*x1{F&i+W408*II0FB_n@uz%pL z8p^WwzoTYt-nw#E^jFS(Fcteh0qq+*Bk9(@tEifKs+V_SH$Bqd{fEC zZcf&^a_q;zaC(FDSp1AI_c8HX{vfU0C(>5xpt``)US7@A|mkQNWxO$y^Wq<`kE^#YATO*RJ(ti zafM8(tnHg?&-OQ#J}V%lLFnS-25S)2Jzv6B#N7TP?2LncyU%RGW~ z{M=J5I}cA!cWd=Wr*10Rgr#R@rUhBZUq#{3=FZOg1SgeSM5q+))_H3bMnQ$qj97H3akM z>PlCBNPwRMGo@u27=JNw_uu?>rZLUiNR296o7)~Lk8#$d$$Hk7JM)4qsj`jEv5!;r zDV|i>W;Z&kmqk-$TiwNd8;k8Zft1gMXjkpTPZM>?bXj&AYqP(EE{iH1*ji}K=6+`s zDNCuJ`ZQicmjM;-(t2<668pEI=Q+ic4{gsiFH&Vj-f0cfA4V6bGCix1{H~SNRhmr8 zC9ZO0=O5TI7V5wvYwVNWG|NIV;+UEVt(E4rLqIN!7^ueB{BPW2s~mDf@M_POv@TqaPaZpejixsuccov`oe{`k z#MT%`trxdNu)_$eOH!o2v%$-o*kM#uRND-6^?6XF@EolF>~u}v^y2cuSVwV`BQ8>4 z4X)3TMGD@yNa5W|U&CKbkpi&~J17EZA4g~>P7f|-O5$f8Ygp5e=xZ!1`r3+)#5fnN z$6!`?kZ7$$L)|VC%f}I7KZitPV?HGM1qB81A`yG==}08cjWi^ZCvXypG$%f4s;Mk1 z&g;d$b@Q?auN(i+p?|@T&)_+g1B^+uAC|yqpFn8Go?L5l2NzctpqOU^Ga@7|IrT3X zn8xf11+>M4Z6!=%dOM|h2+bg^dI&pDU=3zh53#)`sCtO*bXS%Ur#szmUOqv0pg`b- zKkmXiS>PTi#7eMEMZGmndCIXyP<~i}BC-`W}#1Qu?y}Jl|ZRfJk zQP*~ouZ*^~$3X}-FPhh)K&2XW-6buGAyu}!wv$%oPqwyCA++;S7GC7#EWC}Ja5UB7 zWi1rrr7aW%6T;OPYv@OV*LFf(cGvdbwE&U5wnOueY25rn&d?D8@XjU*cOmpc4xosO zfe?Ixm(lYJhmRaSfcN{*$)Ny(@a55V3ODbF-4Q)^9C~!hSwIVw)oIvrD=NQHf1@D% z;Oh5+3U)Cy)9(k1LZ}8`%_XUkwc4H;OEvnK%xemwtkfMD;tt#D<=K^izjJJ|5v(Y6 zXJ!_hS6Nxqut3|LX*q>NMuNSim61NzGP6QPm{v-9^^pvA2Ghz(5B}rBGIq+)A`Dm-V zpnGMdyC9SzgI!eR@D9A1E=zBmfmhRIq4{0Q@M`)wDi+c)W}4_SI5JJ`jL^?kR>654 zODo;@EQGuzc=zTMQ#QCYLqA&?dZpA)e)_~dN8#rwK#$}0#s9#T;j@*2sbh5Mz$W!< zW#Jv-@0UT;H4|aLP&?0*lKk9?!R@h%C>tUaXl3Z1-{03eyu5+RCNW#E$}1$>*#>IE znj}|E#29?*)>A|Oyw1VlfwrnNf3sJ>8hq%`@dujB*sQ|h!t_{QE7b=W@6g}H(Ag(2 zIMCDHMETKq!UmkUEu*GyZ1PrD`Nh4n2PiwbZb6}00pV);j!skNΠJBSPYnQ<5SB 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

T*!LckZdCM_TL3 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 literal 0 HcmV?d00001 diff --git a/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf new file mode 100644 index 0000000000000000000000000000000000000000..41f99f80f377d25385759911f699d90d1b88431b GIT binary patch literal 26330 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 literal 0 HcmV?d00001 diff --git a/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt new file mode 100644 index 0000000..28440a4 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json new file mode 100644 index 0000000..54de2e5 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json @@ -0,0 +1 @@ +{ "bbox": [] } diff --git a/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json new file mode 100644 index 0000000..26ffdb7 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json @@ -0,0 +1,133 @@ +{ + "version": 8, + "name": "ADFA-2436 OSM Bright (offline)", + "glyphs": "GLYPHS_URL", + "sources": { + "osm": { + "type": "vector", + "url": "PMTILES_URL_TILES", + "attribution": "© OpenStreetMap contributors" + }, + "ne": { + "type": "raster", + "url": "PMTILES_URL_BASEMAP", + "tileSize": 512, + "attribution": "Natural Earth" + } + }, + "layers": [ + { "id": "background", "type": "background", "paint": { "background-color": "#f8f4f0" } }, + + { "id": "ne-basemap", "type": "raster", "source": "ne", "minzoom": 0, "maxzoom": 6, + "paint": { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 1.0, 5, 1.0, 6, 0.4] } }, + + { "id": "landcover-wood", "type": "fill", "source": "osm", "source-layer": "landcover", + "filter": ["==", ["get", "class"], "wood"], + "paint": { "fill-color": "#cfe2c9", "fill-opacity": 0.6 } }, + { "id": "landcover-grass", "type": "fill", "source": "osm", "source-layer": "landcover", + "filter": ["==", ["get", "class"], "grass"], + "paint": { "fill-color": "#dceac0", "fill-opacity": 0.6 } }, + + { "id": "landuse-park", "type": "fill", "source": "osm", "source-layer": "park", + "paint": { "fill-color": "#d0e6c0", "fill-opacity": 0.6 } }, + { "id": "landuse-residential", "type": "fill", "source": "osm", "source-layer": "landuse", + "filter": ["==", ["get", "class"], "residential"], + "paint": { "fill-color": "#ecddca", "fill-opacity": 0.3 } }, + { "id": "landuse-industrial", "type": "fill", "source": "osm", "source-layer": "landuse", + "filter": ["==", ["get", "class"], "industrial"], + "paint": { "fill-color": "#e0dac0", "fill-opacity": 0.4 } }, + + { "id": "waterway", "type": "line", "source": "osm", "source-layer": "waterway", "minzoom": 8, + "paint": { "line-color": "#a0c8f0", "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 14, 2] } }, + + { "id": "water", "type": "fill", "source": "osm", "source-layer": "water", + "paint": { "fill-color": "#a0c8f0" } }, + + { "id": "building", "type": "fill", "source": "osm", "source-layer": "building", "minzoom": 13, + "paint": { "fill-color": "#d8d2c0", "fill-outline-color": "#bcb6a4" } }, + + { "id": "road-minor-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 12, + "filter": ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]], + "paint": { "line-color": "#cfcdca", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0.8, 14, 3.5, 18, 16] } }, + { "id": "road-minor", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 12, + "filter": ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]], + "paint": { "line-color": "#ffffff", "line-width": ["interpolate", ["linear"], ["zoom"], 12, 0.4, 14, 2.5, 18, 13] } }, + + { "id": "road-secondary-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["in", ["get", "class"], ["literal", ["tertiary", "secondary"]]], + "paint": { "line-color": "#cfcdca", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 1, 14, 5, 18, 22] } }, + { "id": "road-secondary", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["in", ["get", "class"], ["literal", ["tertiary", "secondary"]]], + "paint": { "line-color": "#fff9e6", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.6, 14, 4, 18, 18] } }, + + { "id": "road-primary-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 7, + "filter": ["==", ["get", "class"], "primary"], + "paint": { "line-color": "#dca663", "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.6, 14, 7, 18, 28] } }, + { "id": "road-primary", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 7, + "filter": ["==", ["get", "class"], "primary"], + "paint": { "line-color": "#fcd6a4", "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.3, 14, 5.5, 18, 22] } }, + + { "id": "road-trunk-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 6, + "filter": ["==", ["get", "class"], "trunk"], + "paint": { "line-color": "#e58e60", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 14, 8, 18, 30] } }, + { "id": "road-trunk", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 6, + "filter": ["==", ["get", "class"], "trunk"], + "paint": { "line-color": "#fbb29a", "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.4, 14, 6, 18, 24] } }, + + { "id": "road-motorway-casing", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 5, + "filter": ["==", ["get", "class"], "motorway"], + "paint": { "line-color": "#d75b3f", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.6, 14, 9, 18, 32] } }, + { "id": "road-motorway", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 5, + "filter": ["==", ["get", "class"], "motorway"], + "paint": { "line-color": "#fc8d63", "line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.4, 14, 7, 18, 26] } }, + + { "id": "rail", "type": "line", "source": "osm", "source-layer": "transportation", "minzoom": 9, + "filter": ["==", ["get", "class"], "rail"], + "paint": { "line-color": "#a0a0a0", "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.4, 18, 2], "line-dasharray": [2, 2] } }, + + { "id": "boundary-country", "type": "line", "source": "osm", "source-layer": "boundary", + "filter": ["==", ["get", "admin_level"], 2], + "paint": { "line-color": "#8a6088", "line-width": 1.0, "line-dasharray": [4, 2] } }, + + { "id": "water-name", "type": "symbol", "source": "osm", "source-layer": "water_name", "minzoom": 8, + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": 12, + "text-max-width": 6, + "symbol-placement": "point" + }, + "paint": { "text-color": "#5685a8", "text-halo-color": "#ffffff", "text-halo-width": 1.0 } }, + + { "id": "road-name", "type": "symbol", "source": "osm", "source-layer": "transportation_name", "minzoom": 13, + "layout": { + "symbol-placement": "line", + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": ["interpolate", ["linear"], ["zoom"], 13, 12, 18, 16], + "text-max-angle": 40, + "symbol-spacing": 220 + }, + "paint": { "text-color": "#4a4540", "text-halo-color": "#ffffff", "text-halo-width": 1.6 } }, + + { "id": "place-village", "type": "symbol", "source": "osm", "source-layer": "place", "minzoom": 11, + "filter": ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet", "quarter"]]], + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": 12, + "text-max-width": 8 + }, + "paint": { "text-color": "#5a5550", "text-halo-color": "#ffffff", "text-halo-width": 1.2 } }, + + { "id": "place-town", "type": "symbol", "source": "osm", "source-layer": "place", + "filter": ["in", ["get", "class"], ["literal", ["city", "town"]]], + "layout": { + "text-field": ["coalesce", ["get", "name:latin"], ["get", "name"]], + "text-font": ["notosans"], + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 16], + "text-max-width": 8 + }, + "paint": { "text-color": "#33312e", "text-halo-color": "#ffffff", "text-halo-width": 1.4 } } + ] +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb new file mode 100644 index 0000000..4d269a5 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb @@ -0,0 +1,384 @@ +package ${{PACKAGE_NAME}}; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.location.Location; +import android.location.LocationManager; +import android.os.Bundle; +import android.widget.ImageButton; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.maplibre.android.MapLibre; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.location.LocationComponent; +import org.maplibre.android.location.LocationComponentActivationOptions; +import org.maplibre.android.location.modes.CameraMode; +import org.maplibre.android.location.modes.RenderMode; +import org.maplibre.android.maps.MapLibreMap; +import org.maplibre.android.maps.MapView; +import org.maplibre.android.maps.Style; +import org.maplibre.android.net.ConnectivityReceiver; +import org.maplibre.android.style.layers.LineLayer; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.sources.GeoJsonSource; + +/** + * Offline region-map Activity. + * + *

Renders ONE bundled OpenStreetMap region from a fixed, flat asset layout: + * + *

+ *   assets/maps/tiles.pmtiles    — vector OSM tiles
+ *   assets/maps/basemap.pmtiles  — low-zoom Natural Earth raster fallback
+ *   assets/maps/style.json       — MapLibre style with PMTILES_URL_* placeholders
+ *   assets/maps/meta.json        — { "bbox": [s, w, n, e] } for the initial camera
+ * 
+ * + *

A region is bundled by the Maps tab in Code on the Go: download a region, + * then "apply" it — that copies those files in, OVERWRITING any previous region. + * Until a region is applied the assets are empty and the map shows a blank + * background with the "no region configured" banner. + * + *

PMTiles loading strategy. MapLibre 13.1.0 OpenGL ES on Android only + * reliably dispatches the {@code pmtiles://http(s)://...} URL scheme — + * {@code file://} silently no-ops and {@code asset://} mishandles the path. So + * this activity starts an in-process loopback HTTP server ({@link + * PmtilesHttpServer}) serving the bundled {@code maps/} assets with Range + * support, then substitutes {@code pmtiles://http://127.0.0.1:/maps/...} + * into the style at runtime. + * + *

build.gradle.kts requirement: pmtiles assets MUST be uncompressed + * ({@code androidResources { noCompress.add("pmtiles") }}) so the server can seek + * with {@code AssetManager.openFd}. + * + *

AndroidManifest: needs INTERNET + a {@code networkSecurityConfig} + * whitelisting cleartext to 127.0.0.1 (modern Android blocks it by default). + * + *

Lifecycle delegates to the {@link MapView} per upstream docs — missing a + * forward leaks GPU memory / crashes the renderer. + */ +public class MapRegionActivity extends AppCompatActivity { + + private static final int LOCATION_PERMISSION_REQUEST = 4011; + + private MapView mapView; + private MapLibreMap map; + private PmtilesHttpServer pmtilesServer; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // MapLibre native init must run BEFORE setContentView() inflates a MapView. + MapLibre.getInstance(this); + // Force connectivity to "connected" so the loopback HTTP server keeps + // serving in airplane mode — ConnectivityReceiver otherwise fires + // connected=false and drops ALL HTTP, even to 127.0.0.1. The tiles are + // bundled in the APK, so no external internet is ever needed. + ConnectivityReceiver.instance(getApplicationContext()).setConnected(true); + + // Start the loopback server before loading the style so its port can be + // substituted into the PMTiles URLs. + pmtilesServer = new PmtilesHttpServer(getAssets(), "maps/"); + int port; + try { + port = pmtilesServer.start(); + } catch (java.io.IOException e) { + android.util.Log.e("MapRegionActivity", "PmtilesHttpServer start failed", e); + port = -1; + } + + setContentView(R.layout.activity_map_region); + + mapView = findViewById(R.id.mapView); + mapView.onCreate(savedInstanceState); + + ImageButton recenter = findViewById(R.id.recenterButton); + if (recenter != null) { + recenter.setOnClickListener(v -> recenterOnUser()); + } + + // Empty-state banner: shown only when NO region is bundled. Key off the + // region's tiles so a real region (which always ships tiles) hides it. + if (hasBundledRegion()) { + findViewById(R.id.empty_state_banner) + .setVisibility(android.view.View.GONE); + } + + applyBottomInsets(); + + // Substitute the loopback port + fixed flat paths into the style. + String styleJson = loadStyleFromAssets(); + if (styleJson != null && port > 0) { + String base = "pmtiles://http://127.0.0.1:" + port + "/maps"; + styleJson = styleJson + .replace("PMTILES_URL_TILES", base + "/tiles.pmtiles") + .replace("PMTILES_URL_BASEMAP", base + "/basemap.pmtiles") + // Glyphs (fonts for place/street labels) are served as plain HTTP + // (NOT pmtiles://) off the same loopback server; {fontstack}/{range} + // are MapLibre template tokens it fills in per glyph request. + .replace("GLYPHS_URL", + "http://127.0.0.1:" + port + "/maps/fonts/{fontstack}/{range}.pbf"); + } + final String finalStyle = styleJson; + + mapView.addOnDidFailLoadingMapListener(reason -> + android.util.Log.e("MapRegionActivity", "MAP FAIL: " + reason)); + + mapView.getMapAsync(m -> { + map = m; + if (finalStyle != null) { + m.setStyle(new Style.Builder().fromJson(finalStyle), style -> { + android.util.Log.i("MapRegionActivity", + "style loaded. sources=" + style.getSources().size() + + " layers=" + style.getLayers().size()); + enableLocationDot(style); + addBboxOutline(style); + applyInitialCamera(); + }); + } else { + applyInitialCamera(); + } + }); + } + + // ----- Current-location dot + recenter button ----- + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } + + /** + * Activate MapLibre's location dot via its built-in default location engine + * (no Play Services needed). No-op without permission. + */ + @SuppressLint("MissingPermission") + private void enableLocationDot(@NonNull Style style) { + if (map == null || !hasLocationPermission()) return; + LocationComponent lc = map.getLocationComponent(); + lc.activateLocationComponent( + LocationComponentActivationOptions.builder(this, style) + .useDefaultLocationEngine(true) + .build()); + lc.setLocationComponentEnabled(true); + lc.setCameraMode(CameraMode.NONE); + lc.setRenderMode(RenderMode.NORMAL); + } + + /** Animate the camera to the user's last known location; request permission on first tap. */ + @SuppressLint("MissingPermission") + private void recenterOnUser() { + if (!hasLocationPermission()) { + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, + LOCATION_PERMISSION_REQUEST); + return; + } + if (map == null) return; + LocationComponent lc = map.getLocationComponent(); + if (!lc.isLocationComponentActivated() && map.getStyle() != null) { + enableLocationDot(map.getStyle()); + } + Location last = lc.getLastKnownLocation(); + if (last != null) { + map.animateCamera(CameraUpdateFactory.newLatLngZoom( + new LatLng(last.getLatitude(), last.getLongitude()), 14.0)); + } else { + android.util.Log.i("MapRegionActivity", "recenter: no location fix yet"); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == LOCATION_PERMISSION_REQUEST && hasLocationPermission() + && map != null && map.getStyle() != null) { + enableLocationDot(map.getStyle()); + recenterOnUser(); + } + } + + // ----- Assets: style + bbox center ----- + + /** True if a region's vector tiles are bundled (assets/maps/tiles.pmtiles present). */ + private boolean hasBundledRegion() { + try (java.io.InputStream is = getAssets().open("maps/tiles.pmtiles")) { + return true; + } catch (Exception e) { + return false; + } + } + + /** Read assets/maps/style.json verbatim — caller substitutes URL placeholders. */ + private String loadStyleFromAssets() { + try { + return new String(readAsset("maps/style.json"), "UTF-8"); + } catch (Exception e) { + return null; + } + } + + /** + * Position the initial camera: center on the user's location if a fix is already + * available, otherwise frame the whole bundled region (its bbox). Falls back to a + * world view if there's no region. We do NOT prompt for location here — only use a + * fix we already have — so launch never blocks on a permission dialog. + */ + private void applyInitialCamera() { + if (map == null) return; + LatLng here = lastKnownLatLng(); + if (here != null) { + map.setCameraPosition(new CameraPosition.Builder().target(here).zoom(14.0).build()); + return; + } + LatLngBounds bounds = readBboxBounds(); + if (bounds != null) { + int pad = (int) (48 * getResources().getDisplayMetrics().density); + try { + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, pad)); + return; + } catch (Exception e) { + android.util.Log.w("MapRegionActivity", "fit bounds failed: " + e.getMessage()); + } + } + map.setCameraPosition(new CameraPosition.Builder().target(new LatLng(0.0, 0.0)).zoom(1.0).build()); + } + + /** The OS's most recent cached fix across providers, or null (no permission / no fix). */ + @SuppressLint("MissingPermission") + private LatLng lastKnownLatLng() { + if (!hasLocationPermission()) return null; + LocationManager lm = getSystemService(LocationManager.class); + if (lm == null) return null; + Location best = null; + for (String p : new String[]{ + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + }) { + try { + Location l = lm.getLastKnownLocation(p); + if (l != null && (best == null || l.getTime() > best.getTime())) best = l; + } catch (Exception ignored) {} + } + return best != null ? new LatLng(best.getLatitude(), best.getLongitude()) : null; + } + + /** assets/maps/meta.json's bbox ([s, w, n, e]) as MapLibre bounds, or null. */ + private LatLngBounds readBboxBounds() { + try { + JSONObject meta = new JSONObject(new String(readAsset("maps/meta.json"), "UTF-8")); + JSONArray bbox = meta.optJSONArray("bbox"); + if (bbox != null && bbox.length() == 4) { + double s = bbox.getDouble(0); + double w = bbox.getDouble(1); + double n = bbox.getDouble(2); + double e = bbox.getDouble(3); + return new LatLngBounds.Builder() + .include(new LatLng(n, e)) + .include(new LatLng(s, w)) + .build(); + } + } catch (Exception ignored) {} + return null; + } + + private byte[] readAsset(String path) throws java.io.IOException { + java.io.InputStream in = getAssets().open(path); + try { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + return out.toByteArray(); + } finally { + in.close(); + } + } + + /** + * Lift the recenter button above the system navigation bar. The app draws + * edge-to-edge (enforced for targetSdk 35+ on Android 15/16), so without this + * the button would sit UNDER the nav bar. We add the nav-bar inset to the + * button's bottom margin. + */ + private void applyBottomInsets() { + final android.view.View root = findViewById(android.R.id.content); + final ImageButton recenter = findViewById(R.id.recenterButton); + final float density = getResources().getDisplayMetrics().density; + final int baseRecenterMarginPx = (int) (16 * density); + ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> { + int navBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; + if (recenter != null + && recenter.getLayoutParams() instanceof android.view.ViewGroup.MarginLayoutParams) { + android.view.ViewGroup.MarginLayoutParams lp = + (android.view.ViewGroup.MarginLayoutParams) recenter.getLayoutParams(); + lp.bottomMargin = baseRecenterMarginPx + navBottom; + recenter.setLayoutParams(lp); + } + return insets; + }); + ViewCompat.requestApplyInsets(root); + } + + // ----- Region boundary outline ----- + + private static final String BBOX_SOURCE = "region-bbox-src"; + private static final String BBOX_LAYER = "region-bbox-outline"; + + /** + * Draw the bundled region's bbox as a dashed rectangle so the user can see the + * downloaded extent at any zoom — the framed area when zoomed out over Natural + * Earth, and the edges when zoomed in. Reads the same {@code bbox} ([s, w, n, e]) + * as the camera framing. + */ + private void addBboxOutline(Style style) { + try { + JSONObject meta = new JSONObject(new String(readAsset("maps/meta.json"), "UTF-8")); + JSONArray bbox = meta.optJSONArray("bbox"); + if (bbox == null || bbox.length() != 4) return; + double s = bbox.getDouble(0), w = bbox.getDouble(1), n = bbox.getDouble(2), e = bbox.getDouble(3); + // GeoJSON coords are [lon, lat]; closed ring NW -> NE -> SE -> SW -> NW. + String geo = "{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":" + + "[[" + w + "," + n + "],[" + e + "," + n + "],[" + e + "," + s + "],[" + + w + "," + s + "],[" + w + "," + n + "]]}}"; + style.addSource(new GeoJsonSource(BBOX_SOURCE, geo)); + style.addLayer(new LineLayer(BBOX_LAYER, BBOX_SOURCE).withProperties( + PropertyFactory.lineColor(Color.parseColor("#1f6feb")), + PropertyFactory.lineWidth(2.5f), + PropertyFactory.lineDasharray(new Float[]{ 3f, 2f }))); + } catch (Exception ignored) {} + } + + // ----- Lifecycle (every callback must reach the MapView) ----- + + @Override protected void onStart() { super.onStart(); mapView.onStart(); } + @Override protected void onResume() { super.onResume(); mapView.onResume(); } + @Override protected void onPause() { super.onPause(); mapView.onPause(); } + @Override protected void onStop() { super.onStop(); mapView.onStop(); } + @Override public void onLowMemory() { super.onLowMemory(); mapView.onLowMemory(); } + @Override protected void onDestroy() { + super.onDestroy(); + mapView.onDestroy(); + if (pmtilesServer != null) pmtilesServer.stop(); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mapView.onSaveInstanceState(outState); + } +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb new file mode 100644 index 0000000..f1aaa85 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb @@ -0,0 +1,344 @@ +package ${{PACKAGE_NAME}} + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Color +import android.location.LocationManager +import android.os.Bundle +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import org.json.JSONObject +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.modes.CameraMode +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.net.ConnectivityReceiver +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineDasharray +import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.sources.GeoJsonSource + +/** + * Offline region-map Activity. + * + * Renders ONE bundled OpenStreetMap region from a fixed, flat asset layout: + * + * assets/maps/tiles.pmtiles — vector OSM tiles + * assets/maps/basemap.pmtiles — low-zoom Natural Earth raster fallback + * assets/maps/style.json — MapLibre style with PMTILES_URL_* placeholders + * assets/maps/meta.json — { "bbox": [s, w, n, e] } for the initial camera + * + * A region is bundled by the Maps tab in Code on the Go: download a region, then + * "apply" it — that copies those files in, OVERWRITING any previous region. Until + * a region is applied the assets are empty and the map shows a blank background + * with the "no region configured" banner. + * + * **PMTiles loading strategy.** MapLibre 13.1.0 OpenGL ES on Android only + * reliably dispatches the `pmtiles://http(s)://...` URL scheme — `file://` + * silently no-ops and `asset://` mishandles the path. So this activity starts an + * in-process loopback HTTP server ([PmtilesHttpServer]) serving the bundled + * `maps/` assets with Range support, then substitutes + * `pmtiles://http://127.0.0.1:/maps/tiles.pmtiles` (and basemap) into the + * style at runtime. + * + * **build.gradle.kts requirement**: pmtiles assets MUST be uncompressed + * (`androidResources { noCompress.add("pmtiles") }`) so the server can seek with + * `AssetManager.openFd`. + * + * **AndroidManifest**: needs INTERNET + a `networkSecurityConfig` whitelisting + * cleartext to 127.0.0.1 (modern Android blocks it by default). + * + * Lifecycle delegates to the [MapView] per upstream docs — missing a forward + * leaks GPU memory / crashes the renderer. + */ +class MapRegionActivity : AppCompatActivity() { + + private lateinit var mapView: MapView + private var map: MapLibreMap? = null + private var pmtilesServer: PmtilesHttpServer? = null + + private val locationPermissionRequest = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + map?.style?.let { enableLocationDot(it) } + recenterOnUser() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // MapLibre native init must run BEFORE setContentView() inflates a MapView. + MapLibre.getInstance(this) + // Force connectivity to "connected" so the loopback HTTP server keeps + // serving in airplane mode — ConnectivityReceiver otherwise fires + // connected=false and drops ALL HTTP, even to 127.0.0.1. The tiles are + // bundled in the APK, so no external internet is ever needed. + ConnectivityReceiver.instance(applicationContext).setConnected(true) + + // Start the loopback server before loading the style so its port can be + // substituted into the PMTiles URLs. + val server = PmtilesHttpServer(assets, "maps/") + val port = try { + server.start() + } catch (e: java.io.IOException) { + android.util.Log.e("MapRegionActivity", "PmtilesHttpServer start failed", e) + -1 + } + pmtilesServer = server + + setContentView(R.layout.activity_map_region) + + mapView = findViewById(R.id.mapView) + mapView.onCreate(savedInstanceState) + + findViewById(R.id.recenterButton)?.setOnClickListener { recenterOnUser() } + + // Empty-state banner: shown only when NO region is bundled. Key off the + // region's tiles so a real region (which always ships tiles) hides it. + if (hasBundledRegion()) { + findViewById(R.id.empty_state_banner)?.visibility = + android.view.View.GONE + } + + applyBottomInsets() + + // Substitute the loopback port + fixed flat paths into the style. + var styleJson = loadStyleFromAssets() + if (styleJson != null && port > 0) { + val base = "pmtiles://http://127.0.0.1:$port/maps" + styleJson = styleJson + .replace("PMTILES_URL_TILES", "$base/tiles.pmtiles") + .replace("PMTILES_URL_BASEMAP", "$base/basemap.pmtiles") + // Glyphs (fonts for place/street labels) are served as plain HTTP + // (NOT pmtiles://) off the same loopback server; {fontstack}/{range} + // are MapLibre template tokens it fills in per glyph request. + .replace("GLYPHS_URL", "http://127.0.0.1:$port/maps/fonts/{fontstack}/{range}.pbf") + } + val finalStyle = styleJson + + mapView.addOnDidFailLoadingMapListener { reason -> + android.util.Log.e("MapRegionActivity", "MAP FAIL: $reason") + } + + mapView.getMapAsync { m -> + map = m + if (finalStyle != null) { + m.setStyle(Style.Builder().fromJson(finalStyle)) { style -> + android.util.Log.i( + "MapRegionActivity", + "style loaded. sources=${style.sources.size} layers=${style.layers.size}", + ) + enableLocationDot(style) + addBboxOutline(style) + applyInitialCamera() + } + } else { + applyInitialCamera() + } + } + } + + // ----- Current-location dot + recenter button ----- + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + /** + * Activate MapLibre's location dot via its built-in default location engine + * (no Play Services needed). No-op without permission. + */ + @SuppressLint("MissingPermission") + private fun enableLocationDot(style: Style) { + val m = map ?: return + if (!hasLocationPermission()) return + val lc = m.locationComponent + lc.activateLocationComponent( + LocationComponentActivationOptions.builder(this, style) + .useDefaultLocationEngine(true) + .build() + ) + lc.isLocationComponentEnabled = true + lc.cameraMode = CameraMode.NONE + lc.renderMode = RenderMode.NORMAL + } + + /** Animate the camera to the user's last known location; request permission on first tap. */ + @SuppressLint("MissingPermission") + private fun recenterOnUser() { + if (!hasLocationPermission()) { + locationPermissionRequest.launch(Manifest.permission.ACCESS_FINE_LOCATION) + return + } + val m = map ?: return + val lc = m.locationComponent + if (!lc.isLocationComponentActivated) { + m.style?.let { enableLocationDot(it) } + } + val last = lc.lastKnownLocation + if (last != null) { + m.animateCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(last.latitude, last.longitude), 14.0) + ) + } else { + android.util.Log.i("MapRegionActivity", "recenter: no location fix yet") + } + } + + // ----- Assets: style + bbox center ----- + + /** True if a region's vector tiles are bundled (assets/maps/tiles.pmtiles present). */ + private fun hasBundledRegion(): Boolean = + runCatching { assets.open("maps/tiles.pmtiles").use { true } }.getOrDefault(false) + + /** Read assets/maps/style.json verbatim — caller substitutes URL placeholders. */ + private fun loadStyleFromAssets(): String? = runCatching { + assets.open("maps/style.json").bufferedReader().use { it.readText() } + }.getOrNull() + + /** + * Position the initial camera: center on the user's location if a fix is already + * available, otherwise frame the whole bundled region (its bbox). Falls back to a + * world view if there's no region. We do NOT prompt for location here — only use a + * fix we already have — so launch never blocks on a permission dialog. + */ + private fun applyInitialCamera() { + val m = map ?: return + lastKnownLatLng()?.let { here -> + m.cameraPosition = CameraPosition.Builder().target(here).zoom(14.0).build() + return + } + readBboxBounds()?.let { bounds -> + val pad = (48 * resources.displayMetrics.density).toInt() + runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, pad)) } + .onFailure { android.util.Log.w("MapRegionActivity", "fit bounds failed: ${it.message}") } + .onSuccess { return } + } + m.cameraPosition = CameraPosition.Builder().target(LatLng(0.0, 0.0)).zoom(1.0).build() + } + + /** The OS's most recent cached fix across providers, or null (no permission / no fix). */ + @SuppressLint("MissingPermission") + private fun lastKnownLatLng(): LatLng? { + if (!hasLocationPermission()) return null + val lm = getSystemService(LocationManager::class.java) ?: return null + var best: android.location.Location? = null + for (p in listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + )) { + val l = runCatching { lm.getLastKnownLocation(p) }.getOrNull() ?: continue + if (best == null || l.time > best!!.time) best = l + } + return best?.let { LatLng(it.latitude, it.longitude) } + } + + /** assets/maps/meta.json's `bbox` ([s, w, n, e]) as MapLibre bounds, or null. */ + private fun readBboxBounds(): LatLngBounds? = runCatching { + val raw = assets.open("maps/meta.json").bufferedReader().use { it.readText() } + val bbox = JSONObject(raw).optJSONArray("bbox") + if (bbox != null && bbox.length() == 4) { + val s = bbox.getDouble(0) + val w = bbox.getDouble(1) + val n = bbox.getDouble(2) + val e = bbox.getDouble(3) + LatLngBounds.Builder() + .include(LatLng(n, e)) + .include(LatLng(s, w)) + .build() + } else { + null + } + }.getOrNull() + + // ----- Region boundary outline ----- + + /** + * Draw the bundled region's bbox as a dashed rectangle so the user can see the + * downloaded extent at any zoom — the framed area when zoomed out over Natural + * Earth, and the edges when zoomed in. Reads the same `bbox` ([s, w, n, e]) as + * the camera framing. + */ + private fun addBboxOutline(style: Style) { + val raw = runCatching { + assets.open("maps/meta.json").bufferedReader().use { it.readText() } + }.getOrNull() ?: return + val bbox = runCatching { JSONObject(raw).optJSONArray("bbox") }.getOrNull() ?: return + if (bbox.length() != 4) return + val s = bbox.getDouble(0); val w = bbox.getDouble(1) + val n = bbox.getDouble(2); val e = bbox.getDouble(3) + // GeoJSON coords are [lon, lat]; closed ring NW -> NE -> SE -> SW -> NW. + val geo = """{"type":"Feature","geometry":{"type":"LineString","coordinates":""" + + "[[$w,$n],[$e,$n],[$e,$s],[$w,$s],[$w,$n]]}}" + style.addSource(GeoJsonSource(BBOX_SOURCE, geo)) + style.addLayer( + LineLayer(BBOX_LAYER, BBOX_SOURCE).withProperties( + lineColor(Color.parseColor("#1f6feb")), + lineWidth(2.5f), + lineDasharray(arrayOf(3f, 2f)), + ) + ) + } + + /** + * Lift the recenter button above the system navigation bar. The app draws + * edge-to-edge (enforced for targetSdk 35+ on Android 15/16), so without this + * the button would sit UNDER the nav bar. We add the nav-bar inset to the + * button's bottom margin. + */ + private fun applyBottomInsets() { + val root = findViewById(android.R.id.content) + val recenter = findViewById(R.id.recenterButton) + val density = resources.displayMetrics.density + val baseRecenterMarginPx = (16 * density).toInt() + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + val navBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + recenter?.let { btn -> + (btn.layoutParams as? android.view.ViewGroup.MarginLayoutParams)?.let { lp -> + lp.bottomMargin = baseRecenterMarginPx + navBottom + btn.layoutParams = lp + } + } + insets + } + ViewCompat.requestApplyInsets(root) + } + + // ----- Lifecycle (every callback must reach the MapView) ----- + + override fun onStart() { super.onStart(); mapView.onStart() } + override fun onResume() { super.onResume(); mapView.onResume() } + override fun onPause() { super.onPause(); mapView.onPause() } + override fun onStop() { super.onStop(); mapView.onStop() } + override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() } + override fun onDestroy() { + super.onDestroy() + mapView.onDestroy() + pmtilesServer?.stop() + } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } + + private companion object { + const val BBOX_SOURCE = "region-bbox-src" + const val BBOX_LAYER = "region-bbox-outline" + } +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb new file mode 100644 index 0000000..8ece71b --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb @@ -0,0 +1,170 @@ +package ${{PACKAGE_NAME}}; + +import android.content.res.AssetFileDescriptor; +import android.content.res.AssetManager; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets + * with Range-request support. Used to feed bundled PMTiles into MapLibre via + * {@code pmtiles://http://127.0.0.1:PORT/...} — the only PMTiles URL scheme proven + * to work in MapLibre 13.1.0 OpenGL ES on Android. + * + *

Requires assets to be stored uncompressed in the APK + * ({@code androidResources { noCompress.add("pmtiles") }}) so {@link + * AssetManager#openFd} returns a real seekable fd. + */ +public class PmtilesHttpServer { + private static final String TAG = "PmtilesHttpServer"; + private static final int BUF = 64 * 1024; + + private final AssetManager assets; + private final String rootPrefix; + private ServerSocket socket; + private ExecutorService pool; + private int port; + private volatile boolean running; + + public PmtilesHttpServer(AssetManager assets, String rootPrefix) { + this.assets = assets; + this.rootPrefix = rootPrefix.endsWith("/") ? rootPrefix : rootPrefix + "/"; + } + + public int start() throws IOException { + socket = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); + port = socket.getLocalPort(); + running = true; + pool = Executors.newCachedThreadPool(); + Thread accept = new Thread(() -> { + while (running) { + try { + Socket client = socket.accept(); + pool.submit(() -> handle(client)); + } catch (IOException e) { + if (running) Log.w(TAG, "accept: " + e); + } + } + }, "PmtilesHttpServer-accept"); + accept.setDaemon(true); + accept.start(); + Log.i(TAG, "listening on 127.0.0.1:" + port + " serving assets/" + rootPrefix); + return port; + } + + public int port() { return port; } + + public void stop() { + running = false; + try { if (socket != null) socket.close(); } catch (IOException ignored) {} + if (pool != null) pool.shutdownNow(); + } + + private void handle(Socket client) { + try (Socket c = client; + InputStream in = c.getInputStream(); + OutputStream out = c.getOutputStream()) { + BufferedReader r = new BufferedReader(new InputStreamReader(in)); + String reqLine = r.readLine(); + if (reqLine == null) return; + String[] parts = reqLine.split(" "); + if (parts.length < 2 || !parts[0].equalsIgnoreCase("GET")) { + writeStatus(out, 405, "Method Not Allowed"); return; + } + String path = parts[1]; + if (path.startsWith("/")) path = path.substring(1); + int q = path.indexOf('?'); + if (q >= 0) path = path.substring(0, q); + if (!path.startsWith(rootPrefix)) { writeStatus(out, 404, "Not Found"); return; } + // Reject path-traversal segments. AssetManager already sandboxes to + // the APK's assets/ tree and the server is loopback-only, but a ".." + // in the request path has no legitimate use here. + if (path.contains("..")) { writeStatus(out, 404, "Not Found"); return; } + + long rangeStart = 0; + long rangeEnd = -1; + String line; + while ((line = r.readLine()) != null && !line.isEmpty()) { + if (line.regionMatches(true, 0, "Range:", 0, 6)) { + String v = line.substring(6).trim(); + if (v.startsWith("bytes=")) { + String spec = v.substring(6); + int dash = spec.indexOf('-'); + if (dash > 0) { + try { rangeStart = Long.parseLong(spec.substring(0, dash)); } catch (Exception ignored) {} + String rest = spec.substring(dash + 1); + if (!rest.isEmpty()) { + try { rangeEnd = Long.parseLong(rest); } catch (Exception ignored) {} + } + } + } + } + } + + AssetFileDescriptor afd; + try { afd = assets.openFd(path); } + catch (IOException e) { writeStatus(out, 404, "Not Found"); return; } + long totalLen = afd.getLength(); + try { + if (rangeEnd < 0 || rangeEnd >= totalLen) rangeEnd = totalLen - 1; + if (rangeStart < 0) rangeStart = 0; + if (rangeStart >= totalLen) { + writeStatus(out, 416, "Requested Range Not Satisfiable"); return; + } + long sliceLen = rangeEnd - rangeStart + 1; + boolean partial = (rangeStart != 0) || (rangeEnd != totalLen - 1); + StringBuilder hdr = new StringBuilder(partial + ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n"); + hdr.append("Content-Type: application/octet-stream\r\n"); + hdr.append("Accept-Ranges: bytes\r\n"); + hdr.append("Content-Length: ").append(sliceLen).append("\r\n"); + if (partial) { + hdr.append("Content-Range: bytes ").append(rangeStart).append('-') + .append(rangeEnd).append('/').append(totalLen).append("\r\n"); + } + hdr.append("Connection: close\r\n\r\n"); + out.write(hdr.toString().getBytes("US-ASCII")); + + try (FileInputStream fis = afd.createInputStream()) { + long skipped = 0; + while (skipped < rangeStart) { + long s = fis.skip(rangeStart - skipped); + if (s <= 0) break; + skipped += s; + } + byte[] buf = new byte[BUF]; + long remaining = sliceLen; + while (remaining > 0) { + int want = (int) Math.min(buf.length, remaining); + int n = fis.read(buf, 0, want); + if (n < 0) break; + out.write(buf, 0, n); + remaining -= n; + } + out.flush(); + } + } finally { + try { afd.close(); } catch (IOException ignored) {} + } + } catch (Exception e) { + Log.w(TAG, "handle: " + e); + } + } + + private void writeStatus(OutputStream out, int code, String msg) throws IOException { + String s = "HTTP/1.1 " + code + " " + msg + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + out.write(s.getBytes("US-ASCII")); + out.flush(); + } +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb new file mode 100644 index 0000000..edf644f --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb @@ -0,0 +1,168 @@ +package ${{PACKAGE_NAME}} + +import android.content.res.AssetManager +import android.util.Log +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets + * with Range-request support. Used to feed bundled PMTiles into MapLibre via + * `pmtiles://http://127.0.0.1:PORT/...` — the only PMTiles URL scheme proven to + * work in MapLibre 13.1.0 OpenGL ES on Android. + * + * Requires assets to be stored uncompressed in the APK + * (`androidResources { noCompress.add("pmtiles") }`) so [AssetManager.openFd] + * returns a real seekable fd. + */ +class PmtilesHttpServer( + private val assets: AssetManager, + rootPrefix: String, +) { + private val rootPrefix: String = if (rootPrefix.endsWith("/")) rootPrefix else "$rootPrefix/" + private var socket: ServerSocket? = null + private var pool: ExecutorService? = null + var port: Int = 0 + private set + @Volatile private var running = false + + @Throws(IOException::class) + fun start(): Int { + val s = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")) + socket = s + port = s.localPort + running = true + pool = Executors.newCachedThreadPool() + val accept = Thread({ + while (running) { + try { + val client = s.accept() + pool?.submit { handle(client) } + } catch (e: IOException) { + if (running) Log.w(TAG, "accept: $e") + } + } + }, "PmtilesHttpServer-accept") + accept.isDaemon = true + accept.start() + Log.i(TAG, "listening on 127.0.0.1:$port serving assets/$rootPrefix") + return port + } + + fun stop() { + running = false + try { socket?.close() } catch (_: IOException) {} + pool?.shutdownNow() + } + + private fun handle(client: Socket) { + try { + client.use { c -> + val out = c.getOutputStream() + val r = BufferedReader(InputStreamReader(c.getInputStream())) + val reqLine = r.readLine() ?: return + val parts = reqLine.split(" ") + if (parts.size < 2 || !parts[0].equals("GET", ignoreCase = true)) { + writeStatus(out, 405, "Method Not Allowed"); return + } + var path = parts[1] + if (path.startsWith("/")) path = path.substring(1) + val q = path.indexOf('?') + if (q >= 0) path = path.substring(0, q) + if (!path.startsWith(rootPrefix)) { writeStatus(out, 404, "Not Found"); return } + // Reject path-traversal segments. AssetManager already sandboxes + // to the APK's assets/ tree and the server is loopback-only, but a + // ".." in the request path has no legitimate use here. + if (path.contains("..")) { writeStatus(out, 404, "Not Found"); return } + + var rangeStart = 0L + var rangeEnd = -1L + var line: String? + while (r.readLine().also { line = it } != null && !line.isNullOrEmpty()) { + val l = line ?: continue + if (l.regionMatches(0, "Range:", 0, 6, ignoreCase = true)) { + val v = l.substring(6).trim() + if (v.startsWith("bytes=")) { + val spec = v.substring(6) + val dash = spec.indexOf('-') + if (dash > 0) { + spec.substring(0, dash).toLongOrNull()?.let { rangeStart = it } + val rest = spec.substring(dash + 1) + if (rest.isNotEmpty()) rest.toLongOrNull()?.let { rangeEnd = it } + } + } + } + } + + val afd = try { + assets.openFd(path) + } catch (e: IOException) { + writeStatus(out, 404, "Not Found"); return + } + val totalLen = afd.length + try { + if (rangeEnd < 0 || rangeEnd >= totalLen) rangeEnd = totalLen - 1 + if (rangeStart < 0) rangeStart = 0 + if (rangeStart >= totalLen) { + writeStatus(out, 416, "Requested Range Not Satisfiable"); return + } + val sliceLen = rangeEnd - rangeStart + 1 + val partial = rangeStart != 0L || rangeEnd != totalLen - 1 + val hdr = StringBuilder( + if (partial) "HTTP/1.1 206 Partial Content\r\n" else "HTTP/1.1 200 OK\r\n" + ) + hdr.append("Content-Type: application/octet-stream\r\n") + hdr.append("Accept-Ranges: bytes\r\n") + hdr.append("Content-Length: ").append(sliceLen).append("\r\n") + if (partial) { + hdr.append("Content-Range: bytes ").append(rangeStart).append('-') + .append(rangeEnd).append('/').append(totalLen).append("\r\n") + } + hdr.append("Connection: close\r\n\r\n") + out.write(hdr.toString().toByteArray(Charsets.US_ASCII)) + + afd.createInputStream().use { fis -> + var skipped = 0L + while (skipped < rangeStart) { + val sk = fis.skip(rangeStart - skipped) + if (sk <= 0) break + skipped += sk + } + val buf = ByteArray(BUF) + var remaining = sliceLen + while (remaining > 0) { + val want = minOf(buf.size.toLong(), remaining).toInt() + val n = fis.read(buf, 0, want) + if (n < 0) break + out.write(buf, 0, n) + remaining -= n + } + out.flush() + } + } finally { + try { afd.close() } catch (_: IOException) {} + } + } + } catch (e: Exception) { + Log.w(TAG, "handle: $e") + } + } + + private fun writeStatus(out: OutputStream, code: Int, msg: String) { + val s = "HTTP/1.1 $code $msg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + out.write(s.toByteArray(Charsets.US_ASCII)) + out.flush() + } + + private companion object { + const val TAG = "PmtilesHttpServer" + const val BUF = 64 * 1024 + } +} diff --git a/maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml b/maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml new file mode 100644 index 0000000..d876a51 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml b/maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ccd9ffa --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + #000000 + #FFFFFF + diff --git a/maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb b/maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb new file mode 100644 index 0000000..01317a2 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb @@ -0,0 +1,4 @@ + + ${{APP_NAME}} + Location permission is required to centre the map on your position. + diff --git a/maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml b/maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ca49106 --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + +