From 3825501f17829a00841f2e00ba710f8ccc99f7f8 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 20:59:17 -0700 Subject: [PATCH 01/81] docs(maps): plugin README, repo registration, OSM/MapLibre tutorial --- maps/README.md | 94 ++++++++ maps/maps.html | 166 ++++++++++++++ maps/src/main/assets/docs/osm-tutorial.html | 242 ++++++++++++++++++++ 3 files changed, 502 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/maps/README.md b/maps/README.md new file mode 100644 index 00000000..6282c34c --- /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 00000000..4a53d3ef --- /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 00000000..6c5d1425 --- /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 9c2f4fd4d942d82d04101a1f4257aaffe1373ff3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 20:59:17 -0700 Subject: [PATCH 02/81] 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 00000000..765a921b --- /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 00000000..a91ac582 --- /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 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^wuKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLThB7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C$lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd|~zDIc3J4 zy9y%wZOW>}eG4&&;Z>vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b=k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gvuz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCtScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@YV9}FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ(p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW<-o!ibBn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss-j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC&@9YI~uvl?rBp^A-57{aH_wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ@O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpae!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4fX`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6tfP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-`uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR)#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_ECjt(wrpr{C_xdAqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg!nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@MgSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7-5`33rjDmyIGvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@mX%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVHy;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M)htph`=if#r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&?q^R|=1+h!1%G4LhQs54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXHsVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mtl zg^UOBv~k>Z(E)O>Z;~Z)W&4FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp_Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9ChGhs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!Ab^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE%ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G!jMY`qJ z8qjZ$*-V|?y0=zIM>!2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IHowW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O=gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w%l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgSj(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8#K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-|``!0l|XrTyUYA@mY?#bJYvD>jX&$o9VAbo?>?#Z^c+Y4Dl zXU9k`s74Sb$OYh7^B|SAVVz*jEW&GWG^cP<_!hW+#Qp|4791Od=HJcesFo?$#0eWD z8!Ib_>H1WQE}shsQiUNk!uWOyAzX>r(-N7;+(O333_ES7*^6z4{`p&O*q8xk{0xy@ zB&9LkW_B}_Y&?pXP-OYNJfqEWUVAPBk)pTP^;f+75Wa(W>^UO_*J05f1k{ zd-}j!4m@q#CaC6mLsQHD1&7{tJ*}LtE{g9LB>sIT7)l^ucm8&+L0=g1E_6#KHfS>A_Z?;pFP96*nX=1&ejZ+XvZ=ML`@oVu>s^WIjn^SY}n zboeP%`O9|dhzvnw%?wAsCw*lvVcv%bmO5M4cas>b%FHd;A6Z%Ej%;jgPuvL$nk=VQ=$-OTwslYg zJQtDS)|qkIs%)K$+r*_NTke8%Rv&w^v;|Ajh5QXaVh}ugccP}3E^(oGC5VO*4`&Q0 z&)z$6i_aKI*CqVBglCxo#9>eOkDD!voCJRFkNolvA2N&SAp^4<8{Y;#Kr5740 za|G`dYGE!9NGU3Ge6C)YByb6Wy#}EN`Ao#R!$LQ&SM#hifEvZp>1PAX{CSLqD4IuO z4#N4AjMj5t2|!yTMrl5r)`_{V6DlqVeTwo|tq4MHLZdZc5;=v9*ibc;IGYh+G|~PB zx2}BAv6p$}?7YpvhqHu7L;~)~Oe^Y)O(G(PJQB<&2AhwMw!(2#AHhjSsBYUd8MDeM z+UXXyV@@cQ`w}mJ2PGs>=jHE{%i44QsPPh(=yorg>jHic+K+S*q3{th6Ik^j=@%xo zXfa9L_<|xTL@UZ?4H`$vt9MOF`|*z&)!mECiuenMW`Eo2VE#|2>2ET7th6+VAmU(o zq$Fz^TUB*@a<}kr6I>r;6`l%8NWtVtkE?}Q<<$BIm*6Z(1EhDtA29O%5d1$0q#C&f zFhFrrss{hOsISjYGDOP*)j&zZUf9`xvR8G)gwxE$HtmKsezo`{Ta~V5u+J&Tg+{bh zhLlNbdzJNF6m$wZNblWNbP6>dTWhngsu=J{);9D|PPJ96aqM4Lc?&6H-J1W15uIpQ ziO{&pEc2}-cqw+)w$`p(k(_yRpmbp-Xcd`*;Y$X=o(v2K+ISW)B1(ZnkV`g4rHQ=s z+J?F9&(||&86pi}snC07Lxi1ja>6kvnut;|Ql3fD)%k+ASe^S|lN69+Ek3UwsSx=2EH)t}K>~ z`Mz-SSVH29@DWyl`ChuGAkG>J;>8ZmLhm>uEmUvLqar~vK3lS;4s<{+ehMsFXM(l- zRt=HT>h9G)JS*&(dbXrM&z;)66C=o{=+^}ciyt8|@e$Y}IREAyd_!2|CqTg=eu}yG z@sI9T;Tjix*%v)c{4G84|0j@8wX^Iig_JsPU|T%(J&KtJ>V zsAR+dcmyT5k&&G{!)VXN`oRS{n;3qd`BgAE9r?%AHy_Gf8>$&X$=>YD7M911?<{qX zkJ;IOfY$nHdy@kKk_+X%g3`T(v|jS;>`pz`?>fqMZ>Fvbx1W=8nvtuve&y`JBfvU~ zr+5pF!`$`TUVsx3^<)48&+XT92U0DS|^X6FwSa-8yviRkZ*@Wu|c*lX!m?8&$0~4T!DB0@)n}ey+ew}T1U>|fH3=W5I!=nfoNs~OkzTY7^x^G&h>M7ewZqmZ=EL0}3#ikWg+(wuoA{7hm|7eJz zNz78l-K81tP16rai+fvXtspOhN-%*RY3IzMX6~8k9oFlXWgICx9dp;`)?Toz`fxV@&m8< z{lzWJG_Y(N1nOox>yG^uDr}kDX_f`lMbtxfP`VD@l$HR*B(sDeE(+T831V-3d3$+% zDKzKnK_W(gLwAK{Saa2}zaV?1QmcuhDu$)#;*4gU(l&rgNXB^WcMuuTki*rt>|M)D zoI;l$FTWIUp}euuZjDidpVw6AS-3dal2TJJaVMGj#CROWr|;^?q>PAo2k^u-27t~v zCv10IL~E)o*|QgdM!GJTaT&|A?oW)m9qk2{=y*7qb@BIAlYgDIe)k(qVH@)#xx6%7 z@)l%aJwz5Joc84Q2jRp71d;=a@NkjSdMyN%L6OevML^(L0_msbef>ewImS=+DgrTk z4ON%Y$mYgcZ^44O*;ctP>_7=}=pslsu>~<-bw=C(jeQ-X`kUo^BS&JDHy%#L32Cj_ zXRzDCfCXKXxGSW9yOGMMOYqPKnU zTF6gDj47!7PoL%z?*{1eyc2IVF*RXX?mj1RS}++hZg_%b@6&PdO)VzvmkXxJ*O7H} z6I7XmJqwX3<>z%M@W|GD%(X|VOZ7A+=@~MxMt8zhDw`yz?V>H%C0&VY+ZZ>9AoDVZeO1c~z$r~!H zA`N_9p`X?z>jm!-leBjW1R13_i2(0&aEY2$l_+-n#powuRO;n2Fr#%jp{+3@`h$c< zcFMr;18Z`UN#spXv+3Ks_V_tSZ1!FY7H(tdAk!v}SkoL9RPYSD3O5w>A3%>7J+C-R zZfDmu=9<1w1CV8rCMEm{qyErCUaA3Q zRYYw_z!W7UDEK)8DF}la9`}8z*?N32-6c-Bwx^Jf#Muwc67sVW24 zJ4nab%>_EM8wPhL=MAN)xx1tozAl zmhXN;*-X%)s>(L=Q@vm$qmuScku>PV(W_x-6E?SFRjSk)A1xVqnml_92fbj0m};UC zcV}lRW-r*wY106|sshV`n#RN{)D9=!>XVH0vMh>od=9!1(U+sWF%#B|eeaKI9RpaW z8Ol_wAJX%j0h5fkvF)WMZ1}?#R(n-OT0CtwsL)|qk;*(!a)5a5ku2nCR9=E*iOZ`9 zy4>LHKt-BgHL@R9CBSG!v4wK zvjF8DORRva)@>nshE~VM@i2c$PKw?3nz(6-iVde;-S~~7R<5r2t$0U8k2_<5C0!$j zQg#lsRYtI#Q1YRs(-%(;F-K7oY~!m&zhuU4LL}>jbLC>B`tk8onRRcmIm{{0cpkD|o@Ixu#x9Wm5J)3oFkbfi62BX8IX1}VTe#{C(d@H|#gy5#Sa#t>sH@8v1h8XFgNGs?)tyF_S^ueJX_-1%+LR`1X@C zS3Oc)o)!8Z9!u9d!35YD^!aXtH;IMNzPp`NS|EcdaQw~<;z`lmkg zE|tQRF7!S!UCsbag%XlQZXmzAOSs= zIUjgY2jcN9`xA6mzG{m|Zw=3kZC4@XY=Bj%k8%D&iadvne$pYNfZI$^2BAB|-MnZW zU4U?*qE3`ZDx-bH})>wz~)a z_SWM!E=-BS#wdrfh;EfPNOS*9!;*+wp-zDthj<>P0a2n?$xfe;YmX~5a;(mNV5nKx zYR86%WtAPsOMIg&*o9uUfD!v&4(mpS6P`bFohPP<&^fZzfA|SvVzPQgbtwwM>IO>Z z75ejU$1_SB1tn!Y-9tajZ~F=Fa~{cnj%Y|$;%z6fJV1XC0080f)Pj|87j142q6`i>#)BCIi+x&jAH9|H#iMvS~?w;&E`y zoarJ)+5HWmZ{&OqlzbdQU=SE3GKmnQq zI{h6f$C@}Mbqf#JDsJyi&7M0O2ORXtEB`#cZ;#AcB zkao0`&|iH8XKvZ_RH|VaK@tAGKMq9x{sdd%p-o`!cJzmd&hb86N!KKxp($2G?#(#BJn5%hF0(^`= z2qRg5?82({w-HyjbffI>eqUXavp&|D8(I6zMOfM}0;h%*D_Dr@+%TaWpIEQX3*$vQ z8_)wkNMDi{rW`L+`yN^J*Gt(l7PExu3_hrntgbW0s}7m~1K=(mFymoU87#{|t*fJ?w8&>Uh zcS$Ny$HNRbT!UCFldTSp2*;%EoW+yhJD8<3FUt8@XSBeJM2dSEz+5}BWmBvdYK(OA zlm`nDDsjKED{$v*jl(&)H7-+*#jWI)W|_X)!em1qpjS_CBbAiyMt;tx*+0P%*m&v< zxV9rlslu8#cS!of#^1O$(ds8aviMFiT`6W+FzMHW{YS+SieJ^?TQb%NT&pasw^kbc znd`=%(bebvrNx3#7vq@vAX-G`4|>cY0svIXopH02{v;GZ{wJM#psz4!m8(IZu<)9D zqR~U7@cz-6H{724_*}-DWwE8Sk+dYBb*O-=c z+wdchFcm6$$^Z0_qGnv0P`)h1=D$_eg8!2-|7Y;o*c)4ax!Me0*EVcioh{wI#!qcb z1&xhOotXMrlo7P6{+C8m;E#4*=8(2y!r0d<6 zKi$d2X;O*zS(&Xiz_?|`ympxITf|&M%^WHp=694g6W@k+BL_T1JtSYX0OZ}o%?Pzu zJ{%P8A$uq?4F!NWGtq>_GLK3*c6dIcGH)??L`9Av&0k$A*14ED9!e9z_SZd3OH6ER zg%5^)3^gw;4DFw(RC;~r`bPJOR}H}?2n60=g4ESUTud$bkBLPyI#4#Ye{5x3@Yw<* z;P5Up>Yn(QdP#momCf=kOzZYzg9E330=67WOPbCMm2-T1%8{=or9L8+HGL{%83lri zODB;Y|LS`@mn#Wmez7t6-x`a2{}U9hE|xY7|BVcFCqoAZQzsEi=dYHB z(bqG3J5?teVSBqTj{aiqe<9}}CEc$HdsJSMp#I;4(EXRy_k|Y8X#5hwkqAaIGKARF zX?$|UO{>3-FU;IlFi80O^t+WMNw4So2nsg}^T1`-Ox&C%Gn_AZ-49Nir=2oYX6 z`uVke@L5PVh)YsvAgFMZfKi{DuSgWnlAaag{RN6t6oLm6{4)H~4xg#Xfcq-e@ALk& z@UP4;uCe(Yjg4jaJZ4pu*+*?4#+XCi%sTrqaT*jNY7|WQ!oR;S8nt)cI27W$Sz!94 z01zoTW`C*P3E?1@6thPe(QpIue$A54gp#C7pmfwRj}GxIw$!!qQetn`nvuwIvMBQ; zfF8K-D~O4aJKmLbNRN1?AZsWY&rp?iy`LP^3KT0UcGNy=Z@7qVM(#5u#Du#w>a&Bs z@f#zU{wk&5n!YF%D11S9*CyaI8%^oX=vq$Ei9cL1&kvv9|8vZD;Mhs1&slm`$A%ED zvz6SQ8aty~`IYp2Xd~G$z%Jf4zwVPKkCtqObrnc2gHKj^jg&-NH|xdNK_;+2d4ZXw zN9j)`jcp7y65&6P@}LsD_OLSi(#GW#hC*qF5KpmeXuQDNS%ZYpuW<;JI<>P6ln!p@ z>KPAM>8^cX|2!n@tV=P)f2Euv?!}UM`^RJ~nTT@W>KC2{{}xXS{}WH{|3najkiEUj z7l;fUWDPCtzQ$?(f)6RvzW~Tqan$bXibe%dv}**BqY!d4J?`1iX`-iy8nPo$s4^mQ z5+@=3xuZAl#KoDF*%>bJ4UrEB2EE8m7sQn!r7Z-ggig`?yy`p~3;&NFukc$`_>?}a z?LMo2LV^n>m!fv^HKKRrDn|2|zk?~S6i|xOHt%K(*TGWkq3{~|9+(G3M-L=;U-YRa zp{kIXZ8P!koE;BN2A;nBx!={yg4v=-xGOMC#~MA07zfR)yZtSF_2W^pDLcXg->*WD zY7Sz5%<_k+lbS^`y)=vX|KaN!gEMQob|(`%nP6huwr$%^?%0^vwr$(CZQD*Jc5?E( zb-q9E`OfoWSJ$rUs$ILfSFg3Mb*-!Ozgaz^%7ZkX@=3km0G;?+e?FQT_l5A9vKr<> z_CoemDo@6YIyl57l*gnJ^7+8xLW5oEGzjLv2P8vj*Q%O1^KOfrsC6eHvk{+$BMLGu z%goP8UY?J7Lj=@jcI$4{m2Sw?1E%_0C7M$lj}w{E#hM4%3QX|;tH6>RJf-TI_1A0w z@KcTEFx(@uitbo?UMMqUaSgt=n`Bu*;$4@cbg9JIS})3#2T;B7S

Z?HZkSa`=MM?n)?|XcM)@e1qmzJ$_4K^?-``~Oi&38`2}sjmP?kK z$yT)K(UU3fJID@~3R;)fU%k%9*4f>oq`y>#t90$(y*sZTzWcW$H=Xv|%^u^?2*n)Csx;35O0v7Nab-REgxDZNf5`cI69k$` zx(&pP6zVxlK5Apn5hAhui}b)(IwZD}D?&)_{_yTL7QgTxL|_X!o@A`)P#!%t9al+# zLD(Rr+?HHJEOl545~m1)cwawqY>cf~9hu-L`crI^5p~-9Mgp9{U5V&dJSwolnl_CM zwAMM1Tl$D@>v?LN2PLe0IZrQL1M zcA%i@Lc)URretFJhtw7IaZXYC6#8slg|*HfUF2Z5{3R_tw)YQ94=dprT`SFAvHB+7 z)-Hd1yE8LB1S+4H7iy$5XruPxq6pc_V)+VO{seA8^`o5{T5s<8bJ`>I3&m%R4cm1S z`hoNk%_=KU2;+#$Y!x7L%|;!Nxbu~TKw?zSP(?H0_b8Qqj4EPrb@~IE`~^#~C%D9k zvJ=ERh`xLgUwvusQbo6S=I5T+?lITYsVyeCCwT9R>DwQa&$e(PxF<}RpLD9Vm2vV# zI#M%ksVNFG1U?;QR{Kx2sf>@y$7sop6SOnBC4sv8S0-`gEt0eHJ{`QSW(_06Uwg*~ zIw}1dZ9c=K$a$N?;j`s3>)AqC$`ld?bOs^^stmYmsWA$XEVhUtGlx&OyziN1~2 z)s5fD(d@gq7htIGX!GCxKT=8aAOHW&DAP=$MpZ)SpeEZhk83}K) z0(Uv)+&pE?|4)D2PX4r6gOGHDY}$8FSg$3eDb*nEVmkFQ#lFpcH~IPeatiH3nPTkP z*xDN7l}r2GM9jwSsl=*!547nRPCS0pb;uE#myTqV+=se>bU=#e)f2}wCp%f-cIrh`FHA$2`monVy?qvJ~o2B6I7IE28bCY4=c#^){*essLG zXUH50W&SWmi{RIG9G^p;PohSPtC}djjXSoC)kyA8`o+L}SjE{i?%;Vh=h;QC{s`T7 zLmmHCr8F}#^O8_~lR)^clv$mMe`e*{MW#Sxd`rDckCnFBo9sC*vw2)dA9Q3lUi*Fy zgDsLt`xt|7G=O6+ms=`_FpD4}37uvelFLc^?snyNUNxbdSj2+Mpv<67NR{(mdtSDNJ3gSD@>gX_7S5 zCD)JP5Hnv!llc-9fwG=4@?=%qu~(4j>YXtgz%gZ#+A9i^H!_R!MxWlFsH(ClP3dU} za&`m(cM0xebj&S170&KLU%39I+XVWOJ_1XpF^ip}3|y()Fn5P@$pP5rvtiEK6w&+w z7uqIxZUj$#qN|<_LFhE@@SAdBy8)xTu>>`xC>VYU@d}E)^sb9k0}YKr=B8-5M?3}d z7&LqQWQ`a&=ihhANxe3^YT>yj&72x#X4NXRTc#+sk;K z=VUp#I(YIRO`g7#;5))p=y=MQ54JWeS(A^$qt>Y#unGRT$0BG=rI(tr>YqSxNm+-x z6n;-y8B>#FnhZX#mhVOT30baJ{47E^j-I6EOp;am;FvTlYRR2_?CjCWY+ypoUD-2S zqnFH6FS+q$H$^7>>(nd^WE+?Zn#@HU3#t|&=JnEDgIU+;CgS+krs+Y8vMo6U zHVkPoReZ-Di3z!xdBu#aW1f{8sC)etjN90`2|Y@{2=Os`(XLL9+ z1$_PE$GgTQrVx`^sx=Y(_y-SvquMF5<`9C=vM52+e+-r=g?D z+E|97MyoaK5M^n1(mnWeBpgtMs8fXOu4Q$89C5q4@YY0H{N47VANA1}M2e zspor6LdndC=kEvxs3YrPGbc;`q}|zeg`f;t3-8na)dGdZ9&d(n{|%mNaHaKJOA~@8 zgP?nkzV-=ULb)L3r`p)vj4<702a5h~Y%byo4)lh?rtu1YXYOY+qyTwzs!59I zL}XLe=q$e<+Wm7tvB$n88#a9LzBkgHhfT<&i#%e*y|}@I z!N~_)vodngB7%CI2pJT*{GX|cI5y>ZBN)}mezK~fFv@$*L`84rb0)V=PvQ2KN}3lTpT@$>a=CP?kcC0S_^PZ#Vd9#CF4 zP&`6{Y!hd^qmL!zr#F~FB0yag-V;qrmW9Jnq~-l>Sg$b%%TpO}{Q+*Pd-@n2suVh_ zSYP->P@# z&gQ^f{?}m(u5B9xqo63pUvDsJDQJi5B~ak+J{tX8$oL!_{Dh zL@=XFzWb+83H3wPbTic+osVp&~UoW3SqK0#P6+BKbOzK65tz)-@AW#g}Ew+pE3@ zVbdJkJ}EM@-Ghxp_4a)|asEk* z5)mMI&EK~BI^aaTMRl)oPJRH^Ld{;1FC&#pS`gh;l3Y;DF*`pR%OSz8U@B@zJxPNX zwyP_&8GsQ7^eYyUO3FEE|9~I~X8;{WTN=DJW0$2OH=3-!KZG=X6TH?>URr(A0l@+d zj^B9G-ACel;yYGZc}G`w9sR$Mo{tzE7&%XKuW$|u7DM<6_z}L>I{o`(=!*1 z{5?1p3F^aBONr6Ws!6@G?XRxJxXt_6b}2%Bp=0Iv5ngnpU^P+?(?O0hKwAK z*|wAisG&8&Td1XY+6qI~-5&+4DE2p|Dj8@do;!40o)F)QuoeUY;*I&QZ0*4?u)$s`VTkNl1WG`}g@J_i zjjmv4L%g&>@U9_|l>8^CN}`@4<D2aMN&?XXD-HNnsVM`irjv$ z^YVNUx3r1{-o6waQfDp=OG^P+vd;qEvd{UUYc;gF0UwaeacXkw32He^qyoYHjZeFS zo(#C9#&NEdFRcFrj7Q{CJgbmDejNS!H%aF6?;|KJQn_*Ps3pkq9yE~G{0wIS*mo0XIEYH zzIiJ>rbmD;sGXt#jlx7AXSGGcjty)5z5lTGp|M#5DCl0q0|~pNQ%1dP!-1>_7^BA~ zwu+uumJmTCcd)r|Hc)uWm7S!+Dw4;E|5+bwPb4i17Ued>NklnnsG+A{T-&}0=sLM- zY;sA9v@YH>b9#c$Vg{j@+>UULBX=jtu~N^%Y#BB5)pB|$?0Mf7msMD<7eACoP1(XY zPO^h5Brvhn$%(0JSo3KFwEPV&dz8(P41o=mo7G~A*P6wLJ@-#|_A z7>k~4&lbqyP1!la!qmhFBfIfT?nIHQ0j2WlohXk^sZ`?8-vwEwV0~uu{RDE^0yfl$ znua{^`VTZ)-h#ch_6^e2{VPaE@o&55|3dx$z_b6gbqduXJ(Lz(zq&ZbJ6qA4Ac4RT zhJO4KBLN!t;h(eW(?cZJw^swf8lP@tWMZ8GD)zg)siA3!2EJYI(j>WI$=pK!mo!Ry z?q&YkTIbTTr<>=}+N8C_EAR0XQL2&O{nNAXb?33iwo8{M``rUHJgnk z8KgZzZLFf|(O6oeugsm<;5m~4N$2Jm5#dph*@TgXC2_k&d%TG0LPY=Fw)=gf(hy9QmY*D6jCAiq44 zo-k2C+?3*+Wu7xm1w*LEAl`Vsq(sYPUMw|MiXrW)92>rVOAse5Pmx^OSi{y%EwPAE zx|csvE{U3c{vA>@;>xcjdCW15pE31F3aoIBsz@OQRvi%_MMfgar2j3Ob`9e@gLQk# zlzznEHgr|Ols%f*a+B-0klD`czi@RWGPPpR1tE@GB|nwe`td1OwG#OjGlTH zfT#^r?%3Ocp^U0F8Kekck6-Vg2gWs|sD_DTJ%2TR<5H3a$}B4ZYpP=p)oAoHxr8I! z1SYJ~v-iP&mNm{ra7!KP^KVpkER>-HFvq*>eG4J#kz1|eu;=~u2|>}TE_5nv2=d!0 z3P~?@blSo^uumuEt{lBsGcx{_IXPO8s01+7DP^yt&>k;<5(NRrF|To2h7hTWBFQ_A z+;?Q$o5L|LlIB>PH(4j)j3`JIb1xA_C@HRFnPnlg{zGO|-RO7Xn}!*2U=Z2V?{5Al z9+iL+n^_T~6Uu{law`R&fFadSVi}da8G>|>D<{(#vi{OU;}1ZnfXy8=etC7)Ae<2S zAlI`&=HkNiHhT0|tQztSLNsRR6v8bmf&$6CI|7b8V4kyJ{=pG#h{1sVeC28&Ho%Fh zwo_FIS}ST-2OF6jNQ$(pjrq)P)@sie#tigN1zSclxJLb-O9V|trp^G8<1rpsj8@+$ z2y27iiM>H8kfd%AMlK|9C>Lkvfs9iSk>k2}tCFlqF~Z_>-uWVQDd$5{3sM%2$du9; z*ukNSo}~@w@DPF)_vS^VaZ)7Mk&8ijX2hNhKom$#PM%bzSA-s$ z0O!broj`!Nuk)Qcp3(>dL|5om#XMx2RUSDMDY9#1|+~fxwP}1I4iYy4j$CGx3jD&eKhf%z`Jn z7mD!y6`nVq%&Q#5yqG`|+e~1$Zkgu!O(~~pWSDTw2^va3u!DOMVRQ8ycq)sk&H%vb z;$a`3gp74~I@swI!ILOkzVK3G&SdTcVe~RzN<+z`u(BY=yuwez{#T3a_83)8>2!X?`^02zVjqx-fN+tW`zCqH^XG>#Ies$qxa!n4*FF0m zxgJlPPYl*q4ylX;DVu3G*I6T&JyWvs`A(*u0+62=+ylt2!u)6LJ=Qe1rA$OWcNCmH zLu7PwMDY#rYQA1!!ONNcz~I^uMvi6N&Lo4dD&HF?1Su5}COTZ-jwR)-zLq=6@bN}X zSP(-MY`TOJ@1O`bLPphMMSWm+YL{Ger>cA$KT~)DuTl+H)!2Lf`c+lZ0ipxd>KfKn zIv;;eEmz(_(nwW24a+>v{K}$)A?=tp+?>zAmfL{}@0r|1>iFQfJ5C*6dKdijK=j16 zQpl4gl93ttF5@d<9e2LoZ~cqkH)aFMgt(el_)#OG4R4Hnqm(@D*Uj>2ZuUCy)o-yy z_J|&S-@o5#2IMcL(}qWF3EL<4n(`cygenA)G%Ssi7k4w)LafelpV5FvS9uJES+(Ml z?rzZ={vYrB#mB-Hd#ID{KS5dKl-|Wh_~v+Lvq3|<@w^MD-RA{q!$gkUUNIvAaex5y z)jIGW{#U=#UWyku7FIAB=TES8>L%Y9*h2N`#Gghie+a?>$CRNth?ORq)!Tde24f5K zKh>cz5oLC;ry*tHIEQEL>8L=zsjG7+(~LUN5K1pT`_Z-4Z}k^m%&H%g3*^e(FDCC{ zBh~eqx%bY?qqu_2qa+9A+oS&yFw^3nLRsN#?FcZvt?*dZhRC_a%Jd{qou(p5AG_Q6 ziOJMu8D~kJ7xEkG(69$Dl3t1J592=Olom%;13uZvYDda08YwzqFlND-;YodmA!SL) z!AOSI=(uCnG#Yo&BgrH(muUemmhQW7?}IHfxI~T`44wuLGFOMdKreQO!a=Z-LkH{T z@h;`A_l2Pp>Xg#`Vo@-?WJn-0((RR4uKM6P2*^-qprHgQhMzSd32@ho>%fFMbp9Y$ zx-#!r8gEu;VZN(fDbP7he+Nu7^o3<+pT!<<>m;m z=FC$N)wx)asxb_KLs}Z^;x*hQM}wQGr((&=%+=#jW^j|Gjn$(qqXwt-o-|>kL!?=T zh0*?m<^>S*F}kPiq@)Cp+^fnKi2)%<-Tw4K3oHwmI-}h}Kc^+%1P!D8aWp!hB@-ZT zybHrRdeYlYulEj>Bk zEIi|PU0eGg&~kWQ{q)gw%~bFT0`Q%k5S|tt!JIZXVXX=>er!7R^w>zeQ%M-(C|eOQG>5i|}i3}X#?aqAg~b1t{-fqwKd(&CyA zmyy)et*E}+q_lEqgbClewiJ=u@bFX}LKe)5o26K9fS;R`!er~a?lUCKf60`4Zq7{2q$L?k?IrAdcDu+ z4A0QJBUiGx&$TBASI2ASM_Wj{?fjv=CORO3GZz;1X*AYY`anM zI`M6C%8OUFSc$tKjiFJ|V74Yj-lK&Epi7F^Gp*rLeDTokfW#o6sl33W^~4V|edbS1 zhx%1PTdnI!C96iYqSA=qu6;p&Dd%)Skjjw0fyl>3k@O?I@x5|>2_7G#_Yc2*1>=^# z|H43bJDx$SS2!vkaMG!;VRGMbY{eJhT%FR{(a+RXDbd4OT?DRoE(`NhiVI6MsUCsT z1gc^~Nv>i;cIm2~_SYOfFpkUvV)(iINXEep;i4>&8@N#|h+_;DgzLqh3I#lzhn>cN zjm;m6U{+JXR2Mi)=~WxM&t9~WShlyA$Pnu+VIW2#;0)4J*C!{1W|y1TP{Q;!tldR< zI7aoH&cMm*apW}~BabBT;`fQ1-9q|!?6nTzmhiIo6fGQlcP{pu)kJh- zUK&Ei9lArSO6ep_SN$Lt_01|Y#@Ksznl@f<+%ku1F|k#Gcwa`(^M<2%M3FAZVb99?Ez4d9O)rqM< zCbYsdZlSo{X#nKqiRA$}XG}1Tw@)D|jGKo1ITqmvE4;ovYH{NAk{h8*Ysh@=nZFiF zmDF`@4do#UDKKM*@wDbwoO@tPx4aExhPF_dvlR&dB5>)W=wG6Pil zq{eBzw%Ov!?D+%8&(uK`m7JV7pqNp-krMd>ECQypq&?p#_3wy){eW{(2q}ij{6bfmyE+-ZO z)G4OtI;ga9;EVyKF6v3kO1RdQV+!*>tV-ditH-=;`n|2T zu(vYR*BJSBsjzFl1Oy#DpL=|pfEY4NM;y5Yly__T*Eg^3Mb_()pHwn)mAsh!7Yz-Z zY`hBLDXS4F^{>x=oOphq|LMo;G!C(b2hS9A6lJqb+e$2af}7C>zW2p{m18@Bdd>iL zoEE$nFUnaz_6p${cMO|;(c1f9nm5G5R;p)m4dcC1?1YD=2Mi&20=4{nu>AV#R^d%A zsmm_RlT#`;g~an9mo#O1dYV)2{mgUWEqb*a@^Ok;ckj;uqy{%*YB^({d{^V)P9VvP zC^qbK&lq~}TWm^RF8d4zbo~bJuw zFV!!}b^4BlJ0>5S3Q>;u*BLC&G6Fa5V|~w&bRZ*-YU>df6%qAvK?%Qf+#=M-+JqLw&w*l4{v7XTstY4j z26z69U#SVzSbY9HBXyD;%P$#vVU7G*Yb-*fy)Qpx?;ed;-P24>-L6U+OAC9Jj63kg zlY`G2+5tg1szc#*9ga3%f9H9~!(^QjECetX-PlacTR+^g8L<#VRovPGvsT)ln3lr= zm5WO@!NDuw+d4MY;K4WJg3B|Sp|WdumpFJO>I2tz$72s4^uXljWseYSAd+vGfjutO z-x~Qlct+BnlI+Iun)fOklxPH?30i&j9R$6g5^f&(x7bIom|FLKq9CUE);w2G>}vye zxWvEaXhx8|~2j)({Rq>0J9}lzdE`yhQ(l$z! z;x%d%_u?^4vlES_>JaIjJBN|N8z5}@l1#PG_@{mh`oWXQOI41_kPG}R_pV+jd^PU) zEor^SHo`VMul*80-K$0mSk|FiI+tHdWt-hzt~S>6!2-!R&rdL_^gGGUzkPe zEZkUKU=EY(5Ex)zeTA4-{Bkbn!Gm?nuaI4jLE%X;zMZ7bwn4FXz(?az;9(Uv;38U6 zi)}rA3xAcD2&6BY<~Pj9Q1~4Dyjs&!$)hyHiiTI@%qXd~+>> zW}$_puSSJ^uWv$jtWakn}}@eX6_LGz|7M#$!3yjY ztS{>HmQ%-8u0@|ig{kzD&CNK~-dIK5e{;@uWOs8$r>J7^c2P~Pwx%QVX0e8~oXK0J zM4HCNK?%t6?v~#;eP#t@tM$@SXRt;(b&kU7uDzlzUuu;+LQ5g%=FqpJPGrX8HJ8CS zITK|(fjhs3@CR}H4@)EjL@J zV_HPexOQ!@k&kvsQG)n;7lZaUh>{87l4NS_=Y-O9Ul3CaKG8iy+xD=QXZSr57a-hb z7jz3Ts-NVsMI783OPEdlE|e&a2;l^h@e>oYMh5@=Lte-9A+20|?!9>Djl~{XkAo>0p9`n&nfWGdGAfT-mSYW z1cvG>GT9dRJdcm7M_AG9JX5AqTCdJ6MRqR3p?+FvMxp(oB-6MZ`lRzSAj%N(1#8@_ zDnIIo9Rtv12(Eo}k_#FILhaZQ`yRD^Vn5tm+IK@hZO>s=t5`@p1#k?Umz2y*R64CF zGM-v&*k}zZ%Xm<_?1=g~<*&3KAy;_^QfccIp~CS7NW24Tn|mSDxb%pvvi}S}(~`2# z3I|kD@||l@lAW06K2%*gHd4x9YKeXWpwU%!ozYcJ+KJeX!s6b94j!Qyy7>S!wb?{qaMa`rpbU1phn0EpF}L zsBdZc|Im#iRiQmJjZwb5#n;`_O{$Zu$I zMXqbfu0yVmt!!Y`Fzl}QV7HUSOPib#da4i@vM$0u2FEYytsvrbR#ui9lrMkZ(AVVJ zMVl^Wi_fSRsEXLA_#rdaG%r(@UCw#o7*yBN)%22b)VSNyng6Lxk|2;XK3Qb=C_<`F zN##8MLHz-s%&O6JE~@P1=iHpj8go@4sC7*AWe99tuf$f7?2~wC&RA^UjB*2`K!%$y zSDzMd7}!vvN|#wDuP%%nuGk8&>N)7eRxtqdMXHD1W%hP7tYW{W>^DJp`3WS>3}i+$ z_li?4AlEj`r=!SPiIc+NNUZ9NCrMv&G0BdQHBO&S7d48aB)LfGi@D%5CC1%)1hVcJ zB~=yNC}LBn(K?cHkPmAX$5^M7JSnNkcc!X!0kD&^F$cJmRP(SJ`9b7}b)o$rj=BZ- zC;BX3IG94%Qz&(V$)7O~v|!=jd-yU1(6wd1u;*$z4DDe6+BFLhz>+8?59?d2Ngxck zm92yR!jk@MP@>>9FtAY2L+Z|MaSp{MnL-;fm}W3~fg!9TRr3;S@ysLf@#<)keHDRO zsJI1tP`g3PNL`2(8hK3!4;r|E-ZQbU0e-9u{(@du`4wjGj|A!QB&9w~?OI1r}M? zw)6tvsknfPfmNijZ;3VZX&HM6=|&W zy6GIe3a?_(pRxdUc==do9?C&v7+6cgIoL4)Ka^bOG9`l;S|QmVzjv%)3^PDi@=-cp z=!R0bU<@_;#*D}e1m@0!%k=VPtyRAkWYW(VFl|eu0LteWH7eDB%P|uF7BQ-|D4`n; z)UpuY1)*s32UwW756>!OoAq#5GAtfrjo*^7YUv^(eiySE?!TQzKxzqXE@jM_bq3Zq zg#1orE*Zd5ZWEpDXW9$=NzuadNSO*NW)ZJ@IDuU`w}j_FRE4-QS*rD4mPVQPH(jGg z+-Ye?3%G%=DT5U1b+TnNHHv(nz-S?3!M4hXtEB@J4WK%%p zkv=Bb`1DHmgUdYo>3kwB(T>Ba#DKv%cLp2h4r8v}p=Np}wL!&PB5J-w4V4REM{kMD z${oSuAw9?*yo3?tNp~X5WF@B^P<6L0HtIW0H7^`R8~9zAXgREH`6H{ntGu$aQ;oNq zig;pB^@KMHNoJcEb0f1fz+!M6sy?hQjof-QoxJgBM`!k^T~cykcmi^s_@1B9 z)t1)Y-ZsV9iA&FDrVoF=L7U#4&inXk{3+Xm9A|R<=ErgxPW~Fq zqu-~x0dIBlR+5_}`IK^*5l3f5$&K@l?J{)_d_*459pvsF*e*#+2guls(cid4!N%DG zl3(2`az#5!^@HNRe3O4(_5nc+){q?ENQG2|uKW0U0$aJ5SQ6hg>G4OyN6os76y%u8qNNHi;}XnRNwpsfn^!6Qt(-4tE`uxaDZ`hQp#aFX373|F?vjEiSEkV>K)cTBG+UL#wDj0_ zM9$H&-86zP=9=5_Q7d3onkqKNr4PAlF<>U^^yYAAEso|Ak~p$3NNZ$~4&kE9Nj^As zQPoo!m*uZ;z1~;#g(?zFECJ$O2@EBy<;F)fnQxOKvH`MojG5T?7thbe%F@JyN^k1K zn3H*%Ymoim)ePf)xhl2%$T)vq3P=4ty%NK)@}po&7Q^~o3l))Zm4<75Y!fFihsXJc z9?vecovF^nYfJVg#W~R3T1*PK{+^YFgb*7}Up2U#)oNyzkfJ#$)PkFxrq_{Ai?0zk zWnjq_ixF~Hs7YS9Y6H&8&k0#2cAj~!Vv4{wCM zi2f1FjQf+F@=BOB)pD|T41a4AEz+8hnH<#_PT#H|Vwm7iQ0-Tw()WMN za0eI-{B2G{sZ7+L+^k@BA)G;mOFWE$O+2nS|DzPSGZ)ede(9%+8kqu4W^wTn!yZPN z7u!Qu0u}K5(0euRZ$7=kn9DZ+llruq5A_l) zOK~wof7_^8Yeh@Qd*=P!gM)lh`Z@7^M?k8Z?t$$vMAuBG>4p56Dt!R$p{)y>QG}it zGG;Ei```7ewXrbGo6Z=!AJNQ!GP8l13m7|FIQTFZTpIg#kpZkl1wj)s1eySXjAAWy zfl;;@{QQ;Qnb$@LY8_Z&7 z6+d98F?z2Zo)sS)z$YoL(zzF>Ey8u#S_%n7)XUX1Pu(>e8gEUU1S;J=EH(#`cWi1+ zoL$5TN+?#NM8=4E7HOk)bf5MXvEo%he5QcB%_5YQ$cu_j)Pd^@5hi}d%nG}x9xXtD-JMQxr;KkC=r_dS-t`lf zF&CS?Lk~>U^!)Y0LZqNVJq+*_#F7W~!UkvZfQhzvW`q;^X&iv~ zEDDGIQ&(S;#Hb(Ej4j+#D#sDS_uHehlY0kZsQpktc?;O z22W1b%wNcdfNza<1M2{*mAkM<{}@(w`VuQ<^lG|iYSuWBD#lYK9+jsdA+&#;Y@=zXLVr840Nq_t5))#7}2s9pK* zg42zd{EY|#sIVMDhg9>t6_Y#O>JoG<{GO&OzTa;iA9&&^6=5MT21f6$7o@nS=w;R) znkgu*7Y{UNPu7B9&B&~q+N@@+%&cO0N`TZ-qQ|@f@e0g2BI+9xO$}NzMOzEbSSJ@v z1uNp(S z-dioXc$5YyA6-My@gW~1GH($Q?;GCHfk{ej-{Q^{iTFs1^Sa67RNd5y{cjX1tG+$& zbGrUte{U1{^Z_qpzW$-V!pJz$dQZrL5i(1MKU`%^= z^)i;xua4w)evDBrFVm)Id5SbXMx2u7M5Df<2L4B`wy4-Y+Wec#b^QJO|J9xF{x#M8 zuLUer`%ZL^m3gy?U&dI+`kgNZ+?bl3H%8)&k84*-=aMfADh&@$xr&IS|4{3$v&K3q zZTn&f{N(#L6<-BZYNs4 zB*Kl*@_IhGXI^_8zfXT^XNmjJ@5E~H*wFf<&er?p7suz85)$-Hqz@C zGMFg1NKs;otNViu)r-u{SOLcqwqc7$poPvm(-^ag1m71}HL#cj5t4Hw(W?*fi4GSH z9962NZ>p^ECPqVc$N}phy>N8rQsWWm%%rc5B4XLATFEtffX&TM2%|8S2Lh_q; zCytXua84HBnSybW-}(j z3Zwv4CaK)jC!{oUvdsFRXK&Sx@t)yGm(h65$!WZ!-jL52no}NX6=E<=H!aZ74h_&> zZ+~c@k!@}Cs84l{u+)%kg4fq~pOeTK3S4)gX~FKJw4t9ba!Ai{_gkKQYQvafZIyKq zX|r4xgC(l%JgmW!tvR&yNt$6uME({M`uNIi7HFiPEQo_UMRkl~12&4c& z^se;dbZWKu7>dLMg`IZq%@b@ME?|@{&xEIZEU(omKNUY? z`JszxNghuO-VA;MrZKEC0|Gi0tz3c#M?aO?WGLy64LkG4T%|PBIt_?bl{C=L@9e;A zia!35TZI7<`R8hr06xF62*rNH5T3N0v^acg+;ENvrLYo|B4!c^eILcn#+lxDZR!%l zjL6!6h9zo)<5GrSPth7+R(rLAW?HF4uu$glo?w1U-y}CR@%v+wSAlsgIXn>e%bc{FE;j@R0AoNIWf#*@BSngZ)HmNqkB z)cs3yN%_PT4f*K+Y1wFl)be=1iq+bb1G-}b|72|gJ|lMt`tf~0Jk}zMbS0+M-Mq}R z>Bv}-W6J%}j#dIz`Z0}zD(DGKn`R;E8A`)$a6qDfr(c@iHKZcCVY_nJEDpcUddGH* z*ct2$&)RelhmV}@jGXY>3Y~vp;b*l9M+hO}&x`e~q*heO8GVkvvJTwyxFetJC8VnhjR`5*+qHEDUNp16g`~$TbdliLLd}AFf}U+Oda1JXwwseRFbj?DN96;VSX~z?JxJSuA^BF}262%Z0)nv<6teKK`F zfm9^HsblS~?Xrb1_~^=5=PD!QH$Y1hD_&qe1HTQnese8N#&C(|Q)CvtAu6{{0Q%ut8ESVdn&& z4y%nsCs!$(#9d{iVjXDR##3UyoMNeY@_W^%qyuZ^K3Oa4(^!tDXOUS?b2P)yRtJ8j zSX}@qGBj+gKf;|6Kb&rq`!}S*cSu-3&S>=pM$eEB{K>PP~I}N|uGE|`3U#{Q6v^kO4nIsaq zfPld}c|4tVPI4!=!ETCNW+LjcbmEoxm0RZ%ieV0`(nVlWKClZW5^>f&h79-~CF(%+ zv|KL(^xQ7$#a}&BSGr9zf{xJ(cCfq>UR*>^-Ou_pmknCt6Y--~!duL{k2D{yLMl__ z!KeMRRg&EsD2s|cmy?xgK&XcGIKeos`&UEVhBTw;mqy|8DlP1M7PYS2z{YmTJ;n!h znPe(Qu?c7+xZz!Tm1AnE8|;&tf7fW$2dArX7ck1Jd(S1+91YB8bjISRZ`UL*?vb{b zMp*!Xq7VaLc0Ogqj5qmop8NREQ{9_iC$;tviZlubGLy1jLlIFBxAymMr@SDLAcx+) z5YRkl$bW**X)W0JzWNcLx9>fTqJj00ipY6Ua?mUlsgQrVVgpmaheE;RgA5U_+WsPh z9+X|PU4zFyNxZ2?Q+V`Mo{xH~(m}OMRZa<&$nCl7o4x`^^|V4?aPz8#KwFm=8T6_} z8=P_4$_rD2a%7}}HT6VQ>ZGKW=QF7zI-2=6oBNZR$HVn|gq`>l$HZ`48lkM7%R$>MS& zghR`WZ9Xrd_6FaDedH6_aKVJhYev*2)UQ>!CRH3PQ_d9nXlO;c z9PeqiKD@aGz^|mvD-tV<{BjfA;)B+76!*+`$CZOJ=#)}>{?!9fAg(Xngbh||n=q*C zU0mGP`NxHn$uY#@)gN<0xr)%Ue80U{-`^FX1~Q@^>WbLraiB|c#4v$5HX)0z!oA#jOXPyWg! z8EC}SBmG7j3T&zCenPLYA{kN(3l62pu}91KOWZl? zg~>T4gQ%1y3AYa^J|>ba$7F5KlVx}_&*~me*q-SYLBCXZFU=U8mHQD4K!?;B61NoX z?VS41SS&jHyhmB~+bC=w0a06V``ZXCkC~}oM9pM{$hU~-s_elYPmT1L!%B`?*<+?( zFQ@TP%y+QL`_&Y0A3679pe5~iL=z)$b)k!oSbJRyw+K};SGAvvE=|<~*aiwJc?uE@2?7a1i9|3=^N%*9smt3ZIhjY>gIsr{Q2rX(NovZ7I1n^V{ z#~(1ze-%`C>fM`^hCV**9BA-04lNuu&3=reevNOMwmX(A{yh`^c8%0mjAKMj{Th05 zXrM(zILwyL-Pcdw^(=gj(ZLVMA95zlzmLa^skb8tQq%8SV&4vp?S>L3+P4^tp`$xA zr38jBw0ItR`VbO5vB1`<3d})}aorkIU1z3*ifYN&Lpp)}|}QJS60th_v-EEkAM zyOREuj!Ou|pVeZEWg;$Hf!x;xAmFu7gB^UR$=L0BuZ~thLC@#moJ(@@wejR|`t_K@ zuQ{XmpAWz%o&~2dk!SIGR$EmpZY)@+r^gvX26%)y>1u2bt~JUPTQzQu&_tB)|{19)&n$m5Fhw0A-8S1^%XpAD%`#a z_ModVxsM|x!m3N1vRt_XEL`O-+J3cMsM1l*dbjT&S0c@}Xxl3I&AeMNT97G3c6%3C zbrZS?2EAKcEq@@Pw?r%eh0YM6z0>&Qe#n+e9hEHK?fzig3v5S#O2IxVLu;a>~c~ZfHVbgLox%_tg)bsC8Rl35P=Jhl+Y=w6zb$ z;*uO%i^U z^mp_QggBILLF$AyjPD41Z0SFdbDj&z&xjq~X|OoM7bCuBfma1CEd!4RKGqPR)K)e}+7^JfFUI_fy63cMyq#&)Z*#w18{S zhC@f9U5k#2S2`d$-)cEoH-eAz{2Qh>YF1Xa)E$rWd52N-@{#lrw3lRqr)z?BGThgO z-Mn>X=RPHQ)#9h{3ciF)<>s{uf_&XdKb&kC!a373l2OCu&y8&n#P%$7YwAVJ_lD-G zX7tgMEV8}dY^mz`R6_0tQ5Eu@CdSOyaI63Vb*mR+rCzxgsjCXLSHOmzt0tA zGoA0Cp&l>rtO@^uQayrkoe#d2@}|?SlQl9W{fmcxY(0*y zHTZ6>FL;$8FEzbb;M(o%mBe-X?o<0+1dH?ZVjcf8)Kyqb07*a zLfP1blbt)=W)TN}4M#dUnt8Gdr4p$QRA<0W)JhWLK3-g82Q~2Drmx4J z;6m4re%igus136VL}MDI-V;WmSfs4guF_(7ifNl#M~Yx5HB!UF)>*-KDQl0U?u4UXV2I*qMhEfsxb%87fi+W;mW5{h?o8!52}VUs*Fpo#aSuXk(Ug z>r>xC#&2<9Uwmao@iJQ|{Vr__?eRT2NB$OcoXQ-jZ{t|?Uy{7q$nU-i|&-R6fHPWJDgHZ69iVbK#Ab@2@y zPD*Gj=hib?PWr8NGf;g$o5I!*n>94Z!IfqRm zLvM>Gx$Y*rEL3Z-+lS42=cnEfXR)h1z`h8a+I%E_ss%qXsrgIV%qv9d|KT>fV5=3e zw>P#ju>2naGc{=6!)9TeHq$S9Pk|>$UCEl}H}lE@;0(jbNT9TXUXyss>al>S4DuGi zVCy;Qt=a2`iu2;TvrIkh2NTvNV}0)qun~9y1yEQMdOf#V#3(e(C?+--8bCsJu={Q1z5qNJIk&yW>ZnVm;A=fL~29lvXQ*4j(SLau?P zi8LC7&**O!6B6=vfY%M;!p2L2tQ+w3Y!am{b?14E`h4kN$1L0XqT5=y=DW8GI_yi% zlIWsjmf0{l#|ei>)>&IM4>jXH)?>!fK?pfWIQn9gT9N(z&w3SvjlD|u*6T@oNQRF6 zU5Uo~SA}ml5f8mvxzX>BGL}c2#AT^6Lo-TM5XluWoqBRin$tiyRQK0wJ!Ro+7S!-K z=S95p-(#IDKOZsRd{l65N(Xae`wOa4Dg9?g|Jx97N-7OfHG(rN#k=yNGW0K$Tia5J zMMX1+!ulc1%8e*FNRV8jL|OSL-_9Nv6O=CH>Ty(W@sm`j=NFa1F3tT$?wM1}GZekB z6F_VLMCSd7(b9T%IqUMo$w9sM5wOA7l8xW<(1w0T=S}MB+9X5UT|+nemtm_;!|bxX z_bnOKN+F30ehJ$459k@=69yTz^_)-hNE4XMv$~_%vlH_y^`P1pLxYF6#_IZyteO`9wpuS> z#%Vyg5mMDt?}j!0}MoBX|9PS0#B zSVo6xLVjujMN57}IVc#A{VB*_yx;#mgM4~yT6wO;Qtm8MV6DX?u(JS~JFA~PvEl%9 z2XI}c>OzPoPn_IoyXa2v}BA(M+sWq=_~L0rZ_yR17I5c^m4;?2&KdCc)3lCs!M|0OzH@(PbG8T6w%N zKzR>%SLxL_C6~r3=xm9VG8<9yLHV6rJOjFHPaNdQHHflp><44l>&;)&7s)4lX%-er znWCv8eJJe1KAi_t1p%c4`bgxD2(1v)jm(gvQLp2K-=04oaIJu{F7SIu8&)gyw7x>+ zbzYF7KXg;T71w!-=C0DjcnF^JP$^o_N>*BAjtH!^HD6t1o?(O7IrmcodeQVDD<*+j zN)JdgB6v^iiJ1q`bZ(^WvN{v@sDqG$M9L`-UV!3q&sWZUnQ{&tAkpX(nZ_L#rMs}>p7l0fU5I5IzArncQi6TWjP#1B=QZ|Uqm-3{)YPn=XFqHW-~Fb z^!0CvIdelQbgcac9;By79%T`uvNhg9tS><pLzXePP=JZzcO@?5GRAdF4)sY*)YGP* zyioMa3=HRQz(v}+cqXc0%2*Q%CQi%e2~$a9r+X*u3J8w^Shg#%4I&?!$})y@ zzg8tQ6_-`|TBa_2v$D;Q(pFutj7@yos0W$&__9$|Yn3DFe*)k{g^|JIV4bqI@2%-4kpb_p? zQ4}qQcA>R6ihbxnVa{c;f7Y)VPV&mRY-*^qm~u3HB>8lf3P&&#GhQk8uIYYgwrugY zei>mp`YdC*R^Cxuv@d0V?$~d*=m-X?1Fqd9@*IM^wQ_^-nQEuc0!OqMr#TeT=8W`JbjjXc-Dh3NhnTj8e82yP;V_B<7LIejij+B{W1ViaJ_)+q?$BaLJpxt_4@&(?rWC3NC-_Z9Sg4JJWc( zX!Y34j67vCMHKB=JcJ1|#UI^D^mn(i=A5rf-iV7y4bR5HhC=I`rFPZv4F>q+h?l34 z4(?KYwZYHwkPG%kK7$A&M#=lpIn3Qo<>s6UFy|J$Zca-s(oM7??dkuKh?f5b2`m57 zJhs4BTcVVmwsswlX?#70uQb*k1Fi3q4+9`V+ikSk{L3K=-5HgN0JekQ=J~549Nd*+H%5+fi6aJuR=K zyD3xW{X$PL7&iR)=wumlTq2gY{LdrngAaPC;Qw_xLfVE0c0Z>y918TQpL!q@?`8{L!el18Qxiki3WZONF=eK$N3)p>36EW)I@Y z7QxbWW_9_7a*`VS&5~4-9!~&g8M+*U9{I2Bz`@TJ@E(YL$l+%<=?FyR#&e&v?Y@@G zqFF`J*v;l$&(A=s`na2>4ExKnxr`|OD+Xd-b4?6xl4mQ94xuk!-$l8*%+1zQU{)!= zTooUhjC0SNBh!&Ne}Q=1%`_r=Vu1c8RuE!|(g4BQGcd5AbpLbvKv_Z~Y`l!mr!sCc zDBupoc{W@U(6KWqW@xV_`;J0~+WDx|t^WeMri#=q0U5ZN7@@FAv<1!hP6!IYX z>UjbhaEv2Fk<6C0M^@J`lH#LgKJ(`?6z5=uH+ImggSQaZtvh52WTK+EBN~-op#EQKYW`$yBmq z4wgLTJPn3;mtbs0m0RO&+EG>?rb*ZECE0#eeSOFL!2YQ$w}cae>sun`<=}m!=go!v zO2jn<0tNh4E-4)ZA(ixh5nIUuXF-qYl>0I_1)K%EAw`D7~la$=gc@6g{iWF=>i_76?Mc zh#l9h7))<|EY=sK!E|54;c!b;Zp}HLd5*-w^6^whxB98v`*P>cj!Nfu1R%@bcp{cb zUZ24(fUXn3d&oc{6H%u(@4&_O?#HO(qd^YH=V`WJ=u*u6Zie8mE^r_Oz zDw`DaXeq4G#m@EK5+p40Xe!Lr!-jTQLCV3?R1|3#`%45h8#WSA!XoLDMS7=t!SluZ4H56;G z6C9D(B6>k^ur_DGfJ@Y-=3$5HkrI zO+3P>R@$6QZ#ATUI3$)xRBEL#5IKs}yhf&fK;ANA#Qj~G zdE|k|`puh$%dyE4R0$7dZd)M*#e7s%*PKPyrS;d%&S(d{_Ktq^!Hpi&bxZx`?9pEw z%sPjo&adHm95F7Z1{RdY#*a!&LcBZVRe{qhn8d{pOUJ{fOu`_kFg7ZVeRYZ(!ezNktT5{Ab z4BZI$vS0$vm3t9q`ECjDK;pmS{8ZTKs`Js~PYv2|=VkDv{Dtt)cLU@9%K6_KqtqfM zaE*e$f$Xm=;IAURNUXw8g%=?jzG2}10ZA5qXzAaJ@eh)yv5B=ETyVwC-a*CD;GgRJ z4J1~zMUey?4iVlS0zW|F-~0nenLiN3S0)l!T2}D%;<}Z9DzeVgcB+MSj;f$KY;uP%UR#f`0u*@6U@tk@jO3N?Fjq< z{cUUhjrr$rmo>qE?52zKe+>6iP5P_tcUfxsLSy{9*)shB(w`UUveNH`a`kr$VEF@} zKh&|lTD;4;m_H6C&)9#D`kRh;S(NTa=Ve^~xe_0~x$6h8Q@B_qu#ee=(lkI9@F6$0m=z@H=4&h%Q{htM>uHs(Sr@2ry`fgLA zKj8lVXdGPyy)2J%A${}Rm_a{){wHnlM?yGPQ7#KO{8*(_l0QZHuV};nO?c%h?qwSL z3wem|w*2tdxW5&PxC(Wd0QG_w|GPbw|0UFK`u$~U%!`QKcME;=Q@?*erh4_>FP~1n zAldwG9h$$u_$RFK6Uxo20GHqJzc}Rl-EwVz3h4n z;3~%DwD84i>)-8#&#y3k)3BG5cNaP3?t4q}F%yfv?*yEiC>sSo}$f>nh0QNZXH1N)-Q7kbk=2uL9OrF)nXrE@F1y%_8Yn 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 00000000..d4081da4 --- /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 00000000..ef07e016 --- /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 00000000..5eed7ee8 --- /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 00000000..e69de29b diff --git a/maps/settings.gradle.kts b/maps/settings.gradle.kts new file mode 100644 index 00000000..e548efc1 --- /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 00000000..586d8d25 --- /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 00000000..23ede9fa --- /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 00000000..3f537e79 --- /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 d762e23fad22f5f41dc0ef99976b533e398991e7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 03/81] =?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 00000000..9e16d43e --- /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 00000000..f291b8cf --- /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 00000000..663a2649 --- /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 00000000..fd964f58 --- /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 00000000..bdb60465 --- /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 00000000..54035040 --- /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 00000000..cee5ac00 --- /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 00000000..3a63482d --- /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 00000000..6b7631d0 --- /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 00000000..32ad8166 --- /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 00000000..cec7206f --- /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 00000000..bc7b3cb4 --- /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 00000000..8d0e8100 --- /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 00000000..55d857d2 --- /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 00000000..0ac446a7 --- /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 00000000..d56aaa0a --- /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 00000000..92207206 --- /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 00000000..6677f5ed --- /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 4dce5dc57498e5d08e998d2c2bdd9299336dfae2 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 04/81] =?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 00000000..f220a0c8 --- /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 00000000..45aeb0cf --- /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 00000000..115c90e7 --- /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 00000000..102382ef --- /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 00000000..d18e4793 --- /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 00000000..189c2582 --- /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 00000000..085059da --- /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 00000000..f82df51e --- /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 00000000..58df8039 --- /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 00000000..d194bdd2 --- /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 00000000..f9e55aaf --- /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 00000000..b5454255 --- /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 00000000..3388547b --- /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 00000000..57a5bb66 --- /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 00000000..e52d4145 --- /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 00000000..edfb1c2a --- /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 00000000..88c863c3 --- /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 00000000..c730dc5f --- /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 00000000..364a5d3e --- /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 00000000..263160db --- /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 00000000..5d9c2656 --- /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 00000000..d82a5b34 --- /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 00000000..67071c1b --- /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 00000000..f5ff0d6b --- /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 00000000..c19c0b0c --- /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 00000000..6952f562 --- /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 00000000..478368ca --- /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 00000000..0b3477e0 --- /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 00000000..a199f3ed --- /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 bed67ac9f942c406019d69f34c1ff60cd55240a2 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 05/81] =?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 00000000..5d7f8dee --- /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 00000000..3d2810a1 --- /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 00000000..68ff7d03 --- /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 00000000..8dd98ed9 --- /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 00000000..02e1f75a --- /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 00000000..1b35c20b --- /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 00000000..c047069f --- /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 00000000..f4c3a17b --- /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 00000000..303b36d5 --- /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 00000000..29dbb3e2 --- /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 00000000..d7661871 --- /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 00000000..e5c642d6 --- /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 00000000..43af268e --- /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 00000000..ab3132ae --- /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 00000000..f5b7b930 --- /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 00000000..973568c6 --- /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 00000000..99f785b8 --- /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 1f10ddd1741f5e73c262fe8efeb2248dedad694d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 06/81] =?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 00000000..613c2fec --- /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 00000000..88e53777 --- /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 00000000..3db00002 --- /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 00000000..877f132b --- /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 00000000..27207132 --- /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 00000000..a614af38 --- /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 00000000..b782446c --- /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 00000000..dd04ca25 --- /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 00000000..1fcd5e78 --- /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 00000000..7f2df157 --- /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 00000000..19948f39 --- /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 00000000..dc3025ae --- /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 00000000..839838ef --- /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 00000000..da90199a --- /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 00000000..75dc5140 --- /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 00000000..5248af15 --- /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 00000000..38f65730 --- /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 00000000..56f0391e --- /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 00000000..b4cd3f30 --- /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 00000000..2d3b1db8 --- /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 00000000..2664cadc --- /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 00000000..57873dfb --- /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 00000000..ed8d16d4 --- /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 00000000..738b8eeb --- /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 00000000..a7b0d408 --- /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 00000000..2d51c205 --- /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 00000000..4e1600d2 --- /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 00000000..b8345c81 --- /dev/null +++ b/maps/src/main/res/values/styles.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + From e23954645a8793ec5280044a6904d566f9f8211f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 4 Jun 2026 21:07:02 -0700 Subject: [PATCH 07/81] =?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 00000000..873ad754 --- /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 00000000..12070c20 --- /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 00000000..1286bf9a --- /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 00000000..28440a43 --- /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 00000000..54de2e5a --- /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 00000000..26ffdb70 --- /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 00000000..4d269a53 --- /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 00000000..f1aaa854 --- /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 00000000..8ece71b2 --- /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 00000000..edf644f0 --- /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 00000000..d876a510 --- /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 00000000..ccd9ffa6 --- /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 00000000..01317a29 --- /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 00000000..ca49106d --- /dev/null +++ b/maps/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + + - - - - - - - - - - diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt index 1b35c20b..f0513ee1 100644 --- a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt @@ -1,5 +1,6 @@ package org.appdevforall.maps.data +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -29,7 +30,7 @@ class ActiveRegionStoreBranchCoverageTest { File(project, "$mapsSubpath/${ActiveRegionStore.ACTIVE_REGION_FILE}") @Test - fun clearIsIdempotentWhenNoMarkersExist() { + fun clearIsIdempotentWhenNoMarkersExist() = runBlocking { // 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") @@ -38,7 +39,7 @@ class ActiveRegionStoreBranchCoverageTest { } @Test - fun readIgnoresMarkerThatIsADirectory() { + fun readIgnoresMarkerThatIsADirectory() = runBlocking { // 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") @@ -47,7 +48,7 @@ class ActiveRegionStoreBranchCoverageTest { } @Test - fun writeCreatesMapsSubpathOnDemand() { + fun writeCreatesMapsSubpathOnDemand() = runBlocking { // mapsSubpath doesn't exist yet → write mkdirs() it and lands a valid id. val project = tmp.newFolder("project") val mapsRoot = File(project, mapsSubpath) @@ -61,7 +62,7 @@ class ActiveRegionStoreBranchCoverageTest { } @Test - fun writeSwallowsFailureWhenMapsPathIsBlockedByAFile() { + fun writeSwallowsFailureWhenMapsPathIsBlockedByAFile() = runBlocking { // 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 @@ -80,7 +81,7 @@ class ActiveRegionStoreBranchCoverageTest { } @Test - fun readPrefersActiveOverLegacyWhenBothValid() { + fun readPrefersActiveOverLegacyWhenBothValid() = runBlocking { // 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). @@ -93,7 +94,7 @@ class ActiveRegionStoreBranchCoverageTest { } @Test - fun writeOverwritesPreviousActiveRegion() { + fun writeOverwritesPreviousActiveRegion() = runBlocking { // Two writes — the second wins (no stale value left behind). val project = tmp.newFolder("project") ActiveRegionStore.write(project, mapsSubpath, "first-region") diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt index c047069f..92e70449 100644 --- a/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt @@ -1,5 +1,6 @@ package org.appdevforall.maps.data +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -27,20 +28,20 @@ class ActiveRegionStoreTest { File(project, "$mapsSubpath/${ActiveRegionStore.REGION_MARKER_FILE}") @Test - fun `write then read round-trips the regionId`() { + fun `write then read round-trips the regionId`() = runBlocking { 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`() { + fun `read returns null when no marker exists`() = runBlocking { val project = tmp.newFolder("project") assertNull(ActiveRegionStore.read(project, mapsSubpath)) } @Test - fun `read prefers active-txt over the legacy marker`() { + fun `read prefers active-txt over the legacy marker`() = runBlocking { val project = tmp.newFolder("project") activeFile(project).apply { parentFile!!.mkdirs(); writeText("current-region") } legacyFile(project).writeText("old-region") @@ -48,14 +49,14 @@ class ActiveRegionStoreTest { } @Test - fun `read falls back to the legacy region-id-txt marker`() { + fun `read falls back to the legacy region-id-txt marker`() = runBlocking { 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`() { + fun `clear removes both active-txt and the legacy marker`() = runBlocking { val project = tmp.newFolder("project") activeFile(project).apply { parentFile!!.mkdirs(); writeText("region-a") } legacyFile(project).writeText("region-b") @@ -66,21 +67,21 @@ class ActiveRegionStoreTest { } @Test - fun `write refuses an invalid regionId`() { + fun `write refuses an invalid regionId`() = runBlocking { 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`() { + fun `read ignores a blank marker`() = runBlocking { val project = tmp.newFolder("project") activeFile(project).apply { parentFile!!.mkdirs(); writeText(" ") } assertNull(ActiveRegionStore.read(project, mapsSubpath)) } @Test - fun `read ignores an oversized marker`() { + fun `read ignores an oversized marker`() = runBlocking { 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)) } @@ -88,7 +89,7 @@ class ActiveRegionStoreTest { } @Test - fun `read ignores a marker whose content is not a valid regionId`() { + fun `read ignores a marker whose content is not a valid regionId`() = runBlocking { 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/DownloadCompleteMessageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt new file mode 100644 index 00000000..ccf84ca8 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt @@ -0,0 +1,58 @@ +package org.appdevforall.maps.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DownloadCompleteMessage] — the pure + * `FirstRegionAutoActivator.Result` → post-download Snackbar mapping extracted + * from `RegionManagerFragment.onDownloadComplete`. Asserts the *exact* + * user-facing strings: they're the contract the android-qa walks verify. + */ +class DownloadCompleteMessageTest { + + @Test + fun `Activated - confirms the apply, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.Activated(regionId = "lagos", displayName = "Lagos"), + regionId = "lagos", + ) + assertEquals("Region downloaded and applied to project: Lagos", spec.message) + assertFalse(spec.showApplyAction) + } + + @Test + fun `NoOpAlreadyActive - active region untouched, offers the Apply action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.NoOpAlreadyActive, + regionId = "lagos", + ) + assertEquals( + "Region downloaded: lagos. Project's active region is unchanged.", + spec.message, + ) + assertTrue("the only branch with a one-tap Apply escape hatch", spec.showApplyAction) + } + + @Test + fun `NoOpRegionNotFound - surfaces the cache miss, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.NoOpRegionNotFound, + regionId = "lagos", + ) + assertEquals("Region downloaded but couldn't be located in cache: lagos", spec.message) + assertFalse(spec.showApplyAction) + } + + @Test + fun `ApplyFailed - surfaces the reason, no action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.ApplyFailed(regionId = "lagos", reason = "disk full"), + regionId = "lagos", + ) + assertEquals("Region downloaded; apply failed: disk full", spec.message) + assertFalse(spec.showApplyAction) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt new file mode 100644 index 00000000..9d5203e6 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt @@ -0,0 +1,153 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Coverage top-up for [FirstRegionAutoActivator] beyond the policy suites: + * + * - the injected `applyRegionToProject` / `writeActiveRegion` callbacks + * GENUINELY suspend (production passes real I/O suspend lambdas; the other + * suites' fakes return synchronously, so the coroutine resume arms of + * `maybeAutoActivate` never run there); + * - an EXISTING but UNREADABLE `active.txt` — `readActiveSentinel`'s + * `runCatching { readText() }` failure arm → treated as "no active region", + * so the new download still auto-activates instead of wedging the project. + */ +class FirstRegionAutoActivatorSuspendCoverageTest { + + 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-susp-project").toFile() + cacheRoot = Files.createTempDirectory("maps-fra-susp-cache").toFile() + } + + @After + fun tearDown() { + // Restore readability so deleteRecursively can't be tripped up by the + // unreadable-sentinel test's chmod. + projectDir.walkBottomUp().forEach { it.setReadable(true) } + 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) + }.toString() + ) + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(16)) + } + + @Test + fun activates_when_apply_and_write_callbacks_genuinely_suspend() = runBlocking { + // Production wires real suspend I/O into both callbacks; the activator + // must come back through its resume path with the same Activated result + // and the same ordering guarantee (write only after apply succeeded). + seedCachedRegion("timbuktu", "Timbuktu, Mali") + val order = mutableListOf() + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "timbuktu", + applyRegionToProject = { info, _ -> + delay(5) // force a real suspension point + order += "apply:${info.regionId}" + true + }, + writeActiveRegion = { dir, id -> + delay(5) // force a real suspension point + order += "write:$id" + val mapsRoot = File(dir, mapsSubpath).apply { mkdirs() } + File(mapsRoot, "active.txt").writeText(id) + }, + ) + + assertTrue("Expected Activated, got $result", result is FirstRegionAutoActivator.Result.Activated) + assertEquals("Timbuktu, Mali", (result as FirstRegionAutoActivator.Result.Activated).displayName) + assertEquals( + "apply must complete before the active sentinel is written", + listOf("apply:timbuktu", "write:timbuktu"), + order, + ) + assertEquals( + "timbuktu", + File(projectDir, "$mapsSubpath/active.txt").readText().trim(), + ) + } + + @Test + fun apply_failure_still_propagates_through_a_suspending_callback() = runBlocking { + // The ApplyFailed arm reached via a genuinely-suspending apply lambda — + // the resume path must carry the `false` back into the !applied branch. + seedCachedRegion("mopti") + var writeCalled = false + + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "mopti", + applyRegionToProject = { _, _ -> + delay(5) + false + }, + writeActiveRegion = { _, _ -> writeCalled = true }, + ) + + assertTrue("Expected ApplyFailed, got $result", result is FirstRegionAutoActivator.Result.ApplyFailed) + assertEquals("mopti", (result as FirstRegionAutoActivator.Result.ApplyFailed).regionId) + assertTrue("active sentinel must not be written on apply failure", !writeCalled) + } + + @Test + fun activates_when_existing_sentinel_is_unreadable() = runBlocking { + // active.txt exists, is a small regular file, but can't be read → + // readActiveSentinel's runCatching yields null → no active region → + // the new download auto-activates (rather than NoOpAlreadyActive + // on a sentinel whose content we couldn't even see). + seedCachedRegion("zinder", "Zinder") + val mapsRoot = File(projectDir, mapsSubpath).apply { mkdirs() } + val sentinel = File(mapsRoot, "active.txt").apply { writeText("zinder") } + // If the platform/user can't actually revoke read (e.g. running as + // root), the scenario can't be produced — skip rather than fake-pass. + assumeTrue(sentinel.setReadable(false, false) && !sentinel.canRead()) + + val applied = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "zinder", + applyRegionToProject = { info, _ -> applied += info.regionId; true }, + writeActiveRegion = { dir, id -> + val root = File(dir, mapsSubpath).apply { mkdirs() } + val f = File(root, "active.txt") + f.setReadable(true) + f.writeText(id) + }, + ) + + assertTrue("Unreadable sentinel must not block activation; got $result", + result is FirstRegionAutoActivator.Result.Activated) + assertEquals(listOf("zinder"), applied) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt new file mode 100644 index 00000000..eba955d0 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt @@ -0,0 +1,88 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.ServiceRegistry +import kotlinx.coroutines.runBlocking +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 + +/** + * [ProjectRegionCoordinator.applyRegionToProject]'s error-routing lambda: when + * the underlying install fails with an exception (on a plain JVM the cache root + * can't resolve — `Environment` external storage is null), the failure must be + * routed to the LIVE plugin logger resolved through `pluginContextProvider`. + * `ProjectRegionCoordinatorTest` covers the provider-null side; this covers the + * provider-present side with a recording logger. + */ +class ProjectRegionCoordinatorLoggerRoutingTest { + + @get:Rule + val tmp = TemporaryFolder() + + private class RecordingLogger : PluginLogger { + val errors = mutableListOf>() + override val pluginId: String = "org.appdevforall.maps" + override fun debug(message: String) {} + override fun debug(message: String, throwable: Throwable) {} + override fun info(message: String) {} + override fun info(message: String, throwable: Throwable) {} + override fun warn(message: String) {} + override fun warn(message: String, throwable: Throwable) {} + override fun error(message: String) { errors += message to null } + override fun error(message: String, throwable: Throwable) { errors += message to throwable } + } + + private fun contextWithLogger(recording: PluginLogger): PluginContext = object : PluginContext { + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException("not used") + override val services: ServiceRegistry = object : ServiceRegistry { + override fun register(serviceClass: Class, implementation: T) = + throw UnsupportedOperationException("not used") + override fun get(serviceClass: Class): T? = null + override fun getAll(serviceClass: Class): List = emptyList() + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: PluginLogger = recording + override val resources: com.itsaky.androidide.plugins.ResourceManager + get() = throw UnsupportedOperationException("not used") + override val pluginId: String = "org.appdevforall.maps" + } + + @Test + fun install_failure_is_routed_to_the_plugin_logger() = runBlocking { + val logger = RecordingLogger() + val coordinator = ProjectRegionCoordinator( + pluginContextProvider = { contextWithLogger(logger) }, + ) + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("regions", "failregion") + val info = RegionInfo( + regionId = "failregion", + displayName = "Fail Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = regionDir, + ) + + val ok = coordinator.applyRegionToProject(info, project) + + assertFalse("JVM install (no external storage) must fail", ok) + assertEquals("exactly one error routed to the plugin logger", 1, logger.errors.size) + val (message, throwable) = logger.errors.single() + assertTrue( + "message must name the failing operation + region, got: $message", + message.contains("applyRegionToProject failed") && message.contains("failregion"), + ) + assertTrue("the causing exception must travel with the log call", throwable != null) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt new file mode 100644 index 00000000..57a9a58f --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt @@ -0,0 +1,182 @@ +package org.appdevforall.maps.data + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.services.IdeProjectService +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Focused tests for [ProjectRegionCoordinator] — the project/active-region + * facade extracted from `RegionManagerFragment`. Temp-dir based like + * [ActiveRegionStoreTest]; the underlying stores have their own exhaustive + * suites, so this covers the coordinator's routing + its one policy rule + * (apply-then-activate pairing). + */ +class ProjectRegionCoordinatorTest { + + @get:Rule + val tmp = TemporaryFolder() + + /** No live PluginContext (standalone JVM) — every host lookup degrades to null. */ + private val coordinator = ProjectRegionCoordinator(pluginContextProvider = { null }) + + private fun activeFile(project: File) = File( + project, + "${ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH}/${ActiveRegionStore.ACTIVE_REGION_FILE}", + ) + + @Test + fun `currentProjectRoot is null without a plugin context`() = runBlocking { + assertNull(coordinator.currentProjectRoot()) + } + + @Test + fun `no-arg readActiveRegionId is null when no project is open`() = runBlocking { + assertNull(coordinator.readActiveRegionId()) + } + + @Test + fun `write then read round-trips through the coordinator`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "addis-ababa") + assertEquals("addis-ababa", coordinator.readActiveRegionId(project)) + } + + @Test + fun `write lands the sentinel under the default maps subpath`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "lagos") + assertEquals("lagos", activeFile(project).readText().trim()) + } + + @Test + fun `clear removes the active marker`() = runBlocking { + val project = tmp.newFolder("project") + coordinator.writeActiveRegionId(project, "addis-ababa") + coordinator.clearActiveRegionId(project) + assertNull(coordinator.readActiveRegionId(project)) + } + + @Test + fun `custom mapsSubpath is honored`() = runBlocking { + val custom = ProjectRegionCoordinator( + pluginContextProvider = { null }, + mapsSubpath = "custom/maps", + ) + val project = tmp.newFolder("project") + custom.writeActiveRegionId(project, "lagos") + assertEquals( + "lagos", + File(project, "custom/maps/${ActiveRegionStore.ACTIVE_REGION_FILE}").readText().trim(), + ) + assertEquals("lagos", custom.readActiveRegionId(project)) + } + + @Test + fun `applyAndActivate does not write active-txt when the apply fails`() = runBlocking { + // A bare temp dir is not a Maps project, so the region copy fails — + // the pairing rule says active.txt must then stay absent (otherwise the + // project would reference a region whose tiles were never copied). + val project = tmp.newFolder("project") + val regionDir = tmp.newFolder("regions", "test-region") + val info = RegionInfo( + regionId = "test-region", + displayName = "Test Region", + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = regionDir, + ) + + val ok = coordinator.applyAndActivate(info, project) + + assertFalse("apply into a non-Maps project must fail", ok) + assertFalse("failed apply must NOT mark the region active", activeFile(project).exists()) + assertNull(coordinator.readActiveRegionId(project)) + } + + // ----- currentProjectRoot host-chain arms (hand-rolled interface fakes; + // this module deliberately has no mocking library) ----- + + /** PluginContext whose ServiceRegistry serves exactly [service] (or nothing). */ + private fun contextWith(service: IdeProjectService?): PluginContext = object : PluginContext { + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException("not used") + override val services: ServiceRegistry = object : ServiceRegistry { + override fun register(serviceClass: Class, implementation: T) = + throw UnsupportedOperationException("not used") + @Suppress("UNCHECKED_CAST") + override fun get(serviceClass: Class): T? = + if (serviceClass == IdeProjectService::class.java) service as T? else null + override fun getAll(serviceClass: Class): List = emptyList() + override fun unregister(serviceClass: Class<*>) = + throw UnsupportedOperationException("not used") + } + override val eventBus: Any get() = throw UnsupportedOperationException("not used") + override val logger: com.itsaky.androidide.plugins.PluginLogger + get() = throw UnsupportedOperationException("not used") + override val resources: com.itsaky.androidide.plugins.ResourceManager + get() = throw UnsupportedOperationException("not used") + override val pluginId: String = "org.appdevforall.maps" + } + + private fun projectServiceReturning(project: IProject?): IdeProjectService = + object : IdeProjectService { + override fun getCurrentProject(): IProject? = project + override fun getAllProjects(): List = emptyList() + override fun getProjectByPath(path: File): IProject? = null + override fun getModuleContext(name: String): com.itsaky.androidide.plugins.services.ModuleContext = + throw UnsupportedOperationException("not used") + } + + private fun projectAt(root: File): IProject = object : IProject { + override val name: String = root.name + override val rootDir: File = root + override val type: com.itsaky.androidide.plugins.extensions.ProjectType + get() = throw UnsupportedOperationException("not used") + override fun getModules() = throw UnsupportedOperationException("not used") + override fun getBuildFiles() = throw UnsupportedOperationException("not used") + } + + @Test + fun `currentProjectRoot is null when the host has no project service`() = runBlocking { + val c = ProjectRegionCoordinator(pluginContextProvider = { contextWith(service = null) }) + assertNull(c.currentProjectRoot()) + } + + @Test + fun `currentProjectRoot is null when no project is open`() = runBlocking { + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(project = null)) }, + ) + assertNull(c.currentProjectRoot()) + } + + @Test + fun `currentProjectRoot returns the open project's root`() = runBlocking { + val root = tmp.newFolder("openproj") + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(projectAt(root))) }, + ) + assertEquals(root, c.currentProjectRoot()) + } + + @Test + fun `no-arg readActiveRegionId resolves through the open project`() = runBlocking { + val root = tmp.newFolder("openproj2") + val c = ProjectRegionCoordinator( + pluginContextProvider = { contextWith(projectServiceReturning(projectAt(root))) }, + ) + c.writeActiveRegionId(root, "lagos-v3") + assertEquals("lagos-v3", c.readActiveRegionId()) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt new file mode 100644 index 00000000..f3c9b3b4 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt @@ -0,0 +1,112 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +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.Before +import org.junit.Test + +/** + * Exercises the HEAD-probe I/O path of [ReachabilityProbe.isReachable] against a + * local [MockWebServer] — the arms `ReachabilityProbeTest` (pure URL logic only) + * leaves untouched: + * + * - 2xx HEAD response → reachable (the `responseCode in 200..399` true arm) + * - non-2xx (404 / 500) → not reachable (the range-check false arm) + * - malformed probe URL → `runCatching` failure arm → `getOrDefault(false)` + * - LAN source with a blank host → the `probeUrlFor(...) ?: return false` + * null arm inside `isReachable` itself + * - explicit `http://` scheme host honoured by [ReachabilityProbe.buildLanProbeUrl] + * + * Deliberately NOT covered: the `withTimeoutOrNull` timeout arm — PROBE_TIMEOUT_MS + * is a private 6 s constant with no injection point, and a 6-second sleep per run + * isn't worth one defensive branch (production code untouchable per task rules). + */ +class ReachabilityProbeHeadCoverageTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun isReachable_true_on_200_head_response() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertTrue(probe.isReachable(SourceKind.INTERNET, host = "")) + // The probe really issued a HEAD against the configured path. + val recorded = server.takeRequest() + assertEquals("HEAD", recorded.method) + assertEquals("/maps/2/", recorded.path) + } + + @Test + fun isReachable_false_on_404() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertFalse("404 must read as unreachable", probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun isReachable_false_on_500() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val probe = ReachabilityProbe(internetProbeUrl = server.url("/maps/2/").toString()) + + assertFalse("5xx must read as unreachable", probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun isReachable_probes_the_lan_inventory_url_for_iiab() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200)) + val probe = ReachabilityProbe(internetProbeUrl = "https://unused.example.org/") + // Point the "LAN host" at the mock server (host:port form, no scheme). + val host = "${server.hostName}:${server.port}" + + assertTrue(probe.isReachable(SourceKind.IIAB_LAN, host = host)) + assertEquals("/maps/extracts.json", server.takeRequest().path) + } + + @Test + fun isReachable_false_for_lan_with_blank_host() = runBlocking { + // probeUrlFor returns null → isReachable's elvis short-circuits to false + // without any network I/O (no request must reach the server). + val probe = ReachabilityProbe(internetProbeUrl = server.url("/").toString()) + + assertFalse(probe.isReachable(SourceKind.IIAB_LAN, host = " ")) + assertEquals("no request may be issued for a blank LAN host", 0, server.requestCount) + } + + @Test + fun isReachable_false_when_probe_url_is_malformed() = runBlocking { + // URL(...) throws MalformedURLException inside the runCatching → + // getOrDefault(false). Never touches the network. + val probe = ReachabilityProbe(internetProbeUrl = "::definitely not a url::") + + assertFalse(probe.isReachable(SourceKind.INTERNET, host = "")) + } + + @Test + fun buildLanProbeUrl_honours_explicit_plain_http_scheme() { + // ReachabilityProbeTest covers https + schemeless; this is the http:// arm. + val probe = ReachabilityProbe() + assertEquals( + "http://box.lan/maps/extracts.json", + probe.buildLanProbeUrl("http://box.lan"), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt new file mode 100644 index 00000000..9483b8e0 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.data + +import org.appdevforall.maps.domain.SourceKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Tests the pure URL logic extracted from SourcePickerFragment. (The HEAD probe itself is I/O.) */ +class ReachabilityProbeTest { + + private val probe = ReachabilityProbe(internetProbeUrl = "https://example.org/maps/") + + @Test + fun `LAN url appends the extracts inventory path`() { + assertEquals("http://192.168.1.5/maps/extracts.json", probe.buildLanProbeUrl("192.168.1.5")) + } + + @Test + fun `LAN url honours an explicit scheme and trims a trailing slash`() { + assertEquals("https://box.local/maps/extracts.json", probe.buildLanProbeUrl("https://box.local/")) + } + + @Test + fun `probeUrlFor returns the internet url for INTERNET`() { + assertEquals("https://example.org/maps/", probe.probeUrlFor(SourceKind.INTERNET, host = "")) + } + + @Test + fun `probeUrlFor is null for LAN with a blank host`() { + assertNull(probe.probeUrlFor(SourceKind.IIAB_LAN, host = " ")) + } + + @Test + fun `probeUrlFor builds the LAN url when a host is present`() { + assertEquals( + "http://10.0.0.2/maps/extracts.json", + probe.probeUrlFor(SourceKind.IIAB_LAN, host = "10.0.0.2"), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt index 29dbb3e2..1190d0bd 100644 --- a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt @@ -1,5 +1,6 @@ package org.appdevforall.maps.data +import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject import org.junit.After @@ -38,7 +39,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun sourceObjectWithBlankHostCoalescesToNull() { + fun sourceObjectWithBlankHostCoalescesToNull() = runBlocking { // 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() } @@ -58,7 +59,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun sourceObjectMissingKindFallsBackToUnknown() { + fun sourceObjectMissingKindFallsBackToUnknown() = runBlocking { // Object-form source with no "kind" → optString default "unknown". val dir = File(tempRoot, "no-kind").apply { mkdirs() } val meta = JSONObject().apply { @@ -73,7 +74,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun downloadedAtAndLastUsedAtSurfacedWhenPositive() { + fun downloadedAtAndLastUsedAtSurfacedWhenPositive() = runBlocking { // The `takeIf { it > 0L }` TRUE branch — both timestamps are non-null. val dir = File(tempRoot, "with-times").apply { mkdirs() } val meta = JSONObject().apply { @@ -89,7 +90,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun zeroTimestampsCoalesceToNull() { + fun zeroTimestampsCoalesceToNull() = runBlocking { // The `takeIf { it > 0L }` FALSE branch — explicit 0 reads as null. val dir = File(tempRoot, "zero-times").apply { mkdirs() } val meta = JSONObject().apply { @@ -105,7 +106,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun layersDropsBlankEntries() { + fun layersDropsBlankEntries() = runBlocking { // 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() } @@ -120,7 +121,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun bboxWithNonNumericEntriesIsDroppedViaRunCatching() { + fun bboxWithNonNumericEntriesIsDroppedViaRunCatching() = runBlocking { // 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 @@ -138,7 +139,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun bboxFullyParsedWhenAllFourPresentAndNumeric() { + fun bboxFullyParsedWhenAllFourPresentAndNumeric() = runBlocking { // The success arm: has("bbox") true, length == 4, all getDouble succeed. val dir = File(tempRoot, "good-bbox").apply { mkdirs() } val meta = JSONObject().apply { @@ -154,7 +155,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun layersPresentButEmptyArrayYieldsEmptyList() { + fun layersPresentButEmptyArrayYieldsEmptyList() = runBlocking { // 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() } @@ -168,7 +169,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun deleteFromRootRefusesInvalidIdViaRegex() { + fun deleteFromRootRefusesInvalidIdViaRegex() = runBlocking { // Hits the `!isValidRegionId` true branch in deleteFromRoot (the regex // guard) — returns false before any canonicalisation. assertFalse(RegionCache.deleteFromRoot(tempRoot, "Bad Id!")) @@ -176,7 +177,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun deleteFromRootSucceedsForValidExistingRegion() { + fun deleteFromRootSucceedsForValidExistingRegion() = runBlocking { // The happy path: valid id, path contained, target exists → deleted. val dir = File(tempRoot, "goodregion").apply { mkdirs() } File(dir, "meta.json").writeText("{}") @@ -186,7 +187,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun listFromRootSkipsDirWithoutMeta_butKeepsValidSibling() { + fun listFromRootSkipsDirWithoutMeta_butKeepsValidSibling() = runBlocking { // No-meta dir is skipped (warn path); the valid sibling still lists. File(tempRoot, "ghost").apply { mkdirs() } val ok = File(tempRoot, "real").apply { mkdirs() } @@ -200,14 +201,14 @@ class RegionCacheBranchCoverageTest { } @Test - fun listFromRootReturnsEmptyWhenRootHasNoSubdirs() { + fun listFromRootReturnsEmptyWhenRootHasNoSubdirs() = runBlocking { // 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() { + fun listReturnsEmptyWhenExternalStorageUnavailableOnJvm() = runBlocking { // The no-arg list() routes through rootDir(), which calls // Environment.getExternalStorageDirectory() — unavailable on the JVM, so // it throws and the `runCatching { rootDir() }.getOrNull() ?: return @@ -217,7 +218,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun deleteReturnsFalseWhenExternalStorageUnavailableOnJvm() { + fun deleteReturnsFalseWhenExternalStorageUnavailableOnJvm() = runBlocking { // 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). @@ -225,7 +226,7 @@ class RegionCacheBranchCoverageTest { } @Test - fun readDirReturnsNullWhenReadThrowsOnNonDirectory() { + fun readDirReturnsNullWhenReadThrowsOnNonDirectory() = runBlocking { // 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. diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt new file mode 100644 index 00000000..35039117 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt @@ -0,0 +1,99 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +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.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Edge-arm top-up for [RegionCache] beyond `RegionCacheTest` / + * `RegionCacheBranchCoverageTest`: + * + * - `listFromRoot` where `root.listFiles` returns NULL (root missing, or a + * plain file) — distinct from the empty-array case already covered; + * - `meta.json` present but ZERO bytes — the `metaFile.length() > 0` false arm + * with `exists()` true: parses as an empty JSON object, all defaults from + * the directory; + * - the suspend entry points invoked from an ALREADY-IO-dispatched coroutine + * (production calls arrive both ways: cold from the main scope, and nested + * inside other `Dispatchers.IO` work like FirstRegionAutoActivator). + */ +class RegionCacheEdgeCoverageTest { + + private lateinit var tempRoot: File + + @Before + fun setUp() { + tempRoot = Files.createTempDirectory("maps-region-cache-edge").toFile() + } + + @After + fun tearDown() { + tempRoot.deleteRecursively() + } + + @Test + fun listFromRootIsEmptyWhenRootDoesNotExist() = runBlocking { + val missing = File(tempRoot, "never-created") + assertFalse("precondition", missing.exists()) + assertTrue(RegionCache.listFromRoot(missing).isEmpty()) + } + + @Test + fun listFromRootIsEmptyWhenRootIsAPlainFile() = runBlocking { + // listFiles on a non-directory returns null — must degrade to an empty + // list, never throw (the Region Manager tab still has to render). + val fileRoot = File(tempRoot, "not-a-dir.txt").apply { writeText("x") } + assertTrue(RegionCache.listFromRoot(fileRoot).isEmpty()) + } + + @Test + fun zeroByteMetaJsonFallsBackToDirectoryDefaults() = runBlocking { + // exists() true but length() == 0 → parsed as an empty meta object: + // regionId/displayName fall back to the dir name and the size column + // reflects the on-disk artifact sum. + val dir = File(tempRoot, "empty-meta").apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText("") + File(dir, RegionCache.FILE_TILES_PMTILES).writeBytes(ByteArray(64)) + + val listed = RegionCache.listFromRoot(tempRoot) + assertEquals(1, listed.size) + assertEquals("empty-meta", listed[0].regionId) + assertEquals("empty-meta", listed[0].displayName) + assertEquals(64L, listed[0].sizeBytes) + } + + @Test + fun suspendEntryPointsWorkWhenAlreadyOnIoDispatcher() = runBlocking { + // Nested-in-IO invocation (withContext(IO) inside IO returns + // undispatched) must behave identically to the cold-dispatch path. + val dir = File(tempRoot, "nestedregion").apply { mkdirs() } + File(dir, RegionCache.FILE_META_JSON).writeText( + JSONObject().apply { + put("regionId", "nestedregion") + put("displayName", "Nested Region") + }.toString() + ) + + withContext(Dispatchers.IO) { + val listed = RegionCache.listFromRoot(tempRoot) + assertEquals(listOf("nestedregion"), listed.map { it.regionId }) + + val info = RegionCache.readDir(dir) + assertNotNull(info) + assertEquals("Nested Region", info!!.displayName) + + assertTrue(RegionCache.deleteFromRoot(tempRoot, "nestedregion")) + assertFalse(dir.exists()) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt index ef7bb7ab10074452ebbd9a8aa460ad361c70b879..9916ba39f09b224bc6c0c7d62c4aae274da328cc 100644 GIT binary patch delta 468 zcmZ2i^P+A;6r)^reo0PdUWHzAeo=mDNoHPZv0hPWo>NYKa&~53`sN_UDa^dK3dllF zSuQf732kM2z>X#~g>MSFDs=&7Ry0+bqH5@>{KOVA^Ee<`IQhNgB@}_p2c?Xd_)#3C zP(7K?TzRv&Y$A4vIr4iLvFVuXZyY_D&z5KM730v&7D~0u*c5HPsVcyP=I|Hl8t4w^ o*Nj0Asw|yJ=t95s4X|6{Z(NJ6_Ow|hy1mAhO4zkm+H7S608_K20RR91 delta 141 zcmaD+x2|SG6yxRw#vbO)QmhvkH~(O}&(2sqnNdf0^H08d#?5U4Ost!yiK;SgJ}I_< zd2*@LrOm<8Mog3I)l@cbmraB)ycG5@f()) + private lateinit var server: MockWebServer + + // Short-timeout client so a hung/failed slicer walk resolves within test time. + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .build() + + private val bboxA = Bbox(south = 5.0, west = -5.0, north = 6.0, east = -4.0) + private val bboxB = Bbox(south = 40.0, west = 10.0, north = 41.0, east = 11.0) + private val cachedTiles = listOf( + TileEntry(z = 6, x = 31, y = 30, tileId = 1L, byteOffset = 0L, byteLength = 128L), + ) + + @Before + fun setUp() { + SliceEstimateCache.clear() + server = MockWebServer() + } + + @After + fun tearDown() { + scope.cancel() + server.shutdown() + SliceEstimateCache.clear() + } + + /** Poll (bounded) until [predicate] holds on the state list, else fail-fast at timeout. */ + private fun awaitStates(timeoutMs: Long = 5_000, predicate: (List) -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (predicate(states.toList())) return + Thread.sleep(50) + } + assertTrue("timed out waiting; states=$states", predicate(states.toList())) + } + + @Test + fun `cache hit reports Done synchronously without Calculating`() { + server.start() + val url = server.url("/tiles.pmtiles").toString() + SliceEstimateCache.put(url, bboxA, 6, 10, cachedTiles) + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(url, bboxA, 6, 10) + + // Synchronous contract: Done is already there when estimate() returns. + assertEquals(listOf(RegionSizeEstimator.State.Done(cachedTiles)), states.toList()) + assertEquals(0, server.requestCount) // no network on a cache hit + } + + @Test + fun `cache hit cancels stale in-flight walk so its failure never lands after Done`() { + // Request #1's walk hangs on a server that never responds, then dies with + // a client timeout — WITHOUT the cancel-on-cache-hit + latest-guard it + // would report Failed ~2s after request #2's Done. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + server.start() + val hangingUrl = server.url("/world.pmtiles").toString() + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(hangingUrl, bboxA, 6, 10) + awaitStates { it.contains(RegionSizeEstimator.State.Calculating) } + // Let the debounce elapse so the walk is genuinely in flight on the socket. + Thread.sleep(500) + + // Request #2 hits the cache → Done, and must cancel request #1's walk. + val cachedUrl = "http://cache.invalid/other.pmtiles" + SliceEstimateCache.put(cachedUrl, bboxB, 6, 10, cachedTiles) + estimator.estimate(cachedUrl, bboxB, 6, 10) + awaitStates { it.lastOrNull() == RegionSizeEstimator.State.Done(cachedTiles) } + + // Give the stale walk's failure window time to fire if it were going to + // (client timeouts are 2 s; wait past them), then assert it never did. + Thread.sleep(3_000) + val trailing = states.toList().dropWhile { it != RegionSizeEstimator.State.Done(cachedTiles) } + assertEquals( + "no state may arrive after the fresh Done; states=$states", + listOf(RegionSizeEstimator.State.Done(cachedTiles)), + trailing, + ) + } + + @Test + fun `failure for the current request still reports Failed`() { + // Server answers 500 immediately — the walk for the CURRENT request + // fails, and the staleness guard must let that Failed through. + server.enqueue(MockResponse().setResponseCode(500)) + server.start() + val url = server.url("/broken.pmtiles").toString() + + val estimator = RegionSizeEstimator(scope, client, states::add) + estimator.estimate(url, bboxA, 6, 10) + + awaitStates { it.lastOrNull() == RegionSizeEstimator.State.Failed } + assertEquals( + listOf(RegionSizeEstimator.State.Calculating, RegionSizeEstimator.State.Failed), + states.toList(), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt new file mode 100644 index 00000000..9a21b8eb --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt @@ -0,0 +1,29 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * [AutoShrinkBbox.computeShrunkBbox]'s defensive collapse arm: a viewport whose + * longitudes both clamp to the same ±180 wall (wrapped/out-of-range longitudes + * from the map SDK) must yield null — never an inverted or zero-width [Bbox] + * (whose init would throw in the caller's face). + */ +class AutoShrinkBboxCollapseTest { + + @Test + fun `viewport beyond the antimeridian wall collapses to null`() { + // Both view longitudes are past -180; after coerceIn both become -180, + // so newE <= newW — the collapse guard must return null. Latitudes are + // healthy, so this isolates the longitude arm of the final check. + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = Bbox(south = 0.0, west = 0.0, north = 10.0, east = 10.0), + viewN = 5.0, + viewS = -5.0, + viewE = -185.0, + viewW = -190.0, + margin = 0.0, + ) + assertNull("collapsed longitude span must yield null, not an invalid Bbox", result) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt new file mode 100644 index 00000000..ac7d1e67 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt @@ -0,0 +1,69 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Exhaustive tests for the estimate-line decision logic extracted from BboxPickerFragment. */ +class EstimateDisplayTest { + + private val allowance = 5L * 1024 * 1024 // 5 MB non-vector headroom + private val cap = 1024L * 1024 * 1024 // 1 GB + + private fun compute(realBytes: Long?, failed: Boolean = false, hasSource: Boolean = true) = + EstimateDisplay.compute(realBytes, failed, hasSource, allowance, cap) + + @Test + fun `no source - blank line, Next disabled`() { + val s = compute(realBytes = 123L, hasSource = false) + assertEquals("", s.text) + assertFalse(s.isError) + assertFalse(s.overBudget) + assertFalse(s.nextEnabled) + } + + @Test + fun `calculating - allows Next`() { + val s = compute(realBytes = null, failed = false) + assertEquals("Calculating download size…", s.text) + assertFalse(s.isError) + assertTrue(s.nextEnabled) + } + + @Test + fun `failed - error text, Next disabled`() { + val s = compute(realBytes = null, failed = true) + assertEquals("Couldn't calculate size — check connection", s.text) + assertTrue(s.isError) + assertFalse(s.nextEnabled) + } + + @Test + fun `under cap - shows MB and enables Next`() { + val tenMb = 10L * 1024 * 1024 + val s = compute(realBytes = tenMb) + assertEquals("10.0 MB download size", s.text) + assertFalse(s.isError) + assertFalse(s.overBudget) + assertTrue(s.nextEnabled) + } + + @Test + fun `over cap including allowance - over-budget, Next disabled`() { + // 1020 MB vector + 5 MB allowance = 1025 MB > 1 GB (1024 MB) + val s = compute(realBytes = 1020L * 1024 * 1024) + assertTrue(s.text.contains("over 1 GB limit")) + assertTrue(s.isError) + assertTrue(s.overBudget) + assertFalse(s.nextEnabled) + } + + @Test + fun `the non-vector allowance is what pushes a near-cap region over`() { + // 1022 MB vector alone is under 1 GB, but +5 MB allowance crosses it. + val s = compute(realBytes = 1022L * 1024 * 1024) + assertTrue(s.overBudget) + assertFalse(s.nextEnabled) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt new file mode 100644 index 00000000..a36234ed --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Verifies the pure style-JSON construction extracted from BboxPickerFragment. */ +class IiabStyleBuilderTest { + + @Test + fun `null tiles url yields a background-only style`() { + val style = IiabStyleBuilder.buildStyle(pmtilesHttpUrl = null, fontsRoot = null) + assertTrue(style.contains("\"background\"")) + assertFalse(style.contains("openmaptiles")) + assertFalse(style.contains("pmtiles://")) + } + + @Test + fun `tiles url is wrapped in the pmtiles scheme and used as the vector source`() { + val style = IiabStyleBuilder.buildStyle( + pmtilesHttpUrl = "http://127.0.0.1:8080/tiles.pmtiles", + fontsRoot = "/data/fonts", + ) + assertTrue(style.contains("\"url\": \"pmtiles://http://127.0.0.1:8080/tiles.pmtiles\"")) + assertTrue(style.contains("\"openmaptiles\"")) + assertTrue(style.contains("place-city-label")) + } + + @Test + fun `fontsRoot present uses a file glyphs url`() { + val style = IiabStyleBuilder.buildStyle("http://h/t.pmtiles", "/data/fonts") + assertTrue(style.contains("\"glyphs\": \"file:///data/fonts/{fontstack}/{range}.pbf\"")) + } + + @Test + fun `fontsRoot null falls back to an unfulfillable asset glyphs url`() { + val style = IiabStyleBuilder.buildStyle("http://h/t.pmtiles", fontsRoot = null) + assertTrue(style.contains("\"glyphs\": \"asset://fonts/{fontstack}/{range}.pbf\"")) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt new file mode 100644 index 00000000..5266b1cf --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt @@ -0,0 +1,190 @@ +package org.appdevforall.maps.domain + +import org.appdevforall.maps.domain.RegionWizardStateMachine.Event +import org.appdevforall.maps.domain.RegionWizardStateMachine.Step +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [RegionWizardStateMachine] — the wizard's step sequencing + + * prefill-propagation rules extracted from `RegionManagerFragment`. Covers the + * full new-download sequence, the redownload-prefill flow, the Step-3 Back + * hop, the zoom-range threading, and the state reset on every list-returning + * event. + */ +class RegionWizardStateMachineTest { + + private val machine = RegionWizardStateMachine() + + private val bbox = Bbox(6.0, 3.0, 7.0, 4.0) + private val estimate = TileEstimate( + tileCount = 100L, + sizeBytesEstimate = 5_000_000L, + zoomMin = 6, + zoomMax = 12, + ) + + // ----- New-download happy path ----- + + @Test + fun `new download walks source - bbox - save - download - list`() { + val source = machine.onEvent(Event.NewDownloadRequested) + assertEquals(Step.SourcePicker(null, null), source) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, "iiab.switnet.org"), + ) as Step.BboxPicker + assertNull(picker.prefillRegionId) + assertNull(picker.prefillRegionName) + assertNull("fresh wizard must not carry a stale bbox", picker.prefillBbox) + assertEquals(SourceKind.INTERNET, picker.sourceKind) + assertEquals("iiab.switnet.org", picker.sourceHost) + + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = null, prefillRegionName = null), + ) as Step.SaveStep + assertSame(bbox, save.bbox) + assertSame(estimate, save.estimate) + assertEquals(SourceKind.INTERNET, save.sourceKind) + assertNull(save.prefillRegionId) + + val download = machine.onEvent( + Event.SaveConfirmed(displayName = "Lagos", regionId = "lagos"), + ) as Step.DownloadProgress + assertEquals("lagos", download.regionId) + assertEquals("Lagos", download.displayName) + assertSame(bbox, download.bbox) + assertEquals(SourceKind.INTERNET, download.sourceKind) + // The picker's auto-capped zoom range is threaded to the downloader. + assertEquals(6, download.zoomMin) + assertEquals(12, download.zoomMax) + + assertEquals(Step.RegionList, machine.onEvent(Event.DownloadDone)) + } + + @Test + fun `save without an estimate falls back to the downloader default zoom range`() { + machine.onEvent(Event.NewDownloadRequested) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + machine.onEvent(Event.BboxPicked(bbox, estimate = null, prefillRegionId = null, prefillRegionName = null)) + val download = machine.onEvent( + Event.SaveConfirmed("Lagos", "lagos"), + ) as Step.DownloadProgress + assertEquals(6, download.zoomMin) + assertEquals(14, download.zoomMax) + } + + // ----- Step 3 Back → Step 2 ----- + + @Test + fun `save back re-opens the bbox picker with the picked bbox as prefill`() { + machine.onEvent(Event.NewDownloadRequested) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, "iiab.switnet.org")) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + val picker = machine.onEvent(Event.SaveBackRequested) as Step.BboxPicker + assertSame("Back must re-open on the same selection", bbox, picker.prefillBbox) + assertEquals(SourceKind.INTERNET, picker.sourceKind) + assertEquals("iiab.switnet.org", picker.sourceHost) + } + + // ----- Redownload (Refresh) prefill flow ----- + + @Test + fun `redownload prefills id, name and bounds through the whole wizard`() { + val source = machine.onEvent( + Event.RedownloadRequested( + regionId = "lagos", + displayName = "Lagos", + bbox = doubleArrayOf(6.0, 3.0, 7.0, 4.0), + ), + ) as Step.SourcePicker + assertEquals("lagos", source.prefillRegionId) + assertEquals("Lagos", source.prefillRegionName) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertEquals("lagos", picker.prefillRegionId) + assertEquals("Lagos", picker.prefillRegionName) + val prefill = picker.prefillBbox + assertTrue("saved bounds must prefill the picker", prefill != null) + assertEquals(6.0, prefill!!.south, 0.0) + assertEquals(3.0, prefill.west, 0.0) + assertEquals(7.0, prefill.north, 0.0) + assertEquals(4.0, prefill.east, 0.0) + + // Step 2 returning null prefill fields must NOT wipe the redownload ids. + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = null, prefillRegionName = null), + ) as Step.SaveStep + assertEquals("lagos", save.prefillRegionId) + assertEquals("Lagos", save.prefillRegionName) + } + + @Test + fun `bbox picker can override the prefill ids when it forwards non-null ones`() { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", null)) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + val save = machine.onEvent( + Event.BboxPicked(bbox, estimate, prefillRegionId = "lagos-v2", prefillRegionName = "Lagos v2"), + ) as Step.SaveStep + assertEquals("lagos-v2", save.prefillRegionId) + assertEquals("Lagos v2", save.prefillRegionName) + } + + @Test + fun `redownload drops a malformed bounds tuple instead of crashing`() { + // Wrong size. + machine.onEvent(Event.RedownloadRequested("a", "A", doubleArrayOf(1.0, 2.0, 3.0))) + var picker = machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) as Step.BboxPicker + assertNull(picker.prefillBbox) + machine.onEvent(Event.Exit) + + // Inverted bounds (south > north) — Bbox's init{} would throw. + machine.onEvent(Event.RedownloadRequested("b", "B", doubleArrayOf(7.0, 3.0, 6.0, 4.0))) + picker = machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) as Step.BboxPicker + assertNull(picker.prefillBbox) + } + + // ----- Reset on every list-returning event ----- + + @Test + fun `exit resets all wizard state`() { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", doubleArrayOf(6.0, 3.0, 7.0, 4.0))) + machine.onEvent(Event.SourceConfirmed(SourceKind.IIAB_LAN, "iiab.local")) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + assertEquals(Step.RegionList, machine.onEvent(Event.Exit)) + + // A later wizard run must not inherit anything from the abandoned one. + assertEquals(Step.SourcePicker(null, null), machine.onEvent(Event.NewDownloadRequested)) + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertNull(picker.prefillBbox) + assertNull(picker.prefillRegionId) + assertNull(picker.prefillRegionName) + } + + @Test + fun `download done, failed and cancelled all return to the list and reset`() { + for (terminal in listOf(Event.DownloadDone, Event.DownloadFailed, Event.DownloadCancelled)) { + machine.onEvent(Event.RedownloadRequested("lagos", "Lagos", null)) + machine.onEvent(Event.SourceConfirmed(SourceKind.INTERNET, null)) + machine.onEvent(Event.BboxPicked(bbox, estimate, null, null)) + + assertEquals(Step.RegionList, machine.onEvent(terminal)) + + val picker = machine.onEvent( + Event.SourceConfirmed(SourceKind.INTERNET, null), + ) as Step.BboxPicker + assertNull("$terminal must reset the bbox", picker.prefillBbox) + assertNull("$terminal must reset the prefill id", picker.prefillRegionId) + machine.onEvent(Event.Exit) + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt new file mode 100644 index 00000000..d0be7e68 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt @@ -0,0 +1,30 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ZoomFitTest { + + @Test + fun `a smaller bbox fits at a higher zoom than a larger one`() { + val small = Bbox.aroundPoint(0.0, 0.0, 10.0) + val large = Bbox.aroundPoint(0.0, 0.0, 1000.0) + assertTrue(ZoomFit.fitZoom(small, 1080, 0.5) > ZoomFit.fitZoom(large, 1080, 0.5)) + } + + @Test + fun `zoom is clamped to 0_16`() { + val tiny = Bbox.aroundPoint(0.0, 0.0, 0.001) + val huge = Bbox.aroundPoint(0.0, 0.0, 20000.0) + assertTrue(ZoomFit.fitZoom(tiny, 1080, 0.5) <= 16.0) + assertTrue(ZoomFit.fitZoom(huge, 1080, 0.5) >= 0.0) + } + + @Test + fun `non-positive map width falls back to 1080 (no crash, finite zoom)`() { + val z = ZoomFit.fitZoom(Bbox.aroundPoint(0.0, 0.0, 100.0), mapWidthPx = 0, screenFraction = 0.5) + assertTrue(z.isFinite()) + assertEquals(ZoomFit.fitZoom(Bbox.aroundPoint(0.0, 0.0, 100.0), 1080, 0.5), z, 1e-9) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt new file mode 100644 index 00000000..3a7ed06e --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt @@ -0,0 +1,47 @@ +package org.appdevforall.maps.domain + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the OSM-wiki zoom-name mapping. Every arm of the `when` is asserted at + * its boundaries so a shifted range (e.g. `in 3..5` becoming `in 3..6`) fails + * a test instead of silently mislabeling the bbox-dims line. + */ +class ZoomLabelTest { + + @Test + fun `every range maps to its OSM label at both boundaries`() { + val expected = listOf( + 0 to "world", 2 to "world", + 3 to "country", 5 to "country", + 6 to "country / state", 7 to "country / state", + 8 to "region", + 9 to "metro area", 10 to "metro area", + 11 to "city", + 12 to "town", + 13 to "village", + 14 to "streets", + 15 to "small roads", 16 to "small roads", + 17 to "buildings", 22 to "buildings", + ) + for ((z, label) in expected) { + assertEquals("zoom $z", label, ZoomLabel.forZoom(z)) + } + } + + @Test + fun `out-of-range zooms fall through to buildings`() { + // Negative zoom can't occur from ZoomCap, but the mapping's else-arm is + // the documented fallback either way. + assertEquals("buildings", ZoomLabel.forZoom(99)) + } + + @Test + fun `negative zoom hits the world arm`() { + // `in 0..2` excludes negatives; they land in else. Pin the actual + // behavior so a future "clamp negatives to world" change is a + // deliberate, test-visible decision. + assertEquals("buildings", ZoomLabel.forZoom(-1)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt new file mode 100644 index 00000000..c07e0098 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt @@ -0,0 +1,37 @@ +package org.appdevforall.maps.slicer + +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Negative-argument arms of [Hilbert]'s precondition checks. + * `HilbertCoverageTest` covers the too-LARGE side (z=27, x/y = n); these are + * the too-SMALL side of the same `in 0..max` range checks. + */ +class HilbertNegativeArgsTest { + + @Test + fun zxyToTileId_rejects_negative_zoom() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(-1, 0, 0) + } + assertTrue(ex.message!!.contains("out of range")) + } + + @Test + fun zxyToTileId_rejects_negative_x() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(1, -1, 0) + } + assertTrue(ex.message!!.contains("out of range")) + } + + @Test + fun zxyToTileId_rejects_negative_y() { + val ex = assertThrows(IllegalArgumentException::class.java) { + Hilbert.zxyToTileId(1, 0, -1) + } + assertTrue(ex.message!!.contains("out of range")) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt new file mode 100644 index 00000000..8c506b4d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt @@ -0,0 +1,66 @@ +package org.appdevforall.maps.slicer + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [HttpRangeFetcher] size-guard arms `RangeFetcherCoverageTest` leaves open: + * + * - a 206 whose body is SHORTER than the requested range (truncated/buggy + * server) → the size `require` must reject it instead of returning a short + * array that would corrupt the sliced archive downstream; + * - a 200-OK fallback whose full body can't satisfy the requested range + * (range end past EOF) → the bounds `require` must reject instead of + * slicing out of bounds. + */ +class HttpRangeFetcherSizeGuardTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + HttpRangeByteCache.clear() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + HttpRangeByteCache.clear() + } + + private fun body(n: Int): Buffer = Buffer().write(ByteArray(n) { it.toByte() }) + + @Test + fun rejects_206_with_truncated_body() { + // Ask for 10 bytes; server 206s with only 5. Must throw, not hand back a + // short buffer (a silent short read would corrupt header/dir parsing). + server.enqueue(MockResponse().setResponseCode(206).setBody(body(5))) + val fetcher = HttpRangeFetcher(server.url("/a.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { + fetcher.readRange(0L, 10) + } + assertTrue(ex.message!!.contains("expected 10")) + } + + @Test + fun rejects_200_fallback_when_range_extends_past_eof() { + // Server ignores Range and 200s the whole 8-byte file; the requested range + // [4, 14) runs past EOF → the fallback bounds require must reject. + server.enqueue(MockResponse().setResponseCode(200).setBody(body(8))) + val fetcher = HttpRangeFetcher(server.url("/b.pmtiles").toString(), RangeFetcher.defaultClient()) + + val ex = assertThrows(IllegalArgumentException::class.java) { + fetcher.readRange(4L, 10) + } + assertTrue(ex.message!!.contains("can't satisfy range")) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt index 67071c1b..0841fb97 100644 --- a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt @@ -232,7 +232,7 @@ class PmtilesRegionSlicerCoverageTest { 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( + val ranges = TileMath.tileIdRangesForBbox( Bbox(-85.0, -180.0, 85.0, 180.0), 0 ) assertEquals(1, ranges.size) @@ -243,7 +243,7 @@ class PmtilesRegionSlicerCoverageTest { 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( + val ranges = TileMath.tileIdRangesForBbox( Bbox(-85.0, -180.0, 85.0, 180.0), 2 ) assertTrue("z2 full-world produces at least one range", ranges.isNotEmpty()) @@ -258,7 +258,7 @@ class PmtilesRegionSlicerCoverageTest { 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( + val ranges = TileMath.tileIdRangesForBbox( Bbox(-80.0, -180.0, -70.0, -179.0), 3 ) assertTrue("west-edge bbox yields ranges", ranges.isNotEmpty()) diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt new file mode 100644 index 00000000..720a9de4 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt @@ -0,0 +1,86 @@ +package org.appdevforall.maps.slicer + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * `downloadAndSlice` must stamp the sliced header's bbox with the min/max + * coverage of the tiles actually written — the `tiles.minOf/maxOf` folds over + * lon/lat. Feeds a hand-built tile list whose x/y sequence is deliberately + * NON-monotonic (middle → low → high) so every fold takes both its + * "new extreme" and "keep current" arms, and asserts the resulting header + * bbox equals the union of the tiles' slippy bounds. + * + * Same offline fixture technique as [PmtilesRegionSlicerCoverageTest]: the + * entries alias a real blob of the bundled Natural Earth archive so the chunk + * fetch reads genuine bytes. + */ +class PmtilesRegionSlicerHeaderBboxTest { + + 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 sourceHeader(): PmtilesHeader { + val headerBytes = bundledNe.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) } + return PmtilesHeader.parse(ByteBuffer.wrap(headerBytes).order(ByteOrder.LITTLE_ENDIAN)) + } + + @Test + fun sliced_header_bbox_is_union_of_tile_bounds_regardless_of_tile_order() = runBlocking { + assumeTrue(bundledNe.exists()) + val src = "file://" + bundledNe.absolutePath + // NE's real tile-0 blob (same alias trick as the run-extend test). + val blobOffset = 1215L + val blobLength = 30122L + // z=2 tiles in mid → min → max order for BOTH axes: (1,1), (0,0), (2,2). + val tiles = listOf( + TileEntry(z = 2, x = 1, y = 1, tileId = 9L, byteOffset = blobOffset, byteLength = blobLength), + TileEntry(z = 2, x = 0, y = 0, tileId = 11L, byteOffset = blobOffset, byteLength = blobLength), + TileEntry(z = 2, x = 2, y = 2, tileId = 13L, byteOffset = blobOffset, byteLength = blobLength), + ) + + // Deliberately a NOT-yet-existing target (unlike createTempFile, which + // pre-creates it): first-ever slice into a fresh path must work without + // the delete-old-target step. + val outDir = java.nio.file.Files.createTempDirectory("bbox-order-").toFile() + val tmp = File(outDir, "sliced.pmtiles").also { tempFiles += it } + org.junit.Assert.assertFalse("precondition: target must not pre-exist", tmp.exists()) + 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 = { _, _ -> }, + ).getOrThrow() + + val sliced = PmtilesHeader.parse( + ByteBuffer.wrap(tmp.inputStream().use { it.readNBytes(PmtilesV3.HEADER_BYTES) }) + .order(ByteOrder.LITTLE_ENDIAN) + ) + // Union of covered tiles at z=2: x 0..2 → lon [-180, 90); y 0..2 → + // lat (-66.51, 85.05]. E7 storage → compare at 1e-3 tolerance. + assertEquals(TileMath.tileToLon(0, 2), sliced.minLon(), 1e-3) // -180.0 + assertEquals(TileMath.tileToLon(3, 2), sliced.maxLon(), 1e-3) // 90.0 + assertEquals(TileMath.tileToLat(0, 2), sliced.maxLat(), 1e-3) // 85.05 + assertEquals(TileMath.tileToLat(3, 2), sliced.minLat(), 1e-3) // -66.51 + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt new file mode 100644 index 00000000..92e29161 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt @@ -0,0 +1,44 @@ +package org.appdevforall.maps.slicer + +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.domain.Bbox +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +/** + * Upper-bound arms of [PmtilesRegionSlicer.tilesInRegionImpl]'s zoom + * preconditions — [PmtilesRegionSlicerCoverageTest] rejects the LOW side + * (negative zoomMin, zoomMax < zoomMin); these reject the HIGH side of the + * same `in ..20` range checks (z=21 would mean a 4-trillion-tile walk). + */ +class PmtilesRegionSlicerZoomBoundsTest { + + private val bundledNe: File = File("src/main/assets/maps/natural-earth-z0-z4.pmtiles") + private val world = Bbox(-85.0, -180.0, 85.0, 180.0) + + @Test + fun rejects_zoomMin_above_20() { + assumeTrue(bundledNe.exists()) + FileRangeFetcher(bundledNe).use { fetcher -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + PmtilesRegionSlicer.tilesInRegionImpl(fetcher, world, zoomMin = 21, zoomMax = 21) + } + } + } + } + + @Test + fun rejects_zoomMax_above_20() { + assumeTrue(bundledNe.exists()) + FileRangeFetcher(bundledNe).use { fetcher -> + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + PmtilesRegionSlicer.tilesInRegionImpl(fetcher, world, zoomMin = 4, zoomMax = 21) + } + } + } + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt new file mode 100644 index 00000000..4fd7fb89 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt @@ -0,0 +1,60 @@ +package org.appdevforall.maps.slicer + +import org.appdevforall.maps.domain.Bbox +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [TileMath.tileIntersectsBbox] disjointness arms — one test per failing + * conjunct of `tileEast > west && tileWest < east && tileNorth > south && + * tileSouth < north`, plus the boundary-touch semantics (strict inequalities: + * an edge-touching tile does NOT intersect). + * + * Fixture: bbox = (0°S..20°N, 0°E..20°E); z=4 tiles are 22.5° of longitude + * wide, so x=8 spans lon [0, 22.5), y=7 spans lat (0, 21.94], etc. + */ +class TileMathDisjointBboxTest { + + private val bbox = Bbox(south = 0.0, west = 0.0, north = 20.0, east = 20.0) + private val z = 4 + + @Test + fun overlappingTileIntersects() { + // x=8 → lon 0..22.5, y=7 → lat 0..21.94 — clearly overlaps the box. + assertTrue(TileMath.tileIntersectsBbox(z, x = 8, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelyWestDoesNotIntersect() { + // x=6 → lon -45..-22.5, east edge < bbox.west → first conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 6, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelyEastDoesNotIntersect() { + // x=9 → lon 22.5..45, west edge > bbox.east → second conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 9, y = 7, bbox = bbox)) + } + + @Test + fun tileEntirelySouthDoesNotIntersect() { + // y=9 → lat -40.98..-21.94, north edge < bbox.south → third conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 9, bbox = bbox)) + } + + @Test + fun tileEntirelyNorthDoesNotIntersect() { + // y=6 → lat 21.94..40.98, south edge > bbox.north → fourth conjunct false. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 6, bbox = bbox)) + } + + @Test + fun edgeTouchingTileDoesNotIntersect() { + // y=8's NORTH edge is exactly lat 0 = bbox.south; the comparison is strict + // (`tileNorth > south`), so a tile that only touches the boundary is out. + // This is the semantics the slicer relies on to avoid double-counting + // tiles along a shared edge. + assertFalse(TileMath.tileIntersectsBbox(z, x = 8, y = 8, bbox = bbox)) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt new file mode 100644 index 00000000..0ac77b4d --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt @@ -0,0 +1,84 @@ +package org.appdevforall.maps.templates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * Two [ProjectMapEmitter.apply] arms the main emitter suites leave open: + * + * - a JAVA-language Maps project (`MapRegionActivity.java`, no `.kt`) must be + * recognized by `hasMapRegionActivity` — both template languages are + * first-class (see the team's both-language-paths convention); + * - the maps assets dir resolving OUTSIDE the project (symlinked + * `assets/maps` — e.g. a user "optimizing" storage by linking assets to an + * SD card path) must be refused by the canonical-containment guard, not + * written through. + */ +class ProjectMapEmitterGuardArmsTest { + + @get:Rule + val tmp = TemporaryFolder() + + /** Minimal region dir (with tiles.pmtiles) inside a cache root. */ + private fun seedRegion(cacheRoot: File): File { + val region = File(cacheRoot, "testregion").apply { mkdirs() } + File(region, "tiles.pmtiles").writeBytes(ByteArray(32) { it.toByte() }) + return region + } + + @Test + fun applies_into_a_java_language_maps_project() { + val project = tmp.newFolder("javaproj") + val javaDir = File(project, "app/src/main/java/com/example").apply { mkdirs() } + // A neighbouring non-activity file first in walk order: the detector + // must skip past it (neither name matches) and still find the .java. + File(javaDir, "AndroidManifest.xml").writeText("") + File(javaDir, "MapRegionActivity.java").writeText("public class MapRegionActivity {}") + val cacheRoot = tmp.newFolder("cache") + val region = seedRegion(cacheRoot) + + val result = ProjectMapEmitter.apply(project, region, cacheRoot) + + assertTrue("Java-only Maps project must be accepted: ${result.reason}", result.success) + val copied = File(project, "app/src/main/assets/maps/tiles.pmtiles") + assertTrue("tiles must be copied into the Java project", copied.exists()) + assertEquals(32L, copied.length()) + } + + @Test + fun refuses_when_maps_assets_dir_symlinks_out_of_the_project() { + val project = tmp.newFolder("linkproj") + val ktDir = File(project, "app/src/main/kotlin/com/example").apply { mkdirs() } + File(ktDir, "MapRegionActivity.kt").writeText("class MapRegionActivity") + // assets/ exists, but assets/maps is a symlink escaping the project. + val assets = File(project, "app/src/main/assets").apply { mkdirs() } + val outside = tmp.newFolder("outside-target") + val link = File(assets, "maps") + try { + Files.createSymbolicLink(link.toPath(), outside.toPath()) + } catch (e: Exception) { + assumeTrue("filesystem doesn't support symlinks here", false) + } + val cacheRoot = tmp.newFolder("cache") + val region = seedRegion(cacheRoot) + + val result = ProjectMapEmitter.apply(project, region, cacheRoot) + + assertFalse("symlinked maps dir must be refused", result.success) + assertTrue( + "reason should name the containment failure, got: ${result.reason}", + result.reason!!.contains("maps dir escapes"), + ) + assertFalse( + "nothing may be written through the escaping link", + File(outside, "tiles.pmtiles").exists(), + ) + } +} diff --git a/maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt b/maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt new file mode 100644 index 00000000..b21588e4 --- /dev/null +++ b/maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt @@ -0,0 +1,338 @@ +package org.appdevforall.maps.ui + +import org.appdevforall.maps.data.RegionSizeEstimator +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.slicer.TileEntry +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.Test + +/** + * Unit tests for [BboxSelectionModel] — the bbox/estimate state transitions + * extracted from `BboxPickerFragment`. Pure JVM: no Robolectric, no Android. + */ +class BboxSelectionModelTest { + + private val initialBbox = Bbox.aroundPoint(0.0, 0.0, 200.0) + + private fun newModel() = BboxSelectionModel(initialBbox) + + private fun tile(bytes: Long) = + TileEntry(z = 10, x = 1, y = 1, tileId = 0L, byteOffset = 0L, byteLength = bytes) + + // ----- Initial state ----- + + @Test + fun `initial state - constructor bbox, defaults, no fix, no estimate`() { + val m = newModel() + assertSame(initialBbox, m.currentBbox) + assertEquals(BboxPickerArgs.DEFAULT_ZOOM_MIN, m.zoomMin) + assertEquals(BboxPickerArgs.DEFAULT_ZOOM_MAX, m.zoomMax) + assertEquals(0.0, m.anchorLat, 0.0) + assertEquals(0.0, m.anchorLon, 0.0) + assertFalse(m.haveRealGpsFix) + assertNull(m.realByteEstimate) + assertFalse(m.realEstimateFailed) + assertEquals(SourceKind.UNKNOWN, m.sourceKind) + assertNull(m.sourceHost) + } + + // ----- applyLaunchArgs ----- + + @Test + fun `applyLaunchArgs seeds source context and zoom range`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.IIAB_LAN, + sourceHost = "iiab.local", + zoomMin = 4, + zoomMax = 12, + prefillBbox = null, + ) + assertEquals(SourceKind.IIAB_LAN, m.sourceKind) + assertEquals("iiab.local", m.sourceHost) + assertEquals(4, m.zoomMin) + assertEquals(12, m.zoomMax) + // No prefill → the constructor bbox stays. + assertSame(initialBbox, m.currentBbox) + } + + @Test + fun `applyLaunchArgs with a prefill bbox adopts it and re-centers the anchor`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(6.0, 3.0, 7.0, 4.0), // S, W, N, E + ) + assertEquals(6.0, m.currentBbox.south, 0.0) + assertEquals(3.0, m.currentBbox.west, 0.0) + assertEquals(7.0, m.currentBbox.north, 0.0) + assertEquals(4.0, m.currentBbox.east, 0.0) + assertEquals(6.5, m.anchorLat, 1e-9) + assertEquals(3.5, m.anchorLon, 1e-9) + // A saved-bounds prefill is not a GPS fix. + assertFalse(m.haveRealGpsFix) + } + + @Test + fun `applyLaunchArgs with an invalid prefill bbox keeps the default selection`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(7.0, 3.0, 6.0, 4.0), // south > north → invalid + ) + // The malformed bounds are dropped; the constructor default survives. + assertSame(initialBbox, m.currentBbox) + } + + @Test + fun `applyLaunchArgs ignores a wrong-size prefill array`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(1.0, 2.0, 3.0), // size 3 + ) + assertSame(initialBbox, m.currentBbox) + assertEquals(0.0, m.anchorLat, 0.0) + } + + // ----- applyBboxChange ----- + + @Test + fun `applyBboxChange adopts the bbox and invalidates the estimate`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + assertNotNull(m.realByteEstimate) + + val newBbox = Bbox.aroundPoint(10.0, 10.0, 50.0) + m.applyBboxChange(newBbox) + + assertSame(newBbox, m.currentBbox) + assertNull("bbox change must invalidate the stale estimate", m.realByteEstimate) + assertFalse(m.realEstimateFailed) + } + + @Test + fun `applyBboxChange re-caps zoomMax - a small bbox allows deeper zoom than a huge one`() { + val small = newModel().apply { applyBboxChange(Bbox.aroundPoint(10.0, 10.0, 10.0)) } + val huge = newModel().apply { applyBboxChange(Bbox(-60.0, -170.0, 60.0, 170.0)) } + assertTrue( + "10 km box (zMax=${small.zoomMax}) must out-zoom a near-world box (zMax=${huge.zoomMax})", + small.zoomMax > huge.zoomMax, + ) + assertTrue(huge.zoomMax >= huge.zoomMin) + } + + @Test + fun `applyBboxChange clears a failed estimate state`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Failed) + assertTrue(m.realEstimateFailed) + m.applyBboxChange(Bbox.aroundPoint(5.0, 5.0, 50.0)) + assertFalse(m.realEstimateFailed) + } + + // ----- onEstimateState ----- + + @Test + fun `estimate Done sums tile bytes plus overhead`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L), tile(500L)))) + // PmtilesRegionSlicer.estimateRegionBytes = Σ byteLength + 8 KB overhead. + assertEquals(1000L + 500L + 8L * 1024L, m.realByteEstimate) + assertFalse(m.realEstimateFailed) + } + + @Test + fun `estimate Failed flags the failure but keeps the last bytes`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + val lastBytes = m.realByteEstimate + m.onEstimateState(RegionSizeEstimator.State.Failed) + assertTrue(m.realEstimateFailed) + assertEquals("Failed must not wipe the last known bytes", lastBytes, m.realByteEstimate) + } + + @Test + fun `estimate Calculating clears a stale failure flag`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Failed) + m.onEstimateState(RegionSizeEstimator.State.Calculating) + assertFalse(m.realEstimateFailed) + } + + // ----- hasSliceableSource ----- + + @Test + fun `hasSliceableSource - internet always, LAN only with a host, unknown never`() { + val m = newModel() + assertFalse("UNKNOWN source is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.INTERNET, null, 6, 14, null) + assertTrue(m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, null, 6, 14, null) + assertFalse("LAN without a host is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, " ", 6, 14, null) + assertFalse("LAN with a blank host is not sliceable", m.hasSliceableSource()) + + m.applyLaunchArgs(SourceKind.IIAB_LAN, "iiab.local", 6, 14, null) + assertTrue(m.hasSliceableSource()) + } + + // ----- maybeShrinkToFit ----- + + @Test + fun `maybeShrinkToFit no-ops when the bbox is fully inside the viewport`() { + val m = newModel() + val bbox = Bbox(-1.0, -1.0, 1.0, 1.0) + m.applyBboxChange(bbox) + m.noteUserCameraGesture() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + val result = m.maybeShrinkToFit( + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, margin = 0.35, + ) + + assertNull(result) + assertSame("a no-op must not touch the selection", bbox, m.currentBbox) + assertNotNull("a no-op must not invalidate the estimate", m.realByteEstimate) + } + + @Test + fun `maybeShrinkToFit shrinks an out-of-viewport bbox and invalidates the estimate`() { + val m = newModel() + m.applyBboxChange(Bbox(0.0, 0.0, 10.0, 10.0)) + m.noteUserCameraGesture() + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + val result = m.maybeShrinkToFit( + viewN = 5.0, viewS = -5.0, viewE = 5.0, viewW = -5.0, margin = 0.35, + ) + + assertNotNull("bbox pokes out of the viewport → must shrink", result) + assertSame(result, m.currentBbox) + // Shrunk box sits inside the viewport (inset by the margin). + assertTrue(m.currentBbox.north <= 5.0 && m.currentBbox.south >= -5.0) + assertTrue(m.currentBbox.east <= 5.0 && m.currentBbox.west >= -5.0) + assertNull("a real shrink is a bbox change → estimate invalidated", m.realByteEstimate) + } + + // ----- Auto-shrink vs prefilled (Refresh) selection — ADFA-2436 regression ----- + + /** + * On-device regression repro (A56, region Refresh): the wizard opened Step 2 + * prefilled with the region's saved bounds — meta.json bbox + * `[22.94, -25.62, 33.08, -16.19]` (south, west, north, east; ~926×1128 km) — + * and a camera-idle-driven auto-shrink check ran against a transient viewport + * of ~327×533 km while the fullscreen dialog was still settling. The shrink + * collapsed the saved selection to 30 % of that viewport ≈ **98×160 km**, a + * ~10× linear loss of the user's saved extent, before the user touched + * anything. + * + * The contract this pins: a selection the user has NOT driven the camera over + * (no pan/zoom gesture yet) is authoritative — auto-shrink must never fire on + * it, regardless of what viewport the camera-idle happens to report. + */ + @Test + fun `auto-shrink must not fire on a prefilled selection before any user camera gesture`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + // The exact saved bounds from the on-device repro (s, w, n, e). + prefillBbox = doubleArrayOf(22.94, -25.62, 33.08, -16.19), + ) + val saved = m.currentBbox + m.onEstimateState(RegionSizeEstimator.State.Done(listOf(tile(1000L)))) + + // Transient viewport at the idle+debounce mark: ~327×533 km centred on + // the saved box (lat span 4.79°, lon span 3.33°) — smaller than the + // selection, so the naive check sees "edges outside" and shrinks. + val result = m.maybeShrinkToFit( + viewN = 30.41, viewS = 25.62, viewE = -19.24, viewW = -22.57, margin = 0.35, + ) + + assertNull( + "a prefilled selection the user hasn't touched must never be auto-shrunk " + + "(on-device this collapsed 926×1128 km to ~98×160 km)", + result, + ) + assertSame("the saved bounds must survive untouched", saved, m.currentBbox) + assertNotNull("the estimate for the saved bounds must not be invalidated", m.realByteEstimate) + } + + @Test + fun `auto-shrink resumes for a prefilled selection after a real user camera gesture`() { + val m = newModel() + m.applyLaunchArgs( + sourceKind = SourceKind.INTERNET, + sourceHost = null, + zoomMin = 6, + zoomMax = 14, + prefillBbox = doubleArrayOf(22.94, -25.62, 33.08, -16.19), + ) + + // The user pans/zooms (Fragment reports REASON_API_GESTURE) — from here + // on the selection tracks the viewport, Google-Maps-style. + m.noteUserCameraGesture() + + val result = m.maybeShrinkToFit( + viewN = 30.41, viewS = 25.62, viewE = -19.24, viewW = -22.57, margin = 0.35, + ) + + assertNotNull("after a user gesture the selection must track the viewport", result) + assertSame(result, m.currentBbox) + assertTrue(m.currentBbox.north <= 30.41 && m.currentBbox.south >= 25.62) + assertTrue(m.currentBbox.east <= -19.24 && m.currentBbox.west >= -22.57) + } + + @Test + fun `default (non-prefill) selection is also protected until the first user gesture`() { + val m = newModel() + // No prefill: the constructor default (200×200 km at 0,0) is programmatic + // placement too — a spurious early camera-idle must not collapse it either. + val result = m.maybeShrinkToFit( + viewN = 0.5, viewS = -0.5, viewE = 0.5, viewW = -0.5, margin = 0.35, + ) + assertNull(result) + assertSame(initialBbox, m.currentBbox) + } + + // ----- Anchor / GPS fix ----- + + @Test + fun `updateAnchor moves the anchor without claiming a GPS fix`() { + val m = newModel() + m.updateAnchor(12.5, -3.25) + assertEquals(12.5, m.anchorLat, 0.0) + assertEquals(-3.25, m.anchorLon, 0.0) + assertFalse("a camera-idle anchor is NOT a GPS fix", m.haveRealGpsFix) + } + + @Test + fun `applyLocationFix moves the anchor and records the real fix`() { + val m = newModel() + m.applyLocationFix(6.52, 3.37) + assertEquals(6.52, m.anchorLat, 0.0) + assertEquals(3.37, m.anchorLon, 0.0) + assertTrue(m.haveRealGpsFix) + } +} From e56a8b5901e389b286c238b94eeecbbf932fa131 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 14 Oct 2026 14:32:11 -0700 Subject: [PATCH 12/81] chore(maps): move to plugins/Maps and adopt the addon layout (ADFA-2436) main moved every plugin under plugins// and gates PRs on `addons check`. This moves maps/ there, drops the bundled Gradle wrapper for the repo-root one (John's review, polish item 1), points the jar references at ../../libs, renames the artifact to maps.cgp, adds addon.json, fixes the page title and spells out Code on the Go, and adds the README Examples row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018DEPcb7gdHpRHRHXVqNnbf --- README.md | 1 + maps/gradle/wrapper/gradle-wrapper.jar | Bin 45457 -> 0 bytes maps/gradle/wrapper/gradle-wrapper.properties | 7 - maps/gradlew | 251 ------------------ maps/gradlew.bat | 94 ------- {maps => plugins/Maps}/README.md | 14 +- plugins/Maps/addon.json | 16 ++ {maps => plugins/Maps}/build.gradle.kts | 6 +- {maps => plugins/Maps}/gradle.properties | 0 {maps => plugins/Maps}/maps.html | 8 +- {maps => plugins/Maps}/proguard-rules.pro | 0 {maps => plugins/Maps}/settings.gradle.kts | 4 +- .../Maps}/src/main/AndroidManifest.xml | 0 .../src/main/assets/THIRD_PARTY_LICENSES.txt | 0 .../src/main/assets/docs/osm-tutorial.html | 0 .../assets/fonts/Noto Sans Italic/0-255.pbf | Bin .../assets/fonts/Noto Sans Italic/256-511.pbf | Bin .../assets/fonts/Noto Sans Italic/512-767.pbf | Bin .../fonts/Noto Sans Italic/7680-7935.pbf | Bin .../fonts/Noto Sans Italic/8192-8447.pbf | Bin .../assets/fonts/Noto Sans Regular/0-255.pbf | Bin .../fonts/Noto Sans Regular/256-511.pbf | Bin .../fonts/Noto Sans Regular/512-767.pbf | Bin .../fonts/Noto Sans Regular/7680-7935.pbf | Bin .../fonts/Noto Sans Regular/8192-8447.pbf | Bin .../Maps}/src/main/assets/icon_day.png | Bin .../Maps}/src/main/assets/icon_night.png | Bin .../main/assets/maps/natural-earth-style.json | 0 .../assets/maps/natural-earth-z0-z4.pmtiles | Bin .../assets/templates/region-map/README.md.peb | 0 .../region-map/app/build.gradle.kts.peb | 0 .../app/src/main/AndroidManifest.xml.peb | 0 .../main/assets/maps/fonts/notosans/0-255.pbf | Bin .../assets/maps/fonts/notosans/256-511.pbf | Bin .../assets/maps/fonts/notosans/512-767.pbf | Bin .../assets/maps/fonts/notosans/7680-7935.pbf | Bin .../assets/maps/fonts/notosans/8192-8447.pbf | Bin .../main/assets/maps/fonts/notosans/OFL.txt | 0 .../app/src/main/assets/maps/meta.json | 0 .../app/src/main/assets/maps/style.json | 0 .../PACKAGE_NAME/MapRegionActivity.java.peb | 0 .../PACKAGE_NAME/MapRegionActivity.kt.peb | 0 .../PACKAGE_NAME/PmtilesHttpServer.java.peb | 0 .../PACKAGE_NAME/PmtilesHttpServer.kt.peb | 0 .../main/res/layout/activity_map_region.xml | 0 .../app/src/main/res/values/colors.xml | 0 .../app/src/main/res/values/strings.xml.peb | 0 .../app/src/main/res/values/themes.xml | 0 .../main/res/xml/network_security_config.xml | 0 .../templates/region-map/build.gradle.kts.peb | 0 .../templates/region-map/gradle.properties | 0 .../activity-ktx/1.8.1/activity-ktx-1.8.1.aar | Bin .../1.8.1/activity-ktx-1.8.1.module | 0 .../activity-ktx/1.8.1/activity-ktx-1.8.1.pom | 0 .../activity/1.8.1/activity-1.8.1.aar | Bin .../activity/1.8.1/activity-1.8.1.module | 0 .../activity/1.8.1/activity-1.8.1.pom | 0 .../1.4.0/annotation-experimental-1.4.0.aar | Bin .../annotation-experimental-1.4.0.module | 0 .../1.4.0/annotation-experimental-1.4.0.pom | 0 .../1.8.2/annotation-jvm-1.8.2.jar | Bin .../1.8.2/annotation-jvm-1.8.2.module | 0 .../1.8.2/annotation-jvm-1.8.2.pom | 0 .../annotation/1.0.0/annotation-1.0.0.jar | Bin .../annotation/1.0.0/annotation-1.0.0.pom | 0 .../annotation/1.2.0/annotation-1.2.0.module | 0 .../annotation/1.2.0/annotation-1.2.0.pom | 0 .../annotation/1.6.0/annotation-1.6.0.module | 0 .../annotation/1.6.0/annotation-1.6.0.pom | 0 .../annotation/1.8.2/annotation-1.8.2.module | 0 .../annotation/1.8.2/annotation-1.8.2.pom | 0 .../core-common/2.2.0/core-common-2.2.0.jar | Bin .../2.2.0/core-common-2.2.0.module | 0 .../core-common/2.2.0/core-common-2.2.0.pom | 0 .../core-runtime/2.2.0/core-runtime-2.2.0.aar | Bin .../2.2.0/core-runtime-2.2.0.module | 0 .../core-runtime/2.2.0/core-runtime-2.2.0.pom | 0 .../1.1.0/collection-ktx-1.1.0.jar | Bin .../1.1.0/collection-ktx-1.1.0.pom | 0 .../collection/1.0.0/collection-1.0.0.pom | 0 .../collection/1.1.0/collection-1.1.0.jar | Bin .../collection/1.1.0/collection-1.1.0.pom | 0 .../1.1.0/concurrent-futures-1.1.0.jar | Bin .../1.1.0/concurrent-futures-1.1.0.module | 0 .../1.1.0/concurrent-futures-1.1.0.pom | 0 .../2.0.1/constraintlayout-solver-2.0.1.jar | Bin .../2.0.1/constraintlayout-solver-2.0.1.pom | 0 .../2.0.1/constraintlayout-2.0.1.aar | Bin .../2.0.1/constraintlayout-2.0.1.pom | 0 .../core/core-ktx/1.13.1/core-ktx-1.13.1.aar | Bin .../core-ktx/1.13.1/core-ktx-1.13.1.module | 0 .../core/core-ktx/1.13.1/core-ktx-1.13.1.pom | 0 .../core/core-ktx/1.2.0/core-ktx-1.2.0.aar | Bin .../core/core-ktx/1.2.0/core-ktx-1.2.0.pom | 0 .../core/core-ktx/1.9.0/core-ktx-1.9.0.aar | Bin .../core/core-ktx/1.9.0/core-ktx-1.9.0.module | 0 .../core/core-ktx/1.9.0/core-ktx-1.9.0.pom | 0 .../androidx/core/core/1.0.0/core-1.0.0.aar | Bin .../androidx/core/core/1.0.0/core-1.0.0.pom | 0 .../androidx/core/core/1.13.1/core-1.13.1.aar | Bin .../core/core/1.13.1/core-1.13.1.module | 0 .../androidx/core/core/1.13.1/core-1.13.1.pom | 0 .../fragment-ktx/1.8.9/fragment-ktx-1.8.9.aar | Bin .../1.8.9/fragment-ktx-1.8.9.module | 0 .../fragment-ktx/1.8.9/fragment-ktx-1.8.9.pom | 0 .../fragment/1.8.9/fragment-1.8.9.aar | Bin .../fragment/1.8.9/fragment-1.8.9.module | 0 .../fragment/1.8.9/fragment-1.8.9.pom | 0 .../2.6.1/lifecycle-common-2.6.1.jar | Bin .../2.6.1/lifecycle-common-2.6.1.module | 0 .../2.6.1/lifecycle-common-2.6.1.pom | 0 .../2.6.2/lifecycle-common-2.6.2.jar | Bin .../2.6.2/lifecycle-common-2.6.2.module | 0 .../2.6.2/lifecycle-common-2.6.2.pom | 0 .../lifecycle-livedata-core-ktx-2.6.1.aar | Bin .../lifecycle-livedata-core-ktx-2.6.1.module | 0 .../lifecycle-livedata-core-ktx-2.6.1.pom | 0 .../lifecycle-livedata-core-ktx-2.6.2.aar | Bin .../lifecycle-livedata-core-ktx-2.6.2.module | 0 .../lifecycle-livedata-core-ktx-2.6.2.pom | 0 .../2.0.0/lifecycle-livedata-core-2.0.0.aar | Bin .../2.0.0/lifecycle-livedata-core-2.0.0.pom | 0 .../2.6.1/lifecycle-livedata-core-2.6.1.aar | Bin .../lifecycle-livedata-core-2.6.1.module | 0 .../2.6.1/lifecycle-livedata-core-2.6.1.pom | 0 .../2.6.2/lifecycle-livedata-core-2.6.2.aar | Bin .../lifecycle-livedata-core-2.6.2.module | 0 .../2.6.2/lifecycle-livedata-core-2.6.2.pom | 0 .../2.6.1/lifecycle-livedata-ktx-2.6.1.aar | Bin .../2.6.1/lifecycle-livedata-ktx-2.6.1.module | 0 .../2.6.1/lifecycle-livedata-ktx-2.6.1.pom | 0 .../2.6.2/lifecycle-livedata-ktx-2.6.2.aar | Bin .../2.6.2/lifecycle-livedata-ktx-2.6.2.module | 0 .../2.6.2/lifecycle-livedata-ktx-2.6.2.pom | 0 .../2.6.1/lifecycle-livedata-2.6.1.aar | Bin .../2.6.1/lifecycle-livedata-2.6.1.module | 0 .../2.6.1/lifecycle-livedata-2.6.1.pom | 0 .../2.6.2/lifecycle-livedata-2.6.2.aar | Bin .../2.6.2/lifecycle-livedata-2.6.2.module | 0 .../2.6.2/lifecycle-livedata-2.6.2.pom | 0 .../2.6.1/lifecycle-process-2.6.1.aar | Bin .../2.6.1/lifecycle-process-2.6.1.module | 0 .../2.6.1/lifecycle-process-2.6.1.pom | 0 .../2.6.2/lifecycle-process-2.6.2.aar | Bin .../2.6.2/lifecycle-process-2.6.2.module | 0 .../2.6.2/lifecycle-process-2.6.2.pom | 0 .../2.6.1/lifecycle-runtime-ktx-2.6.1.aar | Bin .../2.6.1/lifecycle-runtime-ktx-2.6.1.module | 0 .../2.6.1/lifecycle-runtime-ktx-2.6.1.pom | 0 .../2.6.2/lifecycle-runtime-ktx-2.6.2.aar | Bin .../2.6.2/lifecycle-runtime-ktx-2.6.2.module | 0 .../2.6.2/lifecycle-runtime-ktx-2.6.2.pom | 0 .../2.6.1/lifecycle-runtime-2.6.1.aar | Bin .../2.6.1/lifecycle-runtime-2.6.1.module | 0 .../2.6.1/lifecycle-runtime-2.6.1.pom | 0 .../2.6.2/lifecycle-runtime-2.6.2.aar | Bin .../2.6.2/lifecycle-runtime-2.6.2.module | 0 .../2.6.2/lifecycle-runtime-2.6.2.pom | 0 .../2.6.1/lifecycle-viewmodel-ktx-2.6.1.aar | Bin .../lifecycle-viewmodel-ktx-2.6.1.module | 0 .../2.6.1/lifecycle-viewmodel-ktx-2.6.1.pom | 0 .../2.6.2/lifecycle-viewmodel-ktx-2.6.2.aar | Bin .../lifecycle-viewmodel-ktx-2.6.2.module | 0 .../2.6.2/lifecycle-viewmodel-ktx-2.6.2.pom | 0 .../lifecycle-viewmodel-savedstate-2.6.1.aar | Bin ...ifecycle-viewmodel-savedstate-2.6.1.module | 0 .../lifecycle-viewmodel-savedstate-2.6.1.pom | 0 .../lifecycle-viewmodel-savedstate-2.6.2.aar | Bin ...ifecycle-viewmodel-savedstate-2.6.2.module | 0 .../lifecycle-viewmodel-savedstate-2.6.2.pom | 0 .../2.0.0/lifecycle-viewmodel-2.0.0.aar | Bin .../2.0.0/lifecycle-viewmodel-2.0.0.pom | 0 .../2.6.1/lifecycle-viewmodel-2.6.1.aar | Bin .../2.6.1/lifecycle-viewmodel-2.6.1.module | 0 .../2.6.1/lifecycle-viewmodel-2.6.1.pom | 0 .../2.6.2/lifecycle-viewmodel-2.6.2.aar | Bin .../2.6.2/lifecycle-viewmodel-2.6.2.module | 0 .../2.6.2/lifecycle-viewmodel-2.6.2.pom | 0 .../1.3.0/profileinstaller-1.3.0.aar | Bin .../1.3.0/profileinstaller-1.3.0.module | 0 .../1.3.0/profileinstaller-1.3.0.pom | 0 .../1.3.1/profileinstaller-1.3.1.aar | Bin .../1.3.1/profileinstaller-1.3.1.module | 0 .../1.3.1/profileinstaller-1.3.1.pom | 0 .../1.2.1/savedstate-ktx-1.2.1.aar | Bin .../1.2.1/savedstate-ktx-1.2.1.module | 0 .../1.2.1/savedstate-ktx-1.2.1.pom | 0 .../savedstate/1.2.1/savedstate-1.2.1.aar | Bin .../savedstate/1.2.1/savedstate-1.2.1.module | 0 .../savedstate/1.2.1/savedstate-1.2.1.pom | 0 .../1.0.0/versionedparcelable-1.0.0.aar | Bin .../1.0.0/versionedparcelable-1.0.0.pom | 0 .../viewpager/1.0.0/viewpager-1.0.0.aar | Bin .../viewpager/1.0.0/viewpager-1.0.0.pom | 0 .../gson-parent/2.10.1/gson-parent-2.10.1.pom | 0 .../code/gson/gson/2.10.1/gson-2.10.1.jar | Bin .../code/gson/gson/2.10.1/gson-2.10.1.pom | 0 .../timber/timber/5.0.1/timber-5.0.1.aar | Bin .../timber/timber/5.0.1/timber-5.0.1.module | 0 .../timber/timber/5.0.1/timber-5.0.1.pom | 0 .../okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar | Bin .../okhttp/4.12.0/okhttp-4.12.0.module | 0 .../okhttp3/okhttp/4.12.0/okhttp-4.12.0.pom | 0 .../okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar | Bin .../okio/okio-jvm/3.6.0/okio-jvm-3.6.0.module | 0 .../okio/okio-jvm/3.6.0/okio-jvm-3.6.0.pom | 0 .../okio/okio/3.6.0/okio-3.6.0.module | 0 .../squareup/okio/okio/3.6.0/okio-3.6.0.pom | 0 .../annotations/13.0/annotations-13.0.jar | Bin .../annotations/13.0/annotations-13.0.pom | 0 .../annotations/20.1.0/annotations-20.1.0.jar | Bin .../annotations/20.1.0/annotations-20.1.0.pom | 0 .../1.8.22/kotlin-stdlib-common-1.8.22.jar | Bin .../1.8.22/kotlin-stdlib-common-1.8.22.pom | 0 .../1.9.10/kotlin-stdlib-common-1.9.10.pom | 0 .../2.2.10/kotlin-stdlib-common-2.2.10.module | 0 .../2.2.10/kotlin-stdlib-common-2.2.10.pom | 0 .../1.6.21/kotlin-stdlib-jdk7-1.6.21.jar | Bin .../1.6.21/kotlin-stdlib-jdk7-1.6.21.pom | 0 .../1.8.0/kotlin-stdlib-jdk7-1.8.0.jar | Bin .../1.8.0/kotlin-stdlib-jdk7-1.8.0.pom | 0 .../1.8.21/kotlin-stdlib-jdk7-1.8.21.pom | 0 .../1.9.10/kotlin-stdlib-jdk7-1.9.10.jar | Bin .../1.9.10/kotlin-stdlib-jdk7-1.9.10.pom | 0 .../1.6.21/kotlin-stdlib-jdk8-1.6.21.jar | Bin .../1.6.21/kotlin-stdlib-jdk8-1.6.21.pom | 0 .../1.8.0/kotlin-stdlib-jdk8-1.8.0.jar | Bin .../1.8.0/kotlin-stdlib-jdk8-1.8.0.pom | 0 .../1.8.21/kotlin-stdlib-jdk8-1.8.21.pom | 0 .../1.9.10/kotlin-stdlib-jdk8-1.9.10.jar | Bin .../1.9.10/kotlin-stdlib-jdk8-1.9.10.pom | 0 .../1.7.10/kotlin-stdlib-1.7.10.pom | 0 .../1.8.22/kotlin-stdlib-1.8.22.jar | Bin .../1.8.22/kotlin-stdlib-1.8.22.pom | 0 .../2.2.10/kotlin-stdlib-2.2.10.jar | Bin .../2.2.10/kotlin-stdlib-2.2.10.module | 0 .../2.2.10/kotlin-stdlib-2.2.10.pom | 0 .../6.0.1/android-sdk-geojson-6.0.1.jar | Bin .../6.0.1/android-sdk-geojson-6.0.1.module | 0 .../6.0.1/android-sdk-geojson-6.0.1.pom | 0 .../13.1.0/android-sdk-opengl-13.1.0.aar | Bin .../13.1.0/android-sdk-opengl-13.1.0.module | 0 .../13.1.0/android-sdk-opengl-13.1.0.pom | 0 .../6.0.1/android-sdk-turf-6.0.1.jar | Bin .../6.0.1/android-sdk-turf-6.0.1.module | 0 .../6.0.1/android-sdk-turf-6.0.1.pom | 0 .../0.0.4/maplibre-android-gestures-0.0.4.aar | Bin .../maplibre-android-gestures-0.0.4.module | 0 .../0.0.4/maplibre-android-gestures-0.0.4.pom | 0 .../offline-maplibre-repo/repo-manifest.txt | 0 .../region-map/settings.gradle.kts.peb | 0 .../assets/templates/region-map/thumb.png | Bin .../org/appdevforall/maps/MapsPlugin.kt | 0 .../maps/data/ActiveRegionStore.kt | 0 .../maps/data/DownloadCompleteMessage.kt | 0 .../maps/data/FirstRegionAutoActivator.kt | 0 .../maps/data/MapFontExtractor.kt | 0 .../maps/data/ProjectRegionCoordinator.kt | 0 .../maps/data/ReachabilityProbe.kt | 0 .../org/appdevforall/maps/data/RegionCache.kt | 0 .../maps/data/RegionDownloader.kt | 0 .../appdevforall/maps/data/RegionInstaller.kt | 0 .../maps/data/RegionSizeEstimator.kt | 0 .../maps/domain/AutoShrinkBbox.kt | 0 .../org/appdevforall/maps/domain/Bbox.kt | 0 .../maps/domain/EstimateDisplay.kt | 0 .../maps/domain/IiabStyleBuilder.kt | 0 .../org/appdevforall/maps/domain/RegionId.kt | 0 .../maps/domain/RegionWizardStateMachine.kt | 0 .../appdevforall/maps/domain/SourceKind.kt | 0 .../appdevforall/maps/domain/TileEstimate.kt | 0 .../org/appdevforall/maps/domain/ZoomCap.kt | 0 .../org/appdevforall/maps/domain/ZoomFit.kt | 0 .../org/appdevforall/maps/domain/ZoomLabel.kt | 0 .../org/appdevforall/maps/slicer/Hilbert.kt | 0 .../maps/slicer/PmtilesDirectory.kt | 0 .../appdevforall/maps/slicer/PmtilesHeader.kt | 0 .../maps/slicer/PmtilesRegionSlicer.kt | 0 .../org/appdevforall/maps/slicer/PmtilesV3.kt | 0 .../org/appdevforall/maps/slicer/README.md | 0 .../appdevforall/maps/slicer/RangeFetcher.kt | 0 .../maps/slicer/SliceEstimateCache.kt | 0 .../org/appdevforall/maps/slicer/TileEntry.kt | 0 .../org/appdevforall/maps/slicer/TileMath.kt | 0 .../org/appdevforall/maps/slicer/Varint.kt | 0 .../maps/templates/MapTemplateBuilder.kt | 0 .../maps/templates/ProjectMapEmitter.kt | 0 .../appdevforall/maps/ui/BboxOverlayView.kt | 0 .../appdevforall/maps/ui/BboxPickerArgs.kt | 0 .../maps/ui/BboxPickerFragment.kt | 0 .../maps/ui/BboxSelectionModel.kt | 0 .../maps/ui/DownloadProgressFragment.kt | 0 .../appdevforall/maps/ui/MapLibreBootstrap.kt | 0 .../maps/ui/MapLocationController.kt | 0 .../maps/ui/PluginChildFragmentFactory.kt | 0 .../org/appdevforall/maps/ui/RegionAdapter.kt | 0 .../maps/ui/RegionManagerFragment.kt | 0 .../maps/ui/SourcePickerFragment.kt | 0 .../appdevforall/maps/ui/Step3SaveFragment.kt | 0 .../appdevforall/maps/ui/ThemedInflater.kt | 0 .../org/appdevforall/maps/util/AtomicFiles.kt | 0 .../org/appdevforall/maps/util/ByteSize.kt | 0 .../src/main/res/drawable/badge_active.xml | 0 .../src/main/res/drawable/badge_internet.xml | 0 .../src/main/res/drawable/badge_reachable.xml | 0 .../src/main/res/drawable/badge_unknown.xml | 0 .../main/res/drawable/badge_unreachable.xml | 0 .../src/main/res/drawable/estimate_bg.xml | 0 .../src/main/res/drawable/radio_row_bg.xml | 0 .../src/main/res/drawable/region_row_bg.xml | 0 .../res/drawable/region_row_bg_active.xml | 0 .../main/res/layout/fragment_bbox_picker.xml | 0 .../res/layout/fragment_download_progress.xml | 0 .../res/layout/fragment_region_manager.xml | 0 .../main/res/layout/fragment_save_region.xml | 0 .../res/layout/fragment_source_picker.xml | 0 .../Maps}/src/main/res/layout/item_region.xml | 0 .../src/main/res/layout/item_summary_row.xml | 0 .../src/main/res/values-night/colors.xml | 0 .../Maps}/src/main/res/values/colors.xml | 0 .../Maps}/src/main/res/values/strings.xml | 0 .../Maps}/src/main/res/values/styles.xml | 0 .../maps/MapsPluginCoverageTest.kt | 0 .../ActiveRegionStoreBranchCoverageTest.kt | 0 .../maps/data/ActiveRegionStoreTest.kt | 0 .../maps/data/DownloadCompleteMessageTest.kt | 0 ...stRegionAutoActivatorBranchCoverageTest.kt | 0 ...tRegionAutoActivatorSuspendCoverageTest.kt | 0 .../maps/data/FirstRegionAutoActivatorTest.kt | 0 ...ojectRegionCoordinatorLoggerRoutingTest.kt | 0 .../maps/data/ProjectRegionCoordinatorTest.kt | 0 .../data/ReachabilityProbeHeadCoverageTest.kt | 0 .../maps/data/ReachabilityProbeTest.kt | 0 .../data/RegionCacheBranchCoverageTest.kt | 0 .../maps/data/RegionCacheEdgeCoverageTest.kt | 0 .../appdevforall/maps/data/RegionCacheTest.kt | Bin .../maps/data/RegionDownloaderCoverageTest.kt | 0 .../maps/data/RegionDownloaderPhaseTest.kt | 0 .../maps/data/RegionDownloaderUrlTest.kt | 0 .../maps/data/RegionIdValidationTest.kt | 0 .../maps/data/RegionInfoCoverageTest.kt | 0 .../maps/data/RegionInstallerCoverageTest.kt | 0 .../maps/data/RegionInstallerTest.kt | 0 .../maps/data/RegionSizeEstimatorTest.kt | 0 .../maps/domain/AutoShrinkBboxCollapseTest.kt | 0 .../maps/domain/AutoShrinkBboxCoverageTest.kt | 0 .../maps/domain/AutoShrinkBboxTest.kt | 0 .../org/appdevforall/maps/domain/BboxTest.kt | 0 .../maps/domain/EstimateDisplayTest.kt | 0 .../maps/domain/IiabStyleBuilderTest.kt | 0 .../maps/domain/RegionIdSlugifyTest.kt | 0 .../domain/RegionWizardStateMachineTest.kt | 0 .../maps/domain/TileEstimateCoverageTest.kt | 0 .../maps/domain/ZoomCapCoverageTest.kt | 0 .../appdevforall/maps/domain/ZoomCapTest.kt | 0 .../appdevforall/maps/domain/ZoomFitTest.kt | 0 .../appdevforall/maps/domain/ZoomLabelTest.kt | 0 .../slicer/DavidmotenHilbertCompatTest.kt | 0 .../maps/slicer/HilbertCoverageTest.kt | 0 .../maps/slicer/HilbertNegativeArgsTest.kt | 0 .../appdevforall/maps/slicer/HilbertTest.kt | 0 .../slicer/HttpRangeByteCacheCoverageTest.kt | 0 .../slicer/HttpRangeFetcherSizeGuardTest.kt | 0 .../maps/slicer/NgoRegionValidationTest.kt | 0 .../maps/slicer/OnDemandRegionSlicingTest.kt | 0 .../slicer/OnlineDownloadThroughputTest.kt | 0 .../slicer/OnlineNgoRegionValidationTest.kt | 0 .../slicer/PmtilesDirectoryCoverageTest.kt | 0 .../maps/slicer/PmtilesHeaderCoverageTest.kt | 0 .../maps/slicer/PmtilesHeaderTest.kt | 0 .../slicer/PmtilesRegionSlicerCoalesceTest.kt | 0 .../slicer/PmtilesRegionSlicerCoverageTest.kt | 0 .../PmtilesRegionSlicerHeaderBboxTest.kt | 0 .../maps/slicer/PmtilesRegionSlicerTest.kt | 0 .../PmtilesRegionSlicerZoomBoundsTest.kt | 0 .../maps/slicer/PmtilesV3CoverageTest.kt | 0 .../maps/slicer/RangeFetcherCoverageTest.kt | 0 .../slicer/SliceEstimateCacheCoverageTest.kt | 0 .../maps/slicer/TileMathDisjointBboxTest.kt | 0 .../maps/slicer/VarintCoverageTest.kt | 0 .../appdevforall/maps/slicer/VarintTest.kt | 0 .../maps/templates/MapTemplateAssetsTest.kt | 0 .../MapTemplateBuilderCoverageTest.kt | 0 .../ProjectMapEmitterCoverageTest.kt | 0 .../ProjectMapEmitterGuardArmsTest.kt | 0 .../maps/templates/ProjectMapEmitterTest.kt | 0 .../maps/ui/BboxSelectionModelTest.kt | 0 .../maps/util/AtomicFilesCoverageTest.kt | 0 .../appdevforall/maps/util/AtomicFilesTest.kt | 0 .../appdevforall/maps/util/ByteSizeTest.kt | 0 390 files changed, 34 insertions(+), 367 deletions(-) delete mode 100755 maps/gradle/wrapper/gradle-wrapper.jar delete mode 100644 maps/gradle/wrapper/gradle-wrapper.properties delete mode 100755 maps/gradlew delete mode 100644 maps/gradlew.bat rename {maps => plugins/Maps}/README.md (94%) create mode 100644 plugins/Maps/addon.json rename {maps => plugins/Maps}/build.gradle.kts (97%) rename {maps => plugins/Maps}/gradle.properties (100%) rename {maps => plugins/Maps}/maps.html (96%) rename {maps => plugins/Maps}/proguard-rules.pro (100%) rename {maps => plugins/Maps}/settings.gradle.kts (85%) rename {maps => plugins/Maps}/src/main/AndroidManifest.xml (100%) rename {maps => plugins/Maps}/src/main/assets/THIRD_PARTY_LICENSES.txt (100%) rename {maps => plugins/Maps}/src/main/assets/docs/osm-tutorial.html (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Italic/0-255.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Italic/256-511.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Italic/512-767.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Italic/7680-7935.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Italic/8192-8447.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Regular/0-255.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Regular/256-511.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Regular/512-767.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Regular/7680-7935.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/fonts/Noto Sans Regular/8192-8447.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/icon_day.png (100%) rename {maps => plugins/Maps}/src/main/assets/icon_night.png (100%) rename {maps => plugins/Maps}/src/main/assets/maps/natural-earth-style.json (100%) rename {maps => plugins/Maps}/src/main/assets/maps/natural-earth-z0-z4.pmtiles (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/README.md.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/build.gradle.kts.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/AndroidManifest.xml.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/0-255.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/256-511.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/512-767.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/7680-7935.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/8192-8447.pbf (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/fonts/notosans/OFL.txt (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/meta.json (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/assets/maps/style.json (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.java.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/MapRegionActivity.kt.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/res/layout/activity_map_region.xml (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/res/values/colors.xml (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/res/values/strings.xml.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/res/values/themes.xml (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/app/src/main/res/xml/network_security_config.xml (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/build.gradle.kts.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/gradle.properties (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity-ktx/1.8.1/activity-ktx-1.8.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity-ktx/1.8.1/activity-ktx-1.8.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity-ktx/1.8.1/activity-ktx-1.8.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity/1.8.1/activity-1.8.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity/1.8.1/activity-1.8.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/activity/activity/1.8.1/activity-1.8.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-experimental/1.4.0/annotation-experimental-1.4.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-experimental/1.4.0/annotation-experimental-1.4.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-experimental/1.4.0/annotation-experimental-1.4.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-jvm/1.8.2/annotation-jvm-1.8.2.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-jvm/1.8.2/annotation-jvm-1.8.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation-jvm/1.8.2/annotation-jvm-1.8.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.0.0/annotation-1.0.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.0.0/annotation-1.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.2.0/annotation-1.2.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.2.0/annotation-1.2.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.6.0/annotation-1.6.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.6.0/annotation-1.6.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.8.2/annotation-1.8.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/annotation/annotation/1.8.2/annotation-1.8.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-common/2.2.0/core-common-2.2.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-common/2.2.0/core-common-2.2.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-common/2.2.0/core-common-2.2.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/collection/collection-ktx/1.1.0/collection-ktx-1.1.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/collection/collection-ktx/1.1.0/collection-ktx-1.1.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/collection/collection/1.0.0/collection-1.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/collection/collection/1.1.0/collection-1.1.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/collection/collection/1.1.0/collection-1.1.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/concurrent/concurrent-futures/1.1.0/concurrent-futures-1.1.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/concurrent/concurrent-futures/1.1.0/concurrent-futures-1.1.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/concurrent/concurrent-futures/1.1.0/concurrent-futures-1.1.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/constraintlayout/constraintlayout-solver/2.0.1/constraintlayout-solver-2.0.1.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/constraintlayout/constraintlayout-solver/2.0.1/constraintlayout-solver-2.0.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/constraintlayout/constraintlayout/2.0.1/constraintlayout-2.0.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/constraintlayout/constraintlayout/2.0.1/constraintlayout-2.0.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.13.1/core-ktx-1.13.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.13.1/core-ktx-1.13.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.13.1/core-ktx-1.13.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.2.0/core-ktx-1.2.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.2.0/core-ktx-1.2.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.9.0/core-ktx-1.9.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.9.0/core-ktx-1.9.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core-ktx/1.9.0/core-ktx-1.9.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core/1.0.0/core-1.0.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core/1.0.0/core-1.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core/1.13.1/core-1.13.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core/1.13.1/core-1.13.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/core/core/1.13.1/core-1.13.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment-ktx/1.8.9/fragment-ktx-1.8.9.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment-ktx/1.8.9/fragment-ktx-1.8.9.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment-ktx/1.8.9/fragment-ktx-1.8.9.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment/1.8.9/fragment-1.8.9.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment/1.8.9/fragment-1.8.9.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/fragment/fragment/1.8.9/fragment-1.8.9.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.1/lifecycle-common-2.6.1.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.1/lifecycle-common-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.1/lifecycle-common-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.2/lifecycle-common-2.6.2.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.2/lifecycle-common-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-common/2.6.2/lifecycle-common-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.1/lifecycle-livedata-core-ktx-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.1/lifecycle-livedata-core-ktx-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.1/lifecycle-livedata-core-ktx-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.2/lifecycle-livedata-core-ktx-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.2/lifecycle-livedata-core-ktx-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core-ktx/2.6.2/lifecycle-livedata-core-ktx-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.0.0/lifecycle-livedata-core-2.0.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.0.0/lifecycle-livedata-core-2.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.1/lifecycle-livedata-core-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.1/lifecycle-livedata-core-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.1/lifecycle-livedata-core-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.2/lifecycle-livedata-core-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.2/lifecycle-livedata-core-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-core/2.6.2/lifecycle-livedata-core-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.1/lifecycle-livedata-ktx-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.1/lifecycle-livedata-ktx-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.1/lifecycle-livedata-ktx-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.2/lifecycle-livedata-ktx-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.2/lifecycle-livedata-ktx-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata-ktx/2.6.2/lifecycle-livedata-ktx-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.1/lifecycle-livedata-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.1/lifecycle-livedata-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.1/lifecycle-livedata-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.2/lifecycle-livedata-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.2/lifecycle-livedata-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-livedata/2.6.2/lifecycle-livedata-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.1/lifecycle-process-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.1/lifecycle-process-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.1/lifecycle-process-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.2/lifecycle-process-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.2/lifecycle-process-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-process/2.6.2/lifecycle-process-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.1/lifecycle-runtime-ktx-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.1/lifecycle-runtime-ktx-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.1/lifecycle-runtime-ktx-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.2/lifecycle-runtime-ktx-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.2/lifecycle-runtime-ktx-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime-ktx/2.6.2/lifecycle-runtime-ktx-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.1/lifecycle-runtime-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.1/lifecycle-runtime-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.1/lifecycle-runtime-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.2/lifecycle-runtime-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.2/lifecycle-runtime-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-runtime/2.6.2/lifecycle-runtime-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.1/lifecycle-viewmodel-ktx-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.1/lifecycle-viewmodel-ktx-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.1/lifecycle-viewmodel-ktx-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.2/lifecycle-viewmodel-ktx-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.2/lifecycle-viewmodel-ktx-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-ktx/2.6.2/lifecycle-viewmodel-ktx-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.1/lifecycle-viewmodel-savedstate-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.1/lifecycle-viewmodel-savedstate-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.1/lifecycle-viewmodel-savedstate-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.2/lifecycle-viewmodel-savedstate-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.2/lifecycle-viewmodel-savedstate-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel-savedstate/2.6.2/lifecycle-viewmodel-savedstate-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.0.0/lifecycle-viewmodel-2.0.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.0.0/lifecycle-viewmodel-2.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.1/lifecycle-viewmodel-2.6.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.1/lifecycle-viewmodel-2.6.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.1/lifecycle-viewmodel-2.6.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.2/lifecycle-viewmodel-2.6.2.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.2/lifecycle-viewmodel-2.6.2.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/lifecycle/lifecycle-viewmodel/2.6.2/lifecycle-viewmodel-2.6.2.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.0/profileinstaller-1.3.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.0/profileinstaller-1.3.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.0/profileinstaller-1.3.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate-ktx/1.2.1/savedstate-ktx-1.2.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate-ktx/1.2.1/savedstate-ktx-1.2.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate-ktx/1.2.1/savedstate-ktx-1.2.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/versionedparcelable/versionedparcelable/1.0.0/versionedparcelable-1.0.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/versionedparcelable/versionedparcelable/1.0.0/versionedparcelable-1.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/google/code/gson/gson-parent/2.10.1/gson-parent-2.10.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/google/code/gson/gson/2.10.1/gson-2.10.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/jakewharton/timber/timber/5.0.1/timber-5.0.1.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/jakewharton/timber/timber/5.0.1/timber-5.0.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/jakewharton/timber/timber/5.0.1/timber-5.0.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okio/okio/3.6.0/okio-3.6.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/com/squareup/okio/okio/3.6.0/okio-3.6.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/annotations/13.0/annotations-13.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/annotations/13.0/annotations-13.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/annotations/20.1.0/annotations-20.1.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/annotations/20.1.0/annotations-20.1.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.22/kotlin-stdlib-common-1.8.22.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.22/kotlin-stdlib-common-1.8.22.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-common/1.9.10/kotlin-stdlib-common-1.9.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-common/2.2.10/kotlin-stdlib-common-2.2.10.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-common/2.2.10/kotlin-stdlib-common-2.2.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.6.21/kotlin-stdlib-jdk7-1.6.21.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.6.21/kotlin-stdlib-jdk7-1.6.21.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.21/kotlin-stdlib-jdk7-1.8.21.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.10/kotlin-stdlib-jdk7-1.9.10.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.10/kotlin-stdlib-jdk7-1.9.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.6.21/kotlin-stdlib-jdk8-1.6.21.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.6.21/kotlin-stdlib-jdk8-1.6.21.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.21/kotlin-stdlib-jdk8-1.8.21.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.10/kotlin-stdlib-jdk8-1.9.10.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.10/kotlin-stdlib-jdk8-1.9.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/1.7.10/kotlin-stdlib-1.7.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/1.8.22/kotlin-stdlib-1.8.22.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/1.8.22/kotlin-stdlib-1.8.22.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/2.2.10/kotlin-stdlib-2.2.10.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/2.2.10/kotlin-stdlib-2.2.10.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/jetbrains/kotlin/kotlin-stdlib/2.2.10/kotlin-stdlib-2.2.10.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-geojson/6.0.1/android-sdk-geojson-6.0.1.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-geojson/6.0.1/android-sdk-geojson-6.0.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-geojson/6.0.1/android-sdk-geojson-6.0.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-opengl/13.1.0/android-sdk-opengl-13.1.0.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-opengl/13.1.0/android-sdk-opengl-13.1.0.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-opengl/13.1.0/android-sdk-opengl-13.1.0.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-turf/6.0.1/android-sdk-turf-6.0.1.jar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-turf/6.0.1/android-sdk-turf-6.0.1.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/android-sdk-turf/6.0.1/android-sdk-turf-6.0.1.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/maplibre-android-gestures/0.0.4/maplibre-android-gestures-0.0.4.aar (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/maplibre-android-gestures/0.0.4/maplibre-android-gestures-0.0.4.module (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/org/maplibre/gl/maplibre-android-gestures/0.0.4/maplibre-android-gestures-0.0.4.pom (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/offline-maplibre-repo/repo-manifest.txt (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/settings.gradle.kts.peb (100%) rename {maps => plugins/Maps}/src/main/assets/templates/region-map/thumb.png (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/ActiveRegionStore.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/MapFontExtractor.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinator.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/ReachabilityProbe.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/RegionCache.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/RegionInstaller.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/data/RegionSizeEstimator.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/Bbox.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/EstimateDisplay.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/IiabStyleBuilder.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/RegionId.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachine.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/SourceKind.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/TileEstimate.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/ZoomCap.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/ZoomFit.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/domain/ZoomLabel.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/Hilbert.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesDirectory.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesHeader.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicer.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/PmtilesV3.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/README.md (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/RangeFetcher.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/SliceEstimateCache.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/TileEntry.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/TileMath.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/slicer/Varint.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/templates/ProjectMapEmitter.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerArgs.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/MapLibreBootstrap.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/MapLocationController.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/PluginChildFragmentFactory.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/SourcePickerFragment.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/Step3SaveFragment.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/ui/ThemedInflater.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/util/AtomicFiles.kt (100%) rename {maps => plugins/Maps}/src/main/kotlin/org/appdevforall/maps/util/ByteSize.kt (100%) rename {maps => plugins/Maps}/src/main/res/drawable/badge_active.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/badge_internet.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/badge_reachable.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/badge_unknown.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/badge_unreachable.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/estimate_bg.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/radio_row_bg.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/region_row_bg.xml (100%) rename {maps => plugins/Maps}/src/main/res/drawable/region_row_bg_active.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/fragment_bbox_picker.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/fragment_download_progress.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/fragment_region_manager.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/fragment_save_region.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/fragment_source_picker.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/item_region.xml (100%) rename {maps => plugins/Maps}/src/main/res/layout/item_summary_row.xml (100%) rename {maps => plugins/Maps}/src/main/res/values-night/colors.xml (100%) rename {maps => plugins/Maps}/src/main/res/values/colors.xml (100%) rename {maps => plugins/Maps}/src/main/res/values/strings.xml (100%) rename {maps => plugins/Maps}/src/main/res/values/styles.xml (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreBranchCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ActiveRegionStoreTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorBranchCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorSuspendCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorLoggerRoutingTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinatorTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeHeadCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/ReachabilityProbeTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionCacheBranchCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionCacheEdgeCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionCacheTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderPhaseTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderUrlTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionIdValidationTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionInfoCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionInstallerTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/data/RegionSizeEstimatorTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCollapseTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/BboxTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/IiabStyleBuilderTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/RegionIdSlugifyTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/RegionWizardStateMachineTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/TileEstimateCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/ZoomCapTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/domain/ZoomLabelTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/DavidmotenHilbertCompatTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/HilbertCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/HilbertNegativeArgsTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/HilbertTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeByteCacheCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/HttpRangeFetcherSizeGuardTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/NgoRegionValidationTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/OnDemandRegionSlicingTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/OnlineDownloadThroughputTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/OnlineNgoRegionValidationTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesDirectoryCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesHeaderTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoalesceTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerHeaderBboxTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesRegionSlicerZoomBoundsTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/PmtilesV3CoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/RangeFetcherCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/SliceEstimateCacheCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/TileMathDisjointBboxTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/VarintCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/slicer/VarintTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterGuardArmsTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/templates/ProjectMapEmitterTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesCoverageTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/util/AtomicFilesTest.kt (100%) rename {maps => plugins/Maps}/src/test/kotlin/org/appdevforall/maps/util/ByteSizeTest.kt (100%) diff --git a/README.md b/README.md index 6e04a418..7c942833 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`plugins/Jetpack-Compose-Preview/`](plugins/Jetpack-Compose-Preview/) | Renders Compose preview functions on the device. | | [`plugins/Keystore-Generator/`](plugins/Keystore-Generator/) | Creates and manages app signing keystores on the device. | | [`plugins/Layout-Editor/`](plugins/Layout-Editor/) | Edits Android XML layouts by dragging views. | +| [`plugins/Maps/`](plugins/Maps/) | Builds offline map apps from OpenStreetMap regions. | | [`plugins/Markdown-Previewer/`](plugins/Markdown-Previewer/) | Shows a live preview of Markdown and HTML files. | | [`plugins/NDK-Installer/`](plugins/NDK-Installer/) | Installs the NDK and CMake, and adds a native project template. | | [`plugins/Project-to-Template/`](plugins/Project-to-Template/) | Turns the open project into a reusable template. | diff --git a/maps/gradle/wrapper/gradle-wrapper.jar b/maps/gradle/wrapper/gradle-wrapper.jar deleted file mode 100755 index 8bdaf60c75ab801e22807dde59e12a8735a34077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg diff --git a/maps/gradle/wrapper/gradle-wrapper.properties b/maps/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index d4081da4..00000000 --- a/maps/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100755 index ef07e016..00000000 --- a/maps/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/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 deleted file mode 100644 index 5eed7ee8..00000000 --- a/maps/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@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/README.md b/plugins/Maps/README.md similarity index 94% rename from maps/README.md rename to plugins/Maps/README.md index 6282c34c..a7a34c1e 100644 --- a/maps/README.md +++ b/plugins/Maps/README.md @@ -25,9 +25,10 @@ where you download a map region and bundle it into that app. ## Architecture ``` -maps/ -├── build.gradle.kts # Build configuration (plugin API via ../libs) -├── settings.gradle.kts # Buildscript classpath (shared ../libs) +plugins/Maps/ +├── addon.json # Gallery metadata +├── 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 @@ -58,8 +59,9 @@ maps/ ## Building the plugin ```bash -./gradlew assemblePlugin # release -> build/plugin/maps-plugin.cgp -./gradlew assemblePluginDebug # debug +cd plugins/Maps +../../gradlew assemblePlugin # release -> build/plugin/maps.cgp +../../gradlew assemblePluginDebug # debug ``` ### Installation @@ -77,7 +79,7 @@ maps/ 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`) +- `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 diff --git a/plugins/Maps/addon.json b/plugins/Maps/addon.json new file mode 100644 index 00000000..c4074e9c --- /dev/null +++ b/plugins/Maps/addon.json @@ -0,0 +1,16 @@ +{ + "summary": "Builds offline map apps from OpenStreetMap regions.", + "description": "Download an OpenStreetMap region from an Internet-in-a-Box server and bundle it into an app made from the Offline OSM Map template. The app shows the map with no internet connection.", + "tags": [ + "maps", + "openstreetmap", + "offline", + "templates" + ], + "origin": "appdevforall", + "license": "AGPL-3.0-or-later", + "author": { + "name": "App Dev For All", + "url": "https://www.appdevforall.org" + } +} diff --git a/maps/build.gradle.kts b/plugins/Maps/build.gradle.kts similarity index 97% rename from maps/build.gradle.kts rename to plugins/Maps/build.gradle.kts index 765a921b..202d6e1a 100644 --- a/maps/build.gradle.kts +++ b/plugins/Maps/build.gradle.kts @@ -16,7 +16,7 @@ jacoco { } pluginBuilder { - pluginName = "maps-plugin" + pluginName = "maps" } android { @@ -80,7 +80,7 @@ kotlin { dependencies { // plugin-api jar is the canonical plugin contract; IDE provides at runtime. - compileOnly(files("../libs/plugin-api.jar")) + compileOnly(files("../../libs/plugin-api.jar")) implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.10.0") @@ -108,7 +108,7 @@ dependencies { // 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")) + testImplementation(files("../../libs/plugin-api.jar")) } tasks.wrapper { diff --git a/maps/gradle.properties b/plugins/Maps/gradle.properties similarity index 100% rename from maps/gradle.properties rename to plugins/Maps/gradle.properties diff --git a/maps/maps.html b/plugins/Maps/maps.html similarity index 96% rename from maps/maps.html rename to plugins/Maps/maps.html index 4a53d3ef..171b21ef 100644 --- a/maps/maps.html +++ b/plugins/Maps/maps.html @@ -3,7 +3,7 @@ - Maps Plugin for Code on the Go + Maps @@ -67,6 +68,100 @@

Working with OpenStreetMap in Code on the Go

OpenStreetMap (what the data is), and MapLibre (what draws it).

+

Using the Maps tab

+ +

Open a project made from the Offline OSM Map template, then tap +the Maps tab in the editor's bottom sheet. Long-press any button on +these screens for a short explanation.

+ +

The region list

+ +

Each row is a region saved on this phone, under +/sdcard/CodeOnTheGo/maps/. A saved region can be used by any number of +projects, so you download it only once.

+ +The Maps tab listing one saved region, Lalibela, marked Active in this project, with its switch on and Refresh and Delete buttons. + +
    +
  • The switch on a row copies the region's files into the open + project's app/src/main/assets/maps/ folder and makes it the region the + app shows. The row then turns green and reads ● Active in this + project. One region is active at a time: turning on another one switches + to it.
  • +
  • Refresh opens the download steps again with this region's name + and area already filled in, so you can download a newer copy.
  • +
  • Delete asks first, then removes the region from the phone. + Projects that already copied it keep their own copy. If it was the open project's + region, the project no longer has a region selected.
  • +
  • + Download new region starts the three download steps below.
  • +
+ +

Step 1: Pick the map source

+ +Step 1 of 3 with Local Network selected, a hostname typed in, and the status Reachable. + +
    +
  • Internet downloads from the public Internet in a Box server, + iiab.switnet.org. This is always selected when the step opens.
  • +
  • Local Network downloads from an Internet in a Box on your + Wi-Fi. Type its hostname; the plugin remembers it, and the screen shows whether the + box answers.
  • +
+ +

Step 2: Choose the region

+ +

Drag the box to move it, and drag a corner to resize it. Pan and zoom the map to +find your area.

+ +
    +
  • Where the box starts. For a new region the map opens on a + world view with a large box over West Africa. If Code on the Go has permission to + read your location, the box moves to a 50 km × 50 km square around + your last known location. Code on the Go does not ask for that permission today, so + you will usually see the default box. Refresh starts from the + region's saved area instead.
  • +
  • The size estimate. The first line under the map is the download + size, measured from the server for the box you drew. It needs a connection to the + source; without one it reads “Couldn't calculate size” and + Next stays off. A region over 1 GB turns the box red and + cannot be downloaded.
  • +
  • The zoom limit. The second line shows the box size + and the most detailed zoom level the region will have, for example + “40km × 38km — max zoom level 14 (streets)”. The + region always starts at zoom level 6. The plugin picks the highest limit, up to 14, + that keeps the region under 100,000 map tiles, so a bigger box gets a lower limit.
  • +
+ +

Step 3: Name and save

+ +Step 3 of 3 with the region name Lalibela, a summary showing the source and the total to download, and the Back and Save buttons. + +
    +
  • Type a place name, such as Lalibela, Ethiopia. The name is shown in the + region list.
  • +
  • The region's folder is made from the name: lowercase, with spaces and + punctuation turned into dashes, up to 64 characters. Letters from any language are + kept.
  • +
  • Save stays off while the name is empty, or when another saved + region already uses the same folder name. To download that region again, use + Refresh on it instead.
  • +
  • The summary shows the source and the total to download.
  • +
+ +

Downloading

+ +The download screen: the Natural Earth basemap is done and the OpenStreetMap tiles are at 0 percent, with a Cancel + discard button. + +
    +
  • The low-zoom world basemap is copied from the plugin, so it needs no download. + The map tiles for your region then download from the source.
  • +
  • Cancel + discard stops the download and deletes what was + downloaded so far.
  • +
  • When the download finishes and the open project has no region yet, the new + region is used in the project straight away. If the project already has one, a + message offers Apply to switch.
  • +
+

What is Internet in a Box?

Internet in a Box (IIAB) is an @@ -167,8 +262,8 @@

Where do tiles come from?

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 +/sdcard/CodeOnTheGo/maps/<region-id>/. When you turn on a +region's switch, 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. diff --git a/plugins/Maps/src/main/assets/docs/maps-downloading.png b/plugins/Maps/src/main/assets/docs/maps-downloading.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd5de1b6c7e30c2af9d7cc4eb88bb47ae0dd322 GIT binary patch literal 8664 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 literal 0 HcmV?d00001 diff --git a/plugins/Maps/src/main/assets/docs/maps-region-list.png b/plugins/Maps/src/main/assets/docs/maps-region-list.png new file mode 100644 index 0000000000000000000000000000000000000000..da038b40e9335c56b5ecf714ca964f123fe813a4 GIT binary patch literal 11516 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 BPJ{pe literal 0 HcmV?d00001 diff --git a/plugins/Maps/src/main/assets/docs/maps-save-region.png b/plugins/Maps/src/main/assets/docs/maps-save-region.png new file mode 100644 index 0000000000000000000000000000000000000000..4fa81a853e2caab7139dedc653a6f6a753a54ebd GIT binary patch literal 8105 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 literal 0 HcmV?d00001 diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt index 2794f055..8ee119a6 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt @@ -221,7 +221,7 @@ class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { */ private fun tutorialButton(anchor: String? = null) = PluginTooltipButton( description = "OSM + MapLibre tutorial", - uri = if (anchor == null) "osm-tutorial.html" else "osm-tutorial.html#$anchor", + uri = if (anchor == null) "index.html" else "index.html#$anchor", order = 0, ) @@ -344,8 +344,8 @@ class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { * 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`. + * `PluginTooltipButton` with `uri = "index.html"` resolves to + * `http://localhost:6174/plugin/org.appdevforall.maps/index.html`. * The asset is HTML (not Markdown) so the host WebView renders it formatted * rather than as raw text. */ diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt index fcac4477..8b9b3b02 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt @@ -207,7 +207,7 @@ class MapsPluginCoverageTest { 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("index.html", button.uri) assertEquals(0, button.order) } @@ -230,7 +230,7 @@ class MapsPluginCoverageTest { val template = MapsPlugin().getTooltipEntries() .single { it.tag == MapsPlugin.TOOLTIP_TAG_REGION_MAP_TEMPLATE } assertTrue(template.summary.contains("Offline OSM Map")) - assertEquals("osm-tutorial.html", template.buttons.single().uri) + assertEquals("index.html", template.buttons.single().uri) } /** @@ -265,6 +265,36 @@ class MapsPluginCoverageTest { ) } + @Test + fun `every tutorial link opens a page and anchor that ship in the docs folder`() { + val docsDir = File("src/main/assets/docs") + val buttons = MapsPlugin().getTooltipEntries().flatMap { it.buttons } + assertTrue("entries carry tutorial buttons", buttons.size >= 7) + for (button in buttons) { + val page = button.uri.substringBefore('#') + val file = File(docsDir, page) + assertTrue("${button.uri}: $file must exist", file.isFile) + val anchor = button.uri.substringAfter('#', "") + if (anchor.isNotEmpty()) { + assertTrue("${button.uri}: no id=\"$anchor\" in $page", file.readText().contains("id=\"$anchor\"")) + } + } + } + + /** The page is served from documentation.db offline, so an image must ship beside it. */ + @Test + fun `every image the tutorial shows ships in the docs folder and stays small`() { + val docsDir = File("src/main/assets/docs") + val sources = Regex("""= 4) + for (src in sources) { + val file = File(docsDir, src) + assertTrue("$src: $file must exist", file.isFile) + assertTrue("$src is ${file.length()} bytes, over 150 KB", file.length() <= 150L * 1024) + } + } + @Test fun `documentation install returns true and uninstall is a no-op that runs`() { val plugin = MapsPlugin() diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt index d530c978..666b5323 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateAssetsTest.kt @@ -347,7 +347,7 @@ class MapTemplateAssetsTest { fun `generated app docs say the app runs on both 64-bit and 32-bit ARM`() { val docs = listOf( asset("README.md.peb"), - File("src/main/assets/docs/osm-tutorial.html"), + File("src/main/assets/docs/index.html"), ) for (doc in docs) { // Collapse whitespace so a re-wrapped line in the HTML does not hide the phrase. From 8c8bdede4e1c4a36894d490cbae9af43978eb422 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 17:23:22 -0700 Subject: [PATCH 60/81] build(maps): drop the jitpack repository (ADFA-2436) Plugin review advisory 3. Nothing resolves from it: hilbert-curve 0.2.3, the only com.github dependency, comes from Maven Central. With jitpack gone, buildEnvironment, releaseRuntimeClasspath and debugUnitTestRuntimeClasspath all resolve under --refresh-dependencies with no failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- plugins/Maps/settings.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/Maps/settings.gradle.kts b/plugins/Maps/settings.gradle.kts index 2bdeb93c..2c360e26 100644 --- a/plugins/Maps/settings.gradle.kts +++ b/plugins/Maps/settings.gradle.kts @@ -10,7 +10,6 @@ buildscript { repositories { google() mavenCentral() - maven { url = uri("https://jitpack.io") } } dependencies { classpath(files("../../libs/plugin-api.jar")) @@ -25,7 +24,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://jitpack.io") } } } From 802e03b5a041d3bcf8e899f2e439a5fcf5fdf894 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 17:24:16 -0700 Subject: [PATCH 61/81] build(maps): drop the Jetifier and R8 full-mode flags (ADFA-2436) Plugin review advisory 4. No dependency on any resolved classpath is a com.android.support artifact, so Jetifier has nothing to rewrite. The release build sets isMinifyEnabled = false and assemblePlugin runs no R8 task, so the full-mode switch never applied. A clean assemblePlugin builds without either AGP deprecation warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- plugins/Maps/gradle.properties | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/Maps/gradle.properties b/plugins/Maps/gradle.properties index a91ac582..35bc3432 100755 --- a/plugins/Maps/gradle.properties +++ b/plugins/Maps/gradle.properties @@ -1,9 +1,5 @@ # Android properties android.useAndroidX=true -android.enableJetifier=true - -# Enable R8 full mode optimizations -android.enableR8.fullMode=false # Kotlin kotlin.code.style=official From 1f1d44d742258da47eeb2d8c97f93846bd46b9d0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:15:24 -0700 Subject: [PATCH 62/81] feat(maps): find a LAN box's archive from its /maps/data/ listing (ADFA-2436) IIAB serves an nginx JSON autoindex of /library/www/maps/ at /maps/data/, and its own map page reads it. Read that listing first and take the newest exact z00-z14 name, so *.pmtiles.old and directories never match. The file is still read from /maps/: the listing location aliases the same directory but lacks IIAB's pmtiles transfer settings. When the listing is missing, not JSON, too large or has no match, probe by name newest first across every published build (10-01, 09-01, 07-01, 04-01). A box with none gets a message that it has no zoom 0-14 archive, that IIAB installs zoom 0-8 by default, and to use the internet source, followed by everything that was tried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/data/IiabCatalog.kt | 34 +++++-- .../maps/data/RegionDownloader.kt | 72 ++++++++++++-- .../appdevforall/maps/data/IiabCatalogTest.kt | 21 ++++ .../maps/data/RegionDownloaderEndToEndTest.kt | 11 ++- .../data/RegionDownloaderLanArchiveTest.kt | 98 +++++++++++++++++-- 5 files changed, 213 insertions(+), 23 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/IiabCatalog.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/IiabCatalog.kt index 9b4153da..ade50ed4 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/IiabCatalog.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/IiabCatalog.kt @@ -1,5 +1,6 @@ package org.appdevforall.maps.data +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -17,18 +18,17 @@ internal object IiabCatalog { /** The mirror directory every archive in the catalog lives under. */ const val MIRROR_BASE = "https://iiab.switnet.org/maps/2/" - /** - * Used when the catalog cannot be read, and always for an IIAB LAN box. The catalog's z00-z14 - * archive as of 2026-10-27. - */ + /** Used when the catalog cannot be read. The catalog's z00-z14 archive as of 2026-10-27. */ const val FALLBACK_VECTOR_ARCHIVE = "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" /** - * The archive names a LAN box may hold, newest first. A box keeps the build it was installed - * with, and boxes installed before IIAB's 2026-10-06 update hold the 2026-04-01 one. + * Every z00-z14 archive IIAB has published, newest first, probed by name when a LAN box gives + * no usable directory listing. A box keeps the build it was installed with. */ val LAN_VECTOR_ARCHIVES = - listOf(FALLBACK_VECTOR_ARCHIVE, "openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles") + listOf("2026-10-01", "2026-09-01", "2026-07-01", "2026-04-01").map { + "openstreetmap-openmaptiles.$it.z00-z14.pmtiles" + } private val VECTOR_ARCHIVE = Regex("""openstreetmap-openmaptiles\.(\d{4}-\d{2}-\d{2})\.z00-z14\.pmtiles""") @@ -50,4 +50,24 @@ internal object IiabCatalog { /** The `yyyy-mm-dd` date in a vector archive name, or null if [archive] is not one. */ fun dateOf(archive: String): String? = VECTOR_ARCHIVE.matchEntire(archive)?.groupValues?.get(1) + + /** The file names in an nginx JSON autoindex listing, or null if [listingJson] is not one. */ + fun listedFiles(listingJson: String): List? { + val entries = + try { + JSONArray(listingJson) + } catch (e: JSONException) { + return null + } + return (0 until entries.length()).mapNotNull { i -> + entries.optJSONObject(i)?.takeIf { it.optString("type") == "file" }?.optString("name") + } + } + + /** + * The newest dated z00-z14 archive in [names]. Exact matches only, so the `*.pmtiles.old` name + * IIAB gives a retired archive never wins. + */ + fun newestVectorArchive(names: List): String? = + names.mapNotNull { name -> dateOf(name)?.let { it to name } }.maxByOrNull { it.first }?.second } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt index 8c29a4b4..458c71c4 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt @@ -48,8 +48,9 @@ import org.appdevforall.maps.util.MAPS_TRAFFIC_STATS_TAG * - **Internet** — `https://iiab.switnet.org/maps/2/`: * - `openstreetmap-openmaptiles..z00-z14.pmtiles` (vector tiles), with the date read * from IIAB's catalog by [IiabArchiveResolver] - * - **LAN** — `http:///maps/` with the newest of [IiabCatalog.LAN_VECTOR_ARCHIVES] the box - * serves, probed by [resolveTilesUrl]. + * - **LAN** — `http:///maps/` with the newest archive the box lists at `/maps/data/`, or + * failing that the first of [IiabCatalog.LAN_VECTOR_ARCHIVES] it serves, found by + * [resolveTilesUrl]. * The Natural Earth basemap is copied from the plugin bundle, not fetched. */ internal object RegionDownloader { @@ -68,6 +69,13 @@ internal object RegionDownloader { private const val INTERNET_BASE = IiabCatalog.MIRROR_BASE private const val LAN_PATH_SUFFIX = "/maps/" + /** IIAB's nginx JSON autoindex of `/maps/`, relative to the LAN base. */ + private const val LAN_LISTING_PATH = "data/" + private const val LISTING_LABEL = "/maps/data/ listing" + + /** A box's maps directory holds a few dozen entries; anything far larger is not the listing. */ + private const val MAX_LISTING_BYTES = 1024L * 1024L + // 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 @@ -341,7 +349,7 @@ internal object RegionDownloader { * The URL a download reads: internet resolves the current archive first, and LAN probes the box * for the newest archive it holds. * - * @throws IOException when a LAN box answers for none of [IiabCatalog.LAN_VECTOR_ARCHIVES]. + * @throws IOException when a LAN box lists and serves no z00-z14 archive. */ internal suspend fun resolveTilesUrl( sourceKind: SourceKind, @@ -360,18 +368,70 @@ internal object RegionDownloader { /** The archive each LAN base last answered for, so the estimate reads what the download did. */ private val lanArchives = ConcurrentHashMap() - /** The first of [IiabCatalog.LAN_VECTOR_ARCHIVES] that [base] serves a one-byte range of. */ + /** + * The newest archive [base] holds: from the box's JSON directory listing when it has one, else + * the first of [IiabCatalog.LAN_VECTOR_ARCHIVES] it serves a one-byte range of. + */ private suspend fun probeLanArchive(base: String): String = withContext(Dispatchers.IO) { val misses = mutableListOf() + listedArchive(base, misses)?.let { + PluginLog.info("LAN archive from listing: $base$it") + return@withContext it + } for (name in IiabCatalog.LAN_VECTOR_ARCHIVES) { val code = rangeProbe(base + name) - if (code == 200 || code == 206) return@withContext name + if (code == 200 || code == 206) { + PluginLog.info("LAN archive from probe: $base$name (${misses.joinToString()})") + return@withContext name + } misses += "$name: ${code ?: "no answer"}" } - throw IOException("IIAB box at $base has no known vector archive (${misses.joinToString()})") + throw IOException( + "The IIAB box at $base has no zoom 0-14 OpenStreetMap archive (IIAB installs zoom " + + "0-8 by default). Download this region from the internet source instead. " + + "Tried: ${misses.joinToString()}" + ) } + /** + * The newest archive in the listing IIAB serves at `/maps/data/`, or null with the reason added + * to [misses]. The listing aliases `/maps/`, and the file is read from `/maps/` because + * only that location carries IIAB's pmtiles transfer settings. + */ + private fun listedArchive(base: String, misses: MutableList): String? { + val request = Request.Builder().url(base + LAN_LISTING_PATH).build() + TrafficStats.setThreadStatsTag(MAPS_TRAFFIC_STATS_TAG) + val listing = + try { + httpClient.newCall(request).execute().use { resp -> + val source = resp.body?.source() + if (resp.code != 200 || source == null) { + misses += "$LISTING_LABEL: ${resp.code}" + null + } else if (source.request(MAX_LISTING_BYTES + 1)) { + misses += "$LISTING_LABEL: too large" + null + } else { + source.buffer.readUtf8() + } + } + } catch (e: IOException) { + misses += "$LISTING_LABEL: no answer" + null + } finally { + TrafficStats.clearThreadStatsTag() + } ?: return null + val files = IiabCatalog.listedFiles(listing) + if (files == null) { + misses += "$LISTING_LABEL: not JSON" + return null + } + return IiabCatalog.newestVectorArchive(files).also { + if (it == null) misses += "$LISTING_LABEL: no z00-z14 archive" + } + } + /** HTTP status of a `bytes=0-0` GET on [url], or null if the request failed. */ private fun rangeProbe(url: String): Int? { val request = Request.Builder().url(url).header("Range", "bytes=0-0").build() diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt index 6f17aa74..1fd44a99 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/IiabCatalogTest.kt @@ -54,6 +54,27 @@ class IiabCatalogTest { assertNull(IiabCatalog.dateOf("naturalearth-openmaptiles.2025-12-10.z00-z08.pmtiles")) } + @Test + fun `listedFiles keeps file entries and rejects a listing that is not a JSON array`() { + val listing = """[{"name":"a.pmtiles","type":"file"},{"name":"d","type":"directory"},"x",{"type":"file"}]""" + assertEquals(listOf("a.pmtiles", ""), IiabCatalog.listedFiles(listing)) + assertNull(IiabCatalog.listedFiles("""{"name":"a.pmtiles"}""")) + assertNull(IiabCatalog.listedFiles("")) + } + + @Test + fun `newestVectorArchive picks the latest date and ignores near-miss names`() { + val names = + listOf( + "openstreetmap-openmaptiles.2026-07-01.z00-z14.pmtiles", + "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles.old", + "openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", + "openstreetmap-openmaptiles.2026-12-01.z00-z08.pmtiles", + ) + assertEquals("openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", IiabCatalog.newestVectorArchive(names)) + assertNull(IiabCatalog.newestVectorArchive(emptyList())) + } + @Test fun `resolver falls back when the fetch fails, returns null or yields an unusable catalog`() = runBlocking { val fallback = IiabCatalog.FALLBACK_VECTOR_ARCHIVE diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt index 5310fe97..0639febd 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt @@ -156,9 +156,14 @@ class RegionDownloaderEndToEndTest { "bundled basemap asset 'maps/natural-earth-z0-z4.pmtiles' unavailable", thrown!!.message, ) - // Only the LAN archive probe may run first, like the internet path's catalog fetch. - val ranges = List(server.requestCount) { server.takeRequest().getHeader("Range") } - assertEquals("no tile data may be read before the basemap", listOf("bytes=0-0"), ranges) + // Only LAN archive discovery (the listing, then the probe) may run first, like the internet + // path's catalog fetch. + val requests = List(server.requestCount) { server.takeRequest() } + assertEquals( + "no tile data may be read before the basemap", + listOf("/maps/data/" to null, "/maps/${IiabCatalog.FALLBACK_VECTOR_ARCHIVE}" to "bytes=0-0"), + requests.map { it.path to it.getHeader("Range") }, + ) } @Test diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt index 5af9d5e2..2adec300 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderLanArchiveTest.kt @@ -18,22 +18,40 @@ import org.junit.Test class RegionDownloaderLanArchiveTest { private val newArchive = "openstreetmap-openmaptiles.2026-10-01.z00-z14.pmtiles" + private val julyArchive = "openstreetmap-openmaptiles.2026-07-01.z00-z14.pmtiles" private val oldArchive = "openstreetmap-openmaptiles.2026-04-01.z00-z14.pmtiles" private val server = MockWebServer() - /** Answers [code] (a one-byte range by default) for each name in [held], 404 for anything else. */ - private fun serve(vararg held: String, code: Int = 206) { + /** + * Answers [code] (a one-byte range by default) for each name in [held], [listing] at + * `/maps/data/`, and 404 for anything else. + */ + private fun serve( + vararg held: String, + code: Int = 206, + listing: MockResponse = MockResponse().setResponseCode(404), + ) { server.dispatcher = object : Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse = - if (held.any { request.path == "/maps/$it" }) { - MockResponse().setResponseCode(code).setBody("P") - } else { - MockResponse().setResponseCode(404) + when { + request.path == "/maps/data/" -> listing + held.any { request.path == "/maps/$it" } -> + MockResponse().setResponseCode(code).setBody("P") + else -> MockResponse().setResponseCode(404) } } server.start() } + /** An nginx JSON autoindex entry, in the shape `autoindex_format json` emits. */ + private fun entry(name: String, type: String = "file") = + """{"name":"$name","type":"$type","mtime":"Wed, 01 Oct 2026 00:00:00 GMT","size":1024}""" + + private fun listing(vararg entries: String) = + MockResponse().setResponseCode(200).setBody(entries.joinToString(",", "[", "]")) + + private fun requestedPaths(): List = List(server.requestCount) { server.takeRequest().path } + private val host get() = server.url("/").toString() private fun resolve(resolver: IiabArchiveResolver = IiabArchiveResolver { null }) = @@ -72,7 +90,9 @@ class RegionDownloaderLanArchiveTest { fail("expected IOException for a box with no known archive") } catch (e: IOException) { val message = e.message.orEmpty() - assertTrue(message, message.contains("no known vector archive")) + assertTrue(message, message.contains("no zoom 0-14 OpenStreetMap archive")) + assertTrue(message, message.contains("internet source")) + assertTrue(message, message.contains("/maps/data/ listing: 404")) assertTrue(message, message.contains("$newArchive: 404")) assertTrue(message, message.contains("$oldArchive: 404")) } @@ -82,6 +102,7 @@ class RegionDownloaderLanArchiveTest { fun `a box that ignores the Range header and sends the whole file still counts as holding it`() { serve(newArchive, code = 200) assertEquals(server.url("/maps/$newArchive").toString(), resolve()) + assertEquals("/maps/data/", server.takeRequest().path) assertEquals("bytes=0-0", server.takeRequest().getHeader("Range")) } @@ -99,4 +120,67 @@ class RegionDownloaderLanArchiveTest { assertTrue(message, message.contains("$oldArchive: no answer")) } } + + @Test + fun `a listing with two archives picks the newer one without probing`() { + serve(newArchive, julyArchive, listing = listing(entry(julyArchive), entry(newArchive))) + assertEquals(server.url("/maps/$newArchive").toString(), resolve()) + assertEquals(listOf("/maps/data/"), requestedPaths()) + } + + @Test + fun `a retired archive renamed to pmtiles-old is ignored`() { + serve(listing = listing(entry(julyArchive), entry("$newArchive.old"))) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + } + + @Test + fun `directory entries are ignored`() { + serve(listing = listing(entry(newArchive, type = "directory"), entry(oldArchive))) + assertEquals(server.url("/maps/$oldArchive").toString(), resolve()) + } + + @Test + fun `a missing listing falls back to probing newest first`() { + serve(julyArchive) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + assertEquals( + listOf( + "/maps/data/", + "/maps/$newArchive", + "/maps/openstreetmap-openmaptiles.2026-09-01.z00-z14.pmtiles", + "/maps/$julyArchive", + ), + requestedPaths(), + ) + } + + @Test + fun `a listing that is not JSON falls back to probing`() { + serve(oldArchive, listing = MockResponse().setResponseCode(200).setBody("Index of /maps/")) + assertEquals(server.url("/maps/$oldArchive").toString(), resolve()) + } + + @Test + fun `a listing with no z00-z14 archive and no probe hit says to use the internet source`() { + serve(listing = listing(entry("osm-zoom-0-8.pmtiles"), entry("$newArchive.old"))) + try { + resolve() + fail("expected IOException for a box with only a zoom 0-8 archive") + } catch (e: IOException) { + val message = e.message.orEmpty() + assertTrue(message, message.contains("no zoom 0-14 OpenStreetMap archive")) + assertTrue(message, message.contains("IIAB installs zoom 0-8 by default")) + assertTrue(message, message.contains("internet source")) + assertTrue(message, message.contains("/maps/data/ listing: no z00-z14 archive")) + assertTrue(message, message.contains("$oldArchive: 404")) + } + } + + @Test + fun `an oversized listing is not read and falls back to probing`() { + val padded = "[" + entry(newArchive) + " ".repeat(1024 * 1024) + "]" + serve(julyArchive, listing = MockResponse().setResponseCode(200).setBody(padded)) + assertEquals(server.url("/maps/$julyArchive").toString(), resolve()) + } } From 30c5c5af590b786c537bcd3ae9560c356d1b3d50 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:15:56 -0700 Subject: [PATCH 63/81] docs(maps): name the Offline OSM Map template and MapLibre's retained context (ADFA-2436) maps.html said to use the "Maps" template or any project. The template is "Offline OSM Map" (MapTemplateBuilder.REGION_MAP_TEMPLATE_NAME), and apply refuses a project without its map activity (ProjectMapEmitter.apply). It also said nothing outlives dispose(); MapLibre keeps the application-scoped context MapLibreBootstrap gives it, with no teardown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- plugins/Maps/maps.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/Maps/maps.html b/plugins/Maps/maps.html index 171b21ef..b4cee513 100644 --- a/plugins/Maps/maps.html +++ b/plugins/Maps/maps.html @@ -116,16 +116,18 @@

Technical architecture

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().

+cancellable. dispose() cancels the plugin's coroutines, clears +its static references and releases its HTTP connections. One thing outlives it: +MapLibre keeps the application-scoped context the plugin gives it for the life of +the process, and has no teardown for it.

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 Code on the Go project.
  4. +
  5. Create a new project from the Offline OSM Map template + (Kotlin or Java). Regions can only be applied to a project made from this + template.
  6. Open the Maps tab in the editor bottom sheet.
  7. Tap + Download new region, draw a bounding box over the area you need, and confirm. The region downloads into the shared cache.
  8. From 9d3b6b50ff1846dc72f54878aae6c62e494a3a3a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:16:58 -0700 Subject: [PATCH 64/81] fix(maps): clear the two Kotlin compile warnings (ADFA-2436) - DownloadCompleteMessage.Spec: target @StringRes at the parameter explicitly, so a future Kotlin applying it to the field too changes nothing. - BboxPickerFragment.onRequestPermissionsResult: drop the super call. Fragment's implementation in fragment 1.8.8 is an empty method (javap: a bare return), and calling it was the deprecated use the compiler flagged. compileDebugKotlin + compileReleaseKotlin with --rerun --no-build-cache: 2 warnings before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/data/DownloadCompleteMessage.kt | 2 +- .../main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt index 6595d5fd..3b491dc1 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt @@ -29,7 +29,7 @@ internal object DownloadCompleteMessage { * hatch for users who wanted it applied. */ data class Spec( - @StringRes val messageRes: Int, + @param:StringRes val messageRes: Int, val messageArg: String, val showApplyAction: Boolean = false, ) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index 79ec4f3b..d30f3795 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -477,7 +477,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { permissions: Array, grantResults: IntArray ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // No super call: Fragment's implementation is empty (fragment 1.8.8) and deprecated. if (requestCode != REQUEST_LOCATION_PERMISSION) return val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED From 1d245b776abd68864ea7774b8ea4180de8b07bfd Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:18:30 -0700 Subject: [PATCH 65/81] fix(maps): cancel in-flight HTTP calls on dispose (ADFA-2436) Both OkHttp clients are plugin-owned singletons, so dispose() already evicted their idle sockets. A blocking execute() does not see the plugin scope's cancellation, though, and ran on until its 20 s / 60 s timeout. Cancel each client's dispatcher before evicting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/MapsPlugin.kt | 9 ++-- .../maps/MapsPluginCoverageTest.kt | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt index 8ee119a6..5b25d3af 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt @@ -162,9 +162,12 @@ class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { override fun dispose() { pluginScope.cancel() - // The shared clients outlive this instance, so close their idle sockets on unload. - RegionDownloader.httpClient.connectionPool.evictAll() - IiabArchiveResolver.catalogClient.connectionPool.evictAll() + // The shared clients outlive this instance, so on unload cancel their queued and running + // calls (a blocking execute() does not see the scope's cancellation) and close idle sockets. + for (client in listOf(RegionDownloader.httpClient, IiabArchiveResolver.catalogClient)) { + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + } // Clear the static plugin-context reference so a subsequent reload // doesn't leave Fragments resolving services from a defunct context. pluginContext = null diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt index 8b9b3b02..0143f163 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt @@ -408,4 +408,47 @@ class MapsPluginCoverageTest { server.shutdown() } } + + @Test + fun `dispose cancels calls still in flight on both shared http clients`() { + val server = okhttp3.mockwebserver.MockWebServer() + // The server never answers, so each call is still running when dispose() runs. + repeat(2) { + server.enqueue( + okhttp3.mockwebserver.MockResponse().setSocketPolicy(okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE) + ) + } + server.start() + try { + val clients = listOf(RegionDownloader.httpClient, IiabArchiveResolver.catalogClient) + val failures = java.util.concurrent.ConcurrentLinkedQueue() + val done = java.util.concurrent.CountDownLatch(clients.size) + val calls = clients.map { client -> + client.newCall(okhttp3.Request.Builder().url(server.url("/")).build()).also { call -> + call.enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + failures += e.message.orEmpty() + done.countDown() + } + + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + response.close() + done.countDown() + } + }) + } + } + repeat(2) { server.takeRequest(5, java.util.concurrent.TimeUnit.SECONDS) } + + val plugin = MapsPlugin() + plugin.initialize(newContext()) + plugin.dispose() + + assertTrue("both calls end well before the 5 s timeouts", done.await(3, java.util.concurrent.TimeUnit.SECONDS)) + assertEquals(listOf(true, true), calls.map { it.isCanceled() }) + assertEquals(listOf("Canceled", "Canceled"), failures.toList()) + } finally { + server.shutdown() + } + } } From 57b9be54a943365e86f94b59ac65588d1dcdb2c6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:25:23 -0700 Subject: [PATCH 66/81] fix(maps): device-walk UI fixes on the region list, save and download screens (ADFA-2436) - Save screen: the duplicate-name warning said "Save will replace it" while Save is disabled for a duplicate. It now says to pick another name; behaviour unchanged. - Region row: the active switch gets a content description ("Use this region in the project") so TalkBack reads more than "switch". - Delete and Cancel + discard: red text on the host's filled button measured 1.15:1 by day (#BA1A1A on #555555) and 1.37:1 at night (#FFB4AB on #AAAAAA) on the A06. The host button is dark by day and light at night, the reverse of the plugin surfaces, so a new plugin_error_on_button token takes #FFC0B8 by day (4.79:1) and #7F0008 at night (4.74:1). - Tab creation: getEditorTabs used a string resource for the title. The host calls it on the main thread, and the first plugin resource read there opens the plugin APK in the host's PluginResourceContext (StrictMode DiskReadViolation). The title is now a constant, which also lets a JVM test cover getEditorTabs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/MapsPlugin.kt | 10 +++- .../res/layout/fragment_download_progress.xml | 2 +- .../main/res/layout/fragment_save_region.xml | 2 +- .../Maps/src/main/res/layout/item_region.xml | 5 +- .../Maps/src/main/res/values-night/colors.xml | 2 + plugins/Maps/src/main/res/values/colors.xml | 6 ++ plugins/Maps/src/main/res/values/strings.xml | 4 +- .../maps/MapsPluginCoverageTest.kt | 58 ++++++------------- 8 files changed, 41 insertions(+), 48 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt index 5b25d3af..302dec31 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt @@ -66,6 +66,9 @@ class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { */ const val MAPS_BOTTOM_SHEET_TAB_ID = "maps_maps_tab" + /** The tab pill's label; the panel's own heading is R.string.maps_regions_title. */ + const val MAPS_TAB_TITLE = "Maps" + /** Tooltip tag for the bottom-sheet "Maps" tab. */ const val TOOLTIP_TAG_MAPS_TAB = "maps.bottom_sheet.maps_tab" @@ -187,9 +190,10 @@ class MapsPlugin : IPlugin, UIExtension, DocumentationExtension { 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), + // A literal, not a string resource: the host calls this on the main thread, and + // the first plugin resource read there opens the plugin APK (StrictMode + // DiskReadViolation in the host's PluginResourceContext). + title = MAPS_TAB_TITLE, fragmentFactory = { RegionManagerFragment() }, isEnabled = true, isVisible = true, diff --git a/plugins/Maps/src/main/res/layout/fragment_download_progress.xml b/plugins/Maps/src/main/res/layout/fragment_download_progress.xml index a68bb197..5802c262 100644 --- a/plugins/Maps/src/main/res/layout/fragment_download_progress.xml +++ b/plugins/Maps/src/main/res/layout/fragment_download_progress.xml @@ -165,7 +165,7 @@ android:id="@+id/btn_cancel" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="?attr/colorError" + android:textColor="@color/plugin_error_on_button" android:text="@string/maps_download_cancel_discard" /> diff --git a/plugins/Maps/src/main/res/layout/fragment_save_region.xml b/plugins/Maps/src/main/res/layout/fragment_save_region.xml index cd4bc66e..a5a215f2 100644 --- a/plugins/Maps/src/main/res/layout/fragment_save_region.xml +++ b/plugins/Maps/src/main/res/layout/fragment_save_region.xml @@ -85,7 +85,7 @@ android:textColor="?attr/colorError" android:visibility="gone" tools:visibility="visible" - tools:text="A region named 'Lalibela' already exists. Save will replace it." /> + tools:text="A region named 'Lalibela' already exists. Pick another name." /> @@ -127,6 +128,6 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/maps_regions_delete" - android:textColor="?attr/colorError" /> + android:textColor="@color/plugin_error_on_button" /> diff --git a/plugins/Maps/src/main/res/values-night/colors.xml b/plugins/Maps/src/main/res/values-night/colors.xml index dee9f6c7..3065e0a6 100644 --- a/plugins/Maps/src/main/res/values-night/colors.xml +++ b/plugins/Maps/src/main/res/values-night/colors.xml @@ -8,6 +8,8 @@ #FFB4AB + + #7F0008 #191C1A diff --git a/plugins/Maps/src/main/res/values/colors.xml b/plugins/Maps/src/main/res/values/colors.xml index b7272562..8982121a 100644 --- a/plugins/Maps/src/main/res/values/colors.xml +++ b/plugins/Maps/src/main/res/values/colors.xml @@ -14,6 +14,10 @@ accents: the bbox selection handle (BboxOverlayView) and the active-region badge / row highlight. Plugin-owned drawing, not Material. - plugin_error — error text in the plugin's own layouts. + - plugin_error_on_button — destructive text (Delete, Cancel + discard) on the + host's filled button, which is dark grey by day and light grey at night + (#555555 / #AAAAAA on the A06, 2026-10-27), so the light and dark values are + the reverse of plugin_error's. Each clears 4.5:1 on its grey. Light values here; dark in values-night/colors.xml (keep the two in sync). --> @@ -23,6 +27,8 @@ #BA1A1A + + #FFC0B8 #FBFDF8 diff --git a/plugins/Maps/src/main/res/values/strings.xml b/plugins/Maps/src/main/res/values/strings.xml index 47566242..16c29633 100644 --- a/plugins/Maps/src/main/res/values/strings.xml +++ b/plugins/Maps/src/main/res/values/strings.xml @@ -22,7 +22,6 @@ 2026-05-15: "Pick one active region to build into your app" — reinforces the per-project one-active-at-a-time rule (R2 scope). --> 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 @@ -30,6 +29,7 @@ Couldn\'t delete %1$s Refresh Use in this project + Use this region in the project ✓ In this project + Download new region Delete cached region? @@ -89,7 +89,7 @@ Center Extent Total to download - A region named "%1$s" already exists. Save will replace it. + A region named "%1$s" already exists. Pick another name. diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt index 0143f163..2cb12fe5 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/MapsPluginCoverageTest.kt @@ -2,7 +2,6 @@ package org.appdevforall.maps import android.content.Context import android.content.ContextWrapper -import android.content.res.Resources import com.itsaky.androidide.plugins.PluginContext import org.appdevforall.maps.data.IiabArchiveResolver import org.appdevforall.maps.data.RegionDownloader @@ -33,9 +32,8 @@ import org.junit.Test * `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`. + * TabItem (id / title / order / tooltipTag / enabled / visible), built with no + * resource read. * - 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`). @@ -100,31 +98,8 @@ class MapsPluginCoverageTest { 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 - } + /** A [Context] with no base, so any resource read through it fails the test. */ + private class FakeContext : ContextWrapper(null) /** PluginContext fake wiring the four sub-fakes together. */ private class FakePluginContext( @@ -149,7 +124,6 @@ class MapsPluginCoverageTest { 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 { @@ -157,7 +131,7 @@ class MapsPluginCoverageTest { } tmpDirs += dir return FakePluginContext( - androidCtx = FakeContext(FakeResources(strings)), + androidCtx = FakeContext(), services = FakeServiceRegistry(service), resources = FakeResourceManager(dir), logger = logger, @@ -314,14 +288,20 @@ class MapsPluginCoverageTest { // 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.) + @Test + fun `getEditorTabs builds the Maps tab without reading a plugin resource`() { + val plugin = MapsPlugin() + // FakeContext has no resources: a getString call returns null here and fails the TabItem. + plugin.initialize(newContext()) + + val tab = plugin.getEditorTabs().single() + + assertEquals("Maps", tab.title) + assertEquals(MapsPlugin.MAPS_BOTTOM_SHEET_TAB_ID, tab.id) + assertEquals(MapsPlugin.TOOLTIP_TAG_MAPS_TAB, tab.tooltipTag) + assertEquals(200, tab.order) + assertTrue(tab.isEnabled && tab.isVisible) + } // ---------------------------------------------------------------------- // Lifecycle From d3290e7021ffd262d252558bb1f2a41430196b9c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:35:08 -0700 Subject: [PATCH 67/81] refactor(maps): split the bbox picker and region manager fragments (ADFA-2436) BboxPickerFragment hands map driving (style load, first camera, camera listeners, projection, auto-shrink) to PickerMapController and the estimate lines to EstimateLabelBinder; its constants and the location and permission code stay where they were. RegionManagerFragment hands the row actions to RegionListActions and the post-download bookkeeping to DownloadCompletion. Both are suspend code with no Fragment references, written against a new ProjectRegions interface so the JVM tests run them over a real active.txt. Behaviour is unchanged: same snackbars, same refresh points, same tooltip wiring. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/data/DownloadCompletion.kt | 53 ++ .../maps/data/ProjectRegionCoordinator.kt | 29 +- .../maps/data/RegionListActions.kt | 88 ++++ .../maps/ui/BboxPickerFragment.kt | 455 +++--------------- .../maps/ui/EstimateLabelBinder.kt | 99 ++++ .../maps/ui/PickerMapController.kt | 253 ++++++++++ .../maps/ui/RegionManagerFragment.kt | 222 +++------ .../maps/data/DownloadCompletionTest.kt | 126 +++++ .../maps/data/FakeProjectRegions.kt | 52 ++ .../maps/data/RegionListActionsTest.kt | 152 ++++++ 10 files changed, 962 insertions(+), 567 deletions(-) create mode 100644 plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt create mode 100644 plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt create mode 100644 plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt create mode 100644 plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt create mode 100644 plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt create mode 100644 plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt create mode 100644 plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt new file mode 100644 index 00000000..f627c552 --- /dev/null +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt @@ -0,0 +1,53 @@ +package org.appdevforall.maps.data + +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.appdevforall.maps.util.runCatchingCancellable + +/** + * The bookkeeping after a download finishes: auto-activate the first region in the open project, + * and the snackbar's "Apply" when the project already had one. + * + * @param listRegions the cached regions; the default reads the shared cache + * @param cacheRoot the cache root [FirstRegionAutoActivator] reads; the default is the shared one + */ +internal class DownloadCompletion( + private val project: ProjectRegions, + private val listRegions: suspend () -> List = { RegionCache.list() }, + private val cacheRoot: suspend () -> File = { RegionCache.rootDir() }, +) { + + /** Where the completion left the project. */ + sealed class Outcome { + object NoProject : Outcome() + + data class Finished(val projectDir: File, val result: FirstRegionAutoActivator.Result) : + Outcome() + } + + /** Auto-activates [regionId] in the open project when that project has no active region yet. */ + suspend fun complete(regionId: String): Outcome = + withContext(Dispatchers.IO) { + val projectDir = project.currentProjectRoot() ?: return@withContext Outcome.NoProject + val result = + FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, + regionsCacheRoot = cacheRoot(), + downloadedRegionId = regionId, + applyRegionToProject = project::applyRegionToProject, + writeActiveRegion = project::writeActiveRegionId, + ) + Outcome.Finished(projectDir, result) + } + + /** Copies [regionId] into [projectDir] and makes it active; false if it is gone from the cache. */ + suspend fun applyFromSnackbar(projectDir: File, regionId: String): Boolean = + withContext(Dispatchers.IO) { + val info = + runCatchingCancellable { listRegions() }.getOrNull()?.firstOrNull { it.regionId == regionId } + ?: return@withContext false + project.applyAndActivate(info, projectDir) + } +} diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinator.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinator.kt index 60042633..2482b5a3 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinator.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/ProjectRegionCoordinator.kt @@ -8,6 +8,21 @@ import kotlinx.coroutines.withContext import java.io.File import kotlin.coroutines.coroutineContext +/** The project-side region operations; [ProjectRegionCoordinator] is the real one, tests fake it. */ +internal interface ProjectRegions { + suspend fun currentProjectRoot(): File? + + suspend fun readActiveRegionId(projectDir: File): String? + + suspend fun writeActiveRegionId(projectDir: File, regionId: String) + + suspend fun clearActiveRegionId(projectDir: File) + + suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean + + suspend fun applyRegionToProject(info: RegionInfo, projectDir: File): Boolean +} + /** * The project/active-region facade, extracted from `RegionManagerFragment` — * resolving the open project, reading/writing the per-project `active.txt` @@ -30,7 +45,7 @@ import kotlin.coroutines.coroutineContext internal class ProjectRegionCoordinator( private val pluginContextProvider: () -> PluginContext?, private val mapsSubpath: String = DEFAULT_PROJECT_MAPS_SUBPATH, -) { +) : ProjectRegions { companion object { /** @@ -46,7 +61,7 @@ internal class ProjectRegionCoordinator( * when no project is open or the service isn't available (running in * standalone test mode). */ - suspend fun currentProjectRoot(): File? = withContext(Dispatchers.IO) { + override suspend fun currentProjectRoot(): File? = withContext(Dispatchers.IO) { val ctx = pluginContextProvider() ?: return@withContext null val project = ctx.services.get(IdeProjectService::class.java)?.getCurrentProject() project?.rootDir @@ -61,14 +76,14 @@ internal class ProjectRegionCoordinator( return readActiveRegionId(projectDir) } - suspend fun readActiveRegionId(projectDir: File): String? = + override suspend fun readActiveRegionId(projectDir: File): String? = ActiveRegionStore.read(projectDir, mapsSubpath) - suspend fun writeActiveRegionId(projectDir: File, regionId: String) { + override suspend fun writeActiveRegionId(projectDir: File, regionId: String) { ActiveRegionStore.write(projectDir, mapsSubpath, regionId) } - suspend fun clearActiveRegionId(projectDir: File) { + override suspend fun clearActiveRegionId(projectDir: File) { ActiveRegionStore.clear(projectDir, mapsSubpath) } @@ -77,7 +92,7 @@ internal class ProjectRegionCoordinator( * download-complete, snackbar-apply, and toggle-on flows all need. Consolidated here so the * pairing (never write active.txt unless the copy succeeded) lives in one place. */ - suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { + override suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { val applied = applyRegionToProject(info, projectDir) if (applied) writeActiveRegionId(projectDir, info.regionId) return applied @@ -93,7 +108,7 @@ internal class ProjectRegionCoordinator( * [writeActiveRegionId] so the toggle flow can decouple "copy data" from * "mark active". */ - suspend fun applyRegionToProject( + override suspend fun applyRegionToProject( info: RegionInfo, projectDir: File, ): Boolean { diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt new file mode 100644 index 00000000..39c7c42f --- /dev/null +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt @@ -0,0 +1,88 @@ +package org.appdevforall.maps.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.appdevforall.maps.domain.RegionWizardStateMachine.Event +import org.appdevforall.maps.util.runCatchingCancellable + +/** + * The region list's row actions (toggle active, delete, re-download) as plain suspend functions, + * so the fragment only renders the result. + * + * @param listRegions the cached regions; the default reads the shared cache + * @param deleteRegion deletes one cached region, returning false on failure + */ +internal class RegionListActions( + private val project: ProjectRegions, + private val listRegions: suspend () -> List = { RegionCache.list() }, + private val deleteRegion: suspend (RegionInfo) -> Boolean = { RegionCache.delete(it) }, +) { + + /** What a toggle did, for the fragment to turn into a snackbar. */ + sealed class ToggleResult { + object NoProject : ToggleResult() + + object ApplyFailed : ToggleResult() + + data class Activated(val name: String) : ToggleResult() + + data class Switched(val fromName: String, val toName: String) : ToggleResult() + + data class Deactivated(val name: String) : ToggleResult() + } + + /** + * Makes [info] the open project's active region, or clears the active region when [newActive] + * is false. Activating copies the region in first and leaves `active.txt` alone if that fails. + */ + suspend fun toggleActive(info: RegionInfo, newActive: Boolean): ToggleResult = + withContext(Dispatchers.IO) { + val projectDir = project.currentProjectRoot() ?: return@withContext ToggleResult.NoProject + val priorActive = project.readActiveRegionId(projectDir) + val priorName = + priorActive?.let { activeId -> + runCatchingCancellable { listRegions() } + .getOrNull() + ?.firstOrNull { it.regionId == activeId } + ?.displayName + } + val ok = + if (newActive) { + project.applyAndActivate(info, projectDir) + } else { + project.clearActiveRegionId(projectDir) + true + } + when { + !ok -> ToggleResult.ApplyFailed + newActive && priorActive != null && priorActive != info.regionId -> + ToggleResult.Switched(priorName ?: priorActive, info.displayName) + newActive -> ToggleResult.Activated(info.displayName) + else -> ToggleResult.Deactivated(info.displayName) + } + } + + /** + * Deletes [info] from the cache and, if it was the open project's active region, clears + * `active.txt` so the next download can auto-activate. Returns whether the delete succeeded. + */ + suspend fun delete(info: RegionInfo): Boolean = + withContext(Dispatchers.IO) { + val ok = deleteRegion(info) + if (ok) { + val projectDir = project.currentProjectRoot() + if (projectDir != null && project.readActiveRegionId(projectDir) == info.regionId) { + project.clearActiveRegionId(projectDir) + } + } + ok + } + + /** The wizard event that re-opens the picker on [info]'s saved name and bounds. */ + fun redownloadRequest(info: RegionInfo): Event = + Event.RedownloadRequested( + regionId = info.regionId, + displayName = info.displayName, + bbox = info.bbox, + ) +} diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index d30f3795..584baa33 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -8,29 +8,13 @@ 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.IiabArchiveResolver -import org.appdevforall.maps.data.MapFontExtractor -import org.appdevforall.maps.data.RegionDownloader -import org.appdevforall.maps.data.RegionSizeEstimator import org.appdevforall.maps.domain.Bbox -import org.appdevforall.maps.domain.EstimateDisplay -import org.appdevforall.maps.domain.IiabStyleBuilder import org.appdevforall.maps.domain.SourceKind -import org.appdevforall.maps.domain.ZoomFit -import org.appdevforall.maps.domain.ZoomLabel import org.appdevforall.maps.domain.TileEstimate -import org.appdevforall.maps.util.runCatchingCancellable import com.google.android.material.button.MaterialButton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style @@ -81,7 +65,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { 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 + internal const val NO_GPS_DEFAULT_EDGE_KM = 200.0 private const val DEFAULT_BBOX_EDGE_KM = NO_GPS_DEFAULT_EDGE_KM @@ -90,32 +74,31 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { // 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 + internal const val NO_GPS_BBOX_SOUTH = 5.0 + internal const val NO_GPS_BBOX_NORTH = 25.0 + internal const val NO_GPS_BBOX_WEST = -5.0 + internal 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 + internal const val FALLBACK_LAT = 0.0 + internal const val FALLBACK_LON = 0.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 + internal const val NO_GPS_WORLD_ZOOM = 1.5 /** Screen fraction the GPS-default bbox occupies on first open (~half width). */ - private const val GPS_DEFAULT_SCREEN_FRACTION = 0.5 + internal const val GPS_DEFAULT_SCREEN_FRACTION = 0.5 /** * 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 + internal const val AUTO_SHRINK_DEBOUNCE_MS = 1_000L /** * Inset the auto-shrunk bbox by this fraction of the viewport on each side. @@ -123,7 +106,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { * 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 + internal const val AUTO_SHRINK_MARGIN = 0.35 fun newInstance( prefillRegionId: String? = null, @@ -153,6 +136,12 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { private lateinit var btnBack: MaterialButton private lateinit var btnNext: MaterialButton + /** The estimate lines and Next button; recreated per view. */ + private var estimateBinder: EstimateLabelBinder? = null + + /** Style, camera and auto-shrink for the map; created once the map is ready. */ + private var mapController: PickerMapController? = null + /** Retained so we can enable the LocationComponent once style + permission are both ready. */ private var mapLibreMap: MapLibreMap? = null @@ -170,26 +159,6 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { private var prefillRegionId: String? = null private var prefillDisplayName: String? = null - /** - * Runs the debounced "download size" slicer call. Recreated per view (it captures the - * view-scoped coroutine scope); [onEstimateState] maps its results onto our state + views. - */ - private var sizeEstimator: RegionSizeEstimator? = 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 - private var cameraMoveStartedListener: - org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener? = 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 - /** * Owns location permission + GPS-fix plumbing. Created lazily so it picks up * the live [mapLibreMap] and applies a resolved fix to wizard state via the @@ -263,14 +232,18 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { // prefills a saved region's bbox/name; the returned prefillBbox is used later // (once the style is ready) to position the camera on that saved region. val prefillBbox = applyLaunchArgs() - // 3. The debounced "how big is this download?" slicer. Scoped to the view's - // lifecycle so it's cancelled with the view; each result comes back through - // onEstimateState, which folds it into selectionModel + re-renders the label. - sizeEstimator = RegionSizeEstimator( + // 3. The estimate lines and their debounced "how big is this download?" slicer, + // scoped to the view's lifecycle so it's cancelled with the view. + val binder = EstimateLabelBinder( + selectionModel = selectionModel, + sizeLine = estimateSizeLine, + dimsLine = estimateFree, + overlay = bboxOverlay, + nextButton = btnNext, scope = viewLifecycleOwner.lifecycleScope, - httpClient = RegionDownloader.httpClient, - onState = ::onEstimateState, + isViewAlive = { this.view != null }, ) + estimateBinder = binder // 4. Bring MapLibre up asynchronously. configureMap runs once the map is ready // and wires the style, gestures, camera listeners, and projection (and applies @@ -288,7 +261,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { listOf(estimateFree, estimateSizeLine, btnNext) .forEach { it.showMapsTooltipOnLongPress(MapsPlugin.TOOLTIP_TAG_BBOX_PICKER) } // 6. Paint the initial "km × km — max zoom N" line before the first settle. - updateBboxDimsLabel() + binder.renderDimensions() } private fun bindViews(view: View) { @@ -318,123 +291,29 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { return args.prefillBbox } - /** Wire the freshly-ready [MapLibreMap]: load the style, then gestures + camera + projection. */ + /** Hand the freshly-ready [MapLibreMap] to a [PickerMapController]. */ private fun configureMap(map: MapLibreMap, prefillBbox: DoubleArray?) { mapLibreMap = map - loadStyleThenPosition(map, 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. (The AnnotationManager dimen-resolve - // crash is prevented by the plugin-resource ContextWrapper in onCreateView, not here.) - map.addOnMapClickListener { true } - wireCameraListeners(map) - wireProjection(map) - } - - /** - * Resolve the IIAB PMTiles URL + extract glyphs off the main thread (both touch disk, which - * trips StrictMode on the UI thread), then apply the style and position the camera. The URL is - * the same one RegionDownloader uses, so the estimate ≈ the download and the basemap matches. - */ - private fun loadStyleThenPosition(map: MapLibreMap, prefillBbox: DoubleArray?) { - viewLifecycleOwner.lifecycleScope.launch { - val (pmtilesUrl, fontsRoot) = withContext(Dispatchers.IO) { - val resources = MapsPlugin.pluginContext?.resources - val cacheDir = context?.cacheDir - val fonts = if (resources != null && cacheDir != null) { - MapFontExtractor.extract(resources, cacheDir) - } else { - null - } - // Resolve the current archive before the first estimate builds its URL. - when (selectionModel.sourceKind) { - SourceKind.INTERNET -> IiabArchiveResolver.shared.resolve() - // A box missing every known archive surfaces as an estimate error instead. - SourceKind.IIAB_LAN -> runCatchingCancellable { - RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, selectionModel.sourceHost) - } - else -> Unit - } - resolveIiabTilesUrl() to fonts?.absolutePath - } - if (view == null) return@launch - val styleJson = IiabStyleBuilder.buildStyle(pmtilesUrl, fontsRoot) - map.setStyle(Style.Builder().fromJson(styleJson)) { style -> - applyStyleReady(map, style, prefillBbox) - } - } - } - - private fun wireCameraListeners(map: MapLibreMap) { - // 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 - selectionModel.updateAnchor(center.latitude, center.longitude) - bboxOverlay.recomputePixelRect() - scheduleAutoShrinkBbox(map) - } - // Auto-shrink only tracks USER-driven camera changes: a REASON_API_GESTURE - // move-started event is the one signal that the user (not our own camera - // placement, and not a transient layout/animation state) is driving the - // viewport. Without this gate, the camera-idle that follows the Refresh - // flow's programmatic saved-bounds fit could collapse the saved selection - // to a fraction of whatever viewport the idle happened to report - // (ADFA-2436: 1054×1047 km region → 98×160 km on open). - val moveStartedListener = org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener { reason -> - if (reason == - org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE - ) { - selectionModel.noteUserCameraGesture() - } - } - map.addOnCameraMoveListener(moveListener) - map.addOnCameraIdleListener(idleListener) - map.addOnCameraMoveStartedListener(moveStartedListener) - cameraMoveListener = moveListener - cameraIdleListener = idleListener - cameraMoveStartedListener = moveStartedListener - } - - /** Let the overlay convert between geographic and screen coordinates via the map's projection. */ - private fun wireProjection(map: MapLibreMap) { - 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 - }, - ) + val binder = estimateBinder ?: return + mapController = PickerMapController( + map = map, + overlay = bboxOverlay, + selectionModel = selectionModel, + scope = viewLifecycleOwner.lifecycleScope, + mapWidth = { mapView.width }, + cacheDir = { context?.cacheDir }, + isViewAlive = { this.view != null }, + onBboxChanged = binder::onBboxChanged, + onStyleReady = { style -> onStyleReady(map, style) }, + ).also { it.start(prefillBbox) } } private fun onBboxResized(newBbox: Bbox) = applyBboxChange(newBbox) - /** - * Shared tail for every "the bbox changed" path — corner-handle resize, - * initial/GPS placement ([applyInitialBbox]), and the auto-shrink check. - * Applies the state transition on [model] (adopt bbox, re-cap zoomMax, - * invalidate the estimate), then re-renders the dependent views and kicks - * a fresh slicer estimate. Previously copy-pasted in three methods. - */ + /** A corner-handle resize: adopt the bbox, re-render, restart the estimate. */ private fun applyBboxChange(newBbox: Bbox) { selectionModel.applyBboxChange(newBbox) - renderBboxState() - } - - /** Re-render everything that depends on the bbox/estimate state + restart the estimate. */ - private fun renderBboxState() { - renderEstimate() - updateBboxDimsLabel() - scheduleRealEstimate() + estimateBinder?.onBboxChanged() } // ----- Map lifecycle proxying (onStart is at dialog-setup section above) ----- @@ -445,23 +324,11 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() } override fun onDestroyView() { super.onDestroyView() - sizeEstimator?.cancel() - sizeEstimator = null - 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) } - cameraMoveStartedListener?.let { map.removeOnCameraMoveStartedListener(it) } - } - } - } - cameraMoveListener = null - cameraIdleListener = null - cameraMoveStartedListener = null + estimateBinder?.cancel() + estimateBinder = null + // Unregister the listeners that capture the overlay before disposing the map view. + mapController?.release() + mapController = null runCatching { mapView.onDestroy() } } override fun onSaveInstanceState(outState: Bundle) { @@ -498,66 +365,17 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { // bbox) — a late GPS fix shouldn't clobber the user's saved bounds. val isPrefill = BboxPickerArgs.fromBundle(arguments).prefillBbox != null if (isPrefill) return - val map = mapLibreMap ?: return - applyInitialBbox(map, Bbox.aroundPoint(selectionModel.anchorLat, selectionModel.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 archive comes from IIAB's catalog (resolved in - * [loadStyleThenPosition]). 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 (selectionModel.sourceKind) { - SourceKind.INTERNET -> RegionDownloader.buildTilesUrl(selectionModel.sourceKind, null) - SourceKind.IIAB_LAN -> { - val host = selectionModel.sourceHost?.trim().orEmpty() - if (host.isBlank()) null - else RegionDownloader.buildTilesUrl(selectionModel.sourceKind, host) - } - else -> null - } - }.onFailure { - MapsPlugin.pluginContext?.logger?.warn( - "resolveIiabTilesUrl failed: ${it.message}" + mapController?.applyInitialBbox( + Bbox.aroundPoint(selectionModel.anchorLat, selectionModel.anchorLon, GPS_DEFAULT_EDGE_KM), ) - }.getOrNull() + } /** - * Called once the MapLibre style has finished loading. Positions the camera, - * enables the GPS dot if permission is granted, and queues the location modal. + * After the first camera placement: enable the GPS dot and fetch a fix if location is + * already granted. Never requests the permission: the host APK does not declare it, so the + * system dialog would never appear and the user would be stranded. */ - 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) - applyInitialBbox(map, initialBbox, worldView = worldView) + private fun onStyleReady(map: MapLibreMap, style: Style) { if (ContextCompat.checkSelfPermission( requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED @@ -565,169 +383,6 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { locationController.enableLocationComponent(map, style) locationController.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) { - bboxOverlay.setBboxLatLon(bbox) - val centerLat = (bbox.south + bbox.north) / 2.0 - // No-GPS path opens at world view; otherwise fit the bbox to ~half the viewport width. - val targetZoom: Double = if (worldView) { - NO_GPS_WORLD_ZOOM - } else { - ZoomFit.fitZoom(bbox, mapView.width, GPS_DEFAULT_SCREEN_FRACTION) - } - 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; applyBboxChange invalidates + reschedules the estimate. - applyBboxChange(bbox) - } - - /** - * 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() { - // Decision logic (1 GB cap, calculating/failed/done states) lives in the pure, - // unit-tested EstimateDisplay; here we only map its result onto the views. - val state = EstimateDisplay.compute( - realBytes = selectionModel.realByteEstimate, - estimateFailed = selectionModel.realEstimateFailed, - hasSliceableSource = selectionModel.hasSliceableSource(), - // budget constants default to EstimateDisplay's own (the 1 GB cap + allowance). - ) - estimateSizeLine.text = state.text - // Resolve through the VIEW's context (from the themed plugin inflater), - // not requireContext() — the host context can't resolve plugin R.color - // ids, and only the plugin context picks up the values-night variants. - estimateSizeLine.setTextColor( - estimateSizeLine.context.getColor( - if (state.isError) R.color.plugin_error else R.color.plugin_on_surface_variant, - ), - ) - bboxOverlay.setOverBudget(state.overBudget) - btnNext.isEnabled = state.nextEnabled - } - - /** - * Kick off a debounced download-size estimate for the current bbox. The orchestration - * (debounce, timeout, cache, staleness) lives in [RegionSizeEstimator]; [onEstimateState] - * maps its results back onto our state + views. - */ - private fun scheduleRealEstimate() { - if (!selectionModel.hasSliceableSource()) return - sizeEstimator?.estimate( - tilesUrl = RegionDownloader.buildTilesUrl(selectionModel.sourceKind, selectionModel.sourceHost), - bbox = selectionModel.currentBbox, - zoomMin = selectionModel.zoomMin, - zoomMax = selectionModel.zoomMax, - ) - } - - /** Apply an estimator [state] on the model + re-render the size line. */ - private fun onEstimateState(state: RegionSizeEstimator.State) { - selectionModel.onEstimateState(state) - if (view != null) renderEstimate() - } - - /** - * 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. - * - * Only ever acts after the user's first real pan/zoom gesture — the model - * gates [BboxSelectionModel.maybeShrinkToFit] on - * [BboxSelectionModel.userHasDrivenCamera], so idles caused by our own - * camera placement (initial fit, Refresh saved-bounds fit) never collapse - * a selection the user hasn't touched. - * - * **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 - // The decision + state transition live in the model; a null return means - // the bbox was already fully visible (or the viewport was degenerate). - val newBbox = selectionModel.maybeShrinkToFit( - viewN = bounds.latitudeNorth, - viewS = bounds.latitudeSouth, - viewE = bounds.longitudeEast, - viewW = bounds.longitudeWest, - margin = AUTO_SHRINK_MARGIN, - ) ?: return - bboxOverlay.setBboxLatLon(newBbox) - renderBboxState() - } - - /** - * 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 = selectionModel.currentBbox.widthKm().toInt() - val height = selectionModel.currentBbox.heightKm().toInt() - val label = ZoomLabel.forZoom(selectionModel.zoomMax) - estimateFree.text = - "${width}km × ${height}km — max zoom level ${selectionModel.zoomMax} ($label)" - // Same rule as renderEstimate: resolve via the themed view context so the - // plugin palette (and its dark-mode variant) wins, not the host theme. - estimateFree.setTextColor( - estimateFree.context.getColor(R.color.plugin_on_surface_variant) - ) } // ----- Navigation ----- diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt new file mode 100644 index 00000000..90126777 --- /dev/null +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt @@ -0,0 +1,99 @@ +package org.appdevforall.maps.ui + +import android.view.View +import android.widget.TextView +import kotlinx.coroutines.CoroutineScope +import org.appdevforall.maps.R +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.data.RegionSizeEstimator +import org.appdevforall.maps.domain.EstimateDisplay +import org.appdevforall.maps.domain.ZoomLabel + +/** + * Renders the bbox picker's size and dimensions lines and the Next button from [selectionModel], + * and runs the debounced size estimate that feeds them. + * + * @param scope the picker's view-lifecycle scope, so the estimate dies with the view + * @param isViewAlive false once the picker's view is gone, so a late estimate renders nothing + */ +internal class EstimateLabelBinder( + private val selectionModel: BboxSelectionModel, + private val sizeLine: TextView, + private val dimsLine: TextView, + private val overlay: BboxOverlayView, + private val nextButton: View, + scope: CoroutineScope, + private val isViewAlive: () -> Boolean, +) { + + private var sizeEstimator: RegionSizeEstimator? = + RegionSizeEstimator( + scope = scope, + httpClient = RegionDownloader.httpClient, + onState = ::onEstimateState, + ) + + /** Re-renders both lines for a changed bbox and restarts the estimate. */ + fun onBboxChanged() { + renderEstimate() + renderDimensions() + scheduleRealEstimate() + } + + /** Stops the estimate; call from `onDestroyView`. */ + fun cancel() { + sizeEstimator?.cancel() + sizeEstimator = null + } + + /** + * The size line, its colour, the overlay's over-budget tint and Next's enabled state. The + * decisions (1 GB cap, calculating / failed / done) are [EstimateDisplay]'s. + */ + private fun renderEstimate() { + val state = + EstimateDisplay.compute( + realBytes = selectionModel.realByteEstimate, + estimateFailed = selectionModel.realEstimateFailed, + hasSliceableSource = selectionModel.hasSliceableSource(), + ) + sizeLine.text = state.text + // The view's own context is the themed plugin one: the host context cannot resolve plugin + // colour ids, and only the plugin context picks up the values-night variants. + sizeLine.setTextColor( + sizeLine.context.getColor( + if (state.isError) R.color.plugin_error else R.color.plugin_on_surface_variant + ) + ) + overlay.setOverBudget(state.overBudget) + nextButton.isEnabled = state.nextEnabled + } + + /** + * The "2220km × 2140km — max zoom level 12 (town)" line. Zoom names follow the OpenStreetMap + * wiki's "Zoom levels" page, with Mapbox / Bing names where it has none. + */ + fun renderDimensions() { + val width = selectionModel.currentBbox.widthKm().toInt() + val height = selectionModel.currentBbox.heightKm().toInt() + val label = ZoomLabel.forZoom(selectionModel.zoomMax) + dimsLine.text = "${width}km × ${height}km — max zoom level ${selectionModel.zoomMax} ($label)" + dimsLine.setTextColor(dimsLine.context.getColor(R.color.plugin_on_surface_variant)) + } + + private fun scheduleRealEstimate() { + if (!selectionModel.hasSliceableSource()) return + sizeEstimator?.estimate( + tilesUrl = + RegionDownloader.buildTilesUrl(selectionModel.sourceKind, selectionModel.sourceHost), + bbox = selectionModel.currentBbox, + zoomMin = selectionModel.zoomMin, + zoomMax = selectionModel.zoomMax, + ) + } + + private fun onEstimateState(state: RegionSizeEstimator.State) { + selectionModel.onEstimateState(state) + if (isViewAlive()) renderEstimate() + } +} diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt new file mode 100644 index 00000000..61309ffd --- /dev/null +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt @@ -0,0 +1,253 @@ +package org.appdevforall.maps.ui + +import android.graphics.PointF +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.appdevforall.maps.MapsPlugin +import org.appdevforall.maps.data.IiabArchiveResolver +import org.appdevforall.maps.data.MapFontExtractor +import org.appdevforall.maps.data.RegionDownloader +import org.appdevforall.maps.domain.Bbox +import org.appdevforall.maps.domain.IiabStyleBuilder +import org.appdevforall.maps.domain.SourceKind +import org.appdevforall.maps.domain.ZoomFit +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.AUTO_SHRINK_DEBOUNCE_MS +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.AUTO_SHRINK_MARGIN +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.FALLBACK_LAT +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.FALLBACK_LON +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.GPS_DEFAULT_SCREEN_FRACTION +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_BBOX_EAST +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_BBOX_NORTH +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_BBOX_SOUTH +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_BBOX_WEST +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_DEFAULT_EDGE_KM +import org.appdevforall.maps.ui.BboxPickerFragment.Companion.NO_GPS_WORLD_ZOOM +import org.appdevforall.maps.util.runCatchingCancellable +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style + +/** + * Drives the bbox picker's [MapLibreMap]: loads the IIAB style, places the first camera and + * bbox, keeps [overlay] projected through pans and zooms, and shrinks the bbox to fit after the + * user's own pans. + * + * @param mapWidth the map view's current width in pixels, for fitting the first bbox + * @param cacheDir where [MapFontExtractor] unpacks glyphs, or null once the fragment detaches + * @param isViewAlive false once the picker's view is gone, so late callbacks do nothing + * @param onBboxChanged re-renders whatever depends on the selection after the bbox changes + * @param onStyleReady runs after the first camera placement, for the picker's location wiring + */ +internal class PickerMapController( + private val map: MapLibreMap, + private val overlay: BboxOverlayView, + private val selectionModel: BboxSelectionModel, + private val scope: CoroutineScope, + private val mapWidth: () -> Int, + private val cacheDir: () -> File?, + private val isViewAlive: () -> Boolean, + private val onBboxChanged: () -> Unit, + private val onStyleReady: (Style) -> Unit, +) { + + // Held so release() can unregister them: they close over the overlay and would leak across + // dialog re-creates otherwise. + private var cameraMoveListener: MapLibreMap.OnCameraMoveListener? = null + private var cameraIdleListener: MapLibreMap.OnCameraIdleListener? = null + private var cameraMoveStartedListener: MapLibreMap.OnCameraMoveStartedListener? = null + + /** The pending auto-shrink check, restarted on every camera idle so only the last one runs. */ + private var autoShrinkJob: Job? = null + + /** Loads the style, then wires gestures, camera listeners and projection. */ + fun start(prefillBbox: DoubleArray?) { + loadStyleThenPosition(prefillBbox) + // The bbox is stored in lat/lon: camera moves only re-project it to fresh pixels. + map.uiSettings.isRotateGesturesEnabled = false + // No annotations on the picker, so consume map clicks. (The AnnotationManager dimen-resolve + // crash is prevented by MapLibreBootstrap's plugin-resource context, not here.) + map.addOnMapClickListener { true } + wireCameraListeners() + wireProjection() + } + + /** Cancels the auto-shrink check and unregisters the camera listeners. */ + fun release() { + autoShrinkJob?.cancel() + runCatching { + cameraMoveListener?.let { map.removeOnCameraMoveListener(it) } + cameraIdleListener?.let { map.removeOnCameraIdleListener(it) } + cameraMoveStartedListener?.let { map.removeOnCameraMoveStartedListener(it) } + } + cameraMoveListener = null + cameraIdleListener = null + cameraMoveStartedListener = null + } + + /** + * Sets [bbox] and moves the camera to fit it to [GPS_DEFAULT_SCREEN_FRACTION] of the map width, + * or to [NO_GPS_WORLD_ZOOM] when [worldView] is true. + */ + fun applyInitialBbox(bbox: Bbox, worldView: Boolean = false) { + overlay.setBboxLatLon(bbox) + val targetZoom = + if (worldView) NO_GPS_WORLD_ZOOM + else ZoomFit.fitZoom(bbox, mapWidth(), GPS_DEFAULT_SCREEN_FRACTION) + map.cameraPosition = + CameraPosition.Builder() + .target(LatLng((bbox.south + bbox.north) / 2.0, (bbox.west + bbox.east) / 2.0)) + .zoom(targetZoom) + .build() + // The camera move re-projects the overlay through the listeners. + selectionModel.applyBboxChange(bbox) + onBboxChanged() + } + + /** + * Resolves the tiles URL and extracts glyphs on `Dispatchers.IO` (both touch disk), then applies + * the style. The URL is the one the download reads, so the basemap and estimate match it. + */ + private fun loadStyleThenPosition(prefillBbox: DoubleArray?) { + scope.launch { + val (pmtilesUrl, fontsRoot) = + withContext(Dispatchers.IO) { + val resources = MapsPlugin.pluginContext?.resources + val cacheDir = cacheDir() + val fonts = + if (resources != null && cacheDir != null) { + MapFontExtractor.extract(resources, cacheDir) + } else { + null + } + // Resolve the current archive before the first estimate builds its URL. + when (selectionModel.sourceKind) { + SourceKind.INTERNET -> IiabArchiveResolver.shared.resolve() + // A box with no archive surfaces as an estimate error instead. + SourceKind.IIAB_LAN -> + runCatchingCancellable { + RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, selectionModel.sourceHost) + } + else -> Unit + } + resolveIiabTilesUrl() to fonts?.absolutePath + } + if (!isViewAlive()) return@launch + val styleJson = IiabStyleBuilder.buildStyle(pmtilesUrl, fontsRoot) + map.setStyle(Style.Builder().fromJson(styleJson)) { style -> + applyStyleReady(style, prefillBbox) + } + } + } + + /** + * Places the first bbox: the saved one on the Refresh flow, else a 20-degree box at world view. + * A location fix later swaps in its own box through [applyInitialBbox]. + */ + private fun applyStyleReady(style: Style, prefillBbox: DoubleArray?) { + 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 + } + applyInitialBbox(initialBbox, worldView = prefillBbox == null) + onStyleReady(style) + } + + private fun wireCameraListeners() { + val moveListener = MapLibreMap.OnCameraMoveListener { overlay.recomputePixelRect() } + val idleListener = + MapLibreMap.OnCameraIdleListener { + val center = map.cameraPosition.target ?: return@OnCameraIdleListener + selectionModel.updateAnchor(center.latitude, center.longitude) + overlay.recomputePixelRect() + scheduleAutoShrinkBbox() + } + // Only a REASON_API_GESTURE move is the user driving the viewport. Without this gate the idle + // after the Refresh flow's own saved-bounds fit collapsed a 1054x1047 km region to 98x160 km. + val moveStartedListener = + MapLibreMap.OnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { + selectionModel.noteUserCameraGesture() + } + } + map.addOnCameraMoveListener(moveListener) + map.addOnCameraIdleListener(idleListener) + map.addOnCameraMoveStartedListener(moveStartedListener) + cameraMoveListener = moveListener + cameraIdleListener = idleListener + cameraMoveStartedListener = moveStartedListener + } + + /** Lets the overlay convert between geographic and screen coordinates. */ + private fun wireProjection() { + overlay.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(PointF(x, y)) + ll.latitude to ll.longitude + }, + ) + } + + /** + * The tiles URL for the source picked in Step 1, from [RegionDownloader.buildTilesUrl] so it is + * the file the download reads. Null when no source is set yet, which loads a blank background. + */ + private fun resolveIiabTilesUrl(): String? = + runCatching { + when (selectionModel.sourceKind) { + SourceKind.INTERNET -> RegionDownloader.buildTilesUrl(selectionModel.sourceKind, null) + SourceKind.IIAB_LAN -> { + val host = selectionModel.sourceHost?.trim().orEmpty() + if (host.isBlank()) null + else RegionDownloader.buildTilesUrl(selectionModel.sourceKind, host) + } + else -> null + } + } + .onFailure { MapsPlugin.pluginContext?.logger?.warn("resolveIiabTilesUrl failed: ${it.message}") } + .getOrNull() + + /** + * Shrinks the bbox to the viewport [AUTO_SHRINK_DEBOUNCE_MS] after the camera settles, if an + * edge left the view. The model ignores idles before the user's first gesture. + */ + private fun scheduleAutoShrinkBbox() { + autoShrinkJob?.cancel() + autoShrinkJob = + scope.launch { + delay(AUTO_SHRINK_DEBOUNCE_MS) + if (!isViewAlive()) return@launch + maybeShrinkBboxToFit() + } + } + + private fun maybeShrinkBboxToFit() { + val bounds = runCatching { map.projection.visibleRegion.latLngBounds }.getOrNull() ?: return + // Null means the bbox was already fully visible, or the viewport was degenerate. + val newBbox = + selectionModel.maybeShrinkToFit( + viewN = bounds.latitudeNorth, + viewS = bounds.latitudeSouth, + viewE = bounds.longitudeEast, + viewW = bounds.longitudeWest, + margin = AUTO_SHRINK_MARGIN, + ) ?: return + overlay.setBboxLatLon(newBbox) + onBboxChanged() + } +} diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt index 40ee5c7a..d6a05418 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt @@ -20,15 +20,16 @@ import org.appdevforall.maps.domain.TileEstimate import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import org.appdevforall.maps.data.DownloadCompleteMessage -import org.appdevforall.maps.data.FirstRegionAutoActivator +import org.appdevforall.maps.data.DownloadCompletion import org.appdevforall.maps.data.ProjectRegionCoordinator import org.appdevforall.maps.data.RegionCache import org.appdevforall.maps.data.RegionInfo +import org.appdevforall.maps.data.RegionListActions +import org.appdevforall.maps.data.RegionListActions.ToggleResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import org.appdevforall.maps.util.runCatchingCancellable /** * Host-resolved Fragment for the "Maps" panel. Registered as a @@ -97,6 +98,8 @@ class RegionManagerFragment : Fragment(), private val projectRegions = ProjectRegionCoordinator( pluginContextProvider = { MapsPlugin.pluginContext }, ) + private val listActions = RegionListActions(projectRegions) + private val downloadCompletion = DownloadCompletion(projectRegions) override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater = themedPluginInflater(super.onGetLayoutInflater(savedInstanceState)) @@ -202,17 +205,12 @@ class RegionManagerFragment : Fragment(), private fun showSourcePicker(step: Step.SourcePicker) { 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 + showWizardChrome(R.string.maps_wizard_title_source) val frag = SourcePickerFragment.newInstance( prefillRegionName = step.prefillRegionName, prefillRegionId = step.prefillRegionId, ) - childFragmentManager.beginTransaction() - .replace(R.id.picker_container, frag, SOURCE_PICKER_TAG) - .commit() + showStepFragment(frag, SOURCE_PICKER_TAG) } private fun showBboxPicker(step: Step.BboxPicker) { @@ -236,10 +234,7 @@ class RegionManagerFragment : Fragment(), } private fun showStep3Save(step: Step.SaveStep) { - wizardContainer?.visibility = View.VISIBLE - wizardTitle?.setText(R.string.maps_wizard_title_save) - btnDownloadNew?.visibility = View.GONE - onBackPressedCallback.isEnabled = true + showWizardChrome(R.string.maps_wizard_title_save) val bbox = step.bbox ?: return // estimate is null when the user tapped Next before the slicer // returned; Step 3 handles that as "Calculating download size…". @@ -251,16 +246,11 @@ class RegionManagerFragment : Fragment(), prefillRegionId = step.prefillRegionId, prefillDisplayName = step.prefillRegionName, ) - childFragmentManager.beginTransaction() - .replace(R.id.picker_container, frag, STEP3_SAVE_TAG) - .commit() + showStepFragment(frag, STEP3_SAVE_TAG) } private fun showDownloadProgress(step: Step.DownloadProgress) { - wizardContainer?.visibility = View.VISIBLE - wizardTitle?.setText(R.string.maps_wizard_title_download) - btnDownloadNew?.visibility = View.GONE - onBackPressedCallback.isEnabled = true + showWizardChrome(R.string.maps_wizard_title_download) val bbox = step.bbox ?: return val frag = DownloadProgressFragment.newInstance( regionId = step.regionId, @@ -273,9 +263,19 @@ class RegionManagerFragment : Fragment(), zoomMin = step.zoomMin, zoomMax = step.zoomMax, ) - childFragmentManager.beginTransaction() - .replace(R.id.picker_container, frag, DOWNLOAD_PROGRESS_TAG) - .commit() + showStepFragment(frag, DOWNLOAD_PROGRESS_TAG) + } + + /** The title bar and BACK handling every in-panel wizard step shares. */ + private fun showWizardChrome(titleRes: Int) { + wizardContainer?.visibility = View.VISIBLE + wizardTitle?.setText(titleRes) + btnDownloadNew?.visibility = View.GONE + onBackPressedCallback.isEnabled = true + } + + private fun showStepFragment(frag: Fragment, tag: String) { + childFragmentManager.beginTransaction().replace(R.id.picker_container, frag, tag).commit() } private fun showList() { @@ -348,153 +348,78 @@ class RegionManagerFragment : Fragment(), // ----- 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, and - // [DownloadCompleteMessage] for the (also tested) result → Snackbar - // mapping this Fragment renders. + // The policy is FirstRegionAutoActivator's and the message mapping is + // DownloadCompleteMessage's; both are unit-tested, this only renders. 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) { projectRegions.currentProjectRoot() } - if (projectDir == null) { - render(wizard.onEvent(Event.DownloadDone)) - Snackbar.make( - requireView(), - getString(R.string.maps_download_done_no_project, regionId), - Snackbar.LENGTH_LONG, - ).show() + val outcome = downloadCompletion.complete(regionId) + render(wizard.onEvent(Event.DownloadDone)) + if (outcome !is DownloadCompletion.Outcome.Finished) { + snack(getString(R.string.maps_download_done_no_project, regionId), Snackbar.LENGTH_LONG) return@launch } - val result = withContext(Dispatchers.IO) { - FirstRegionAutoActivator.maybeAutoActivate( - projectDir = projectDir, - mapsSubpath = ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, - regionsCacheRoot = RegionCache.rootDir(), - downloadedRegionId = regionId, - applyRegionToProject = projectRegions::applyRegionToProject, - writeActiveRegion = projectRegions::writeActiveRegionId, - ) - } - render(wizard.onEvent(Event.DownloadDone)) - val spec = DownloadCompleteMessage.forResult(result, regionId) + val spec = DownloadCompleteMessage.forResult(outcome.result, regionId) + // Indefinite iff there's an action to take, so the user is not racing a timeout to Apply. val snackbar = Snackbar.make( requireView(), getString(spec.messageRes, spec.messageArg), - // Indefinite iff there's an action to take — give the user time - // to tap "Apply" instead of racing a timeout. if (spec.showApplyAction) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_LONG, ) if (spec.showApplyAction) { snackbar.setAction(R.string.maps_download_apply_action) { - applyDownloadedRegionFromSnackbar(projectDir, regionId) + applyDownloadedRegionFromSnackbar(outcome.projectDir, regionId) } } 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). - */ + /** The post-download snackbar's "Apply", for a project that already had an active region. */ private fun applyDownloadedRegionFromSnackbar(projectDir: File, regionId: String) { viewLifecycleOwner.lifecycleScope.launch { - val applied = withContext(Dispatchers.IO) { - val info = runCatchingCancellable { RegionCache.list() }.getOrNull() - ?.firstOrNull { it.regionId == regionId } ?: return@withContext false - projectRegions.applyAndActivate(info, projectDir) - } - if (applied) { + if (downloadCompletion.applyFromSnackbar(projectDir, regionId)) { refresh() - Snackbar.make( - requireView(), - getString(R.string.maps_regions_switched, regionId), - Snackbar.LENGTH_SHORT, - ).show() + snack(getString(R.string.maps_regions_switched, regionId), Snackbar.LENGTH_SHORT) } else { - Snackbar.make( - requireView(), - getString(R.string.maps_regions_apply_failed), - Snackbar.LENGTH_LONG, - ).show() + snack(getString(R.string.maps_regions_apply_failed), Snackbar.LENGTH_LONG) } } } override fun onDownloadCancelled() { render(wizard.onEvent(Event.DownloadCancelled)) - Snackbar.make( - requireView(), - getString(R.string.maps_download_cancelled), - Snackbar.LENGTH_SHORT, - ).show() + snack(getString(R.string.maps_download_cancelled), Snackbar.LENGTH_SHORT) } override fun onDownloadFailed(message: String) { render(wizard.onEvent(Event.DownloadFailed)) - Snackbar.make( - requireView(), - getString(R.string.maps_download_failed, message), - Snackbar.LENGTH_LONG, - ).show() + snack(getString(R.string.maps_download_failed, message), Snackbar.LENGTH_LONG) + } + + private fun snack(message: String, length: Int) { + Snackbar.make(requireView(), message, length).show() } // ----- RegionAdapter.Listener ----- override fun onRegionToggleActive(info: RegionInfo, newActive: Boolean) { viewLifecycleOwner.lifecycleScope.launch { - val projectDir = withContext(Dispatchers.IO) { projectRegions.currentProjectRoot() } - if (projectDir == null) { - Snackbar.make( - requireView(), - R.string.maps_regions_no_project, - Snackbar.LENGTH_LONG, - ).show() - return@launch - } - val priorActive = withContext(Dispatchers.IO) { - projectRegions.readActiveRegionId(projectDir) - } - val priorName = priorActive?.let { activeId -> - withContext(Dispatchers.IO) { runCatchingCancellable { 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). - projectRegions.applyAndActivate(info, projectDir) - } else { - // Deactivate: clear active.txt. - projectRegions.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) + val result = listActions.toggleActive(info, newActive) + when (result) { + ToggleResult.NoProject -> + snack(getString(R.string.maps_regions_no_project), Snackbar.LENGTH_LONG) + ToggleResult.ApplyFailed -> + snack(getString(R.string.maps_regions_apply_failed), Snackbar.LENGTH_LONG) + is ToggleResult.Switched -> snack( + getString(R.string.maps_region_switched_active, result.fromName, result.toName), + Snackbar.LENGTH_SHORT, + ) + is ToggleResult.Activated -> + snack(getString(R.string.maps_region_activated, result.name), Snackbar.LENGTH_SHORT) + is ToggleResult.Deactivated -> + snack(getString(R.string.maps_region_deactivated, result.name), Snackbar.LENGTH_SHORT) } - Snackbar.make(requireView(), statusMsg, Snackbar.LENGTH_SHORT).show() - refresh() + // A failed apply re-renders too, so the switch snaps back to the stored state. + if (result != ToggleResult.NoProject) refresh() } } @@ -505,25 +430,12 @@ class RegionManagerFragment : Fragment(), .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.maps_regions_delete) { _, _ -> viewLifecycleOwner.lifecycleScope.launch { - val ok = withContext(Dispatchers.IO) { RegionCache.delete(info) } - 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 = projectRegions.currentProjectRoot() - if (projectDir != null && - projectRegions.readActiveRegionId(projectDir) == info.regionId - ) { - projectRegions.clearActiveRegionId(projectDir) - } - } - } + val ok = listActions.delete(info) val msg = getString( if (ok) R.string.maps_regions_deleted else R.string.maps_regions_delete_failed, info.displayName, ) - Snackbar.make(requireView(), msg, Snackbar.LENGTH_SHORT).show() + snack(msg, Snackbar.LENGTH_SHORT) refresh() } } @@ -534,17 +446,7 @@ class RegionManagerFragment : Fragment(), showMapsTooltip(anchor, MapsPlugin.TOOLTIP_TAG_REGION_LIST) 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. The machine - // parses (and validates) the raw bounds tuple. - render( - wizard.onEvent( - Event.RedownloadRequested( - regionId = info.regionId, - displayName = info.displayName, - bbox = info.bbox, - ), - ), - ) + // Re-opens the wizard on this region's saved name and bounds; the machine validates them. + render(wizard.onEvent(listActions.redownloadRequest(info))) } } diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt new file mode 100644 index 00000000..47e085fb --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt @@ -0,0 +1,126 @@ +package org.appdevforall.maps.data + +import java.io.File +import java.nio.file.Files +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.assertTrue +import org.junit.Before +import org.junit.Test + +/** [DownloadCompletion] against a temp project and a temp region cache. */ +class DownloadCompletionTest { + + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private lateinit var project: FakeProjectRegions + private var listFails = false + + private val completion by lazy { + DownloadCompletion( + project = project, + listRegions = { if (listFails) error("cache unreadable") else RegionCache.listFromRoot(cacheRoot) }, + cacheRoot = { cacheRoot }, + ) + } + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-dc-project").toFile() + cacheRoot = Files.createTempDirectory("maps-dc-cache").toFile() + project = FakeProjectRegions(projectDir) + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } + + private fun seed(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(16)) + } + + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + + @Test + fun `no open project is reported and nothing is copied`() = runBlocking { + seed("alpha") + project.projectDir = null + assertEquals(DownloadCompletion.Outcome.NoProject, completion.complete("alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `the first region in a project is activated in that project`() = runBlocking { + seed("alpha", "Alpha Town") + val outcome = completion.complete("alpha") + assertEquals( + DownloadCompletion.Outcome.Finished( + projectDir, + FirstRegionAutoActivator.Result.Activated("alpha", "Alpha Town"), + ), + outcome, + ) + assertEquals("alpha", active()) + } + + @Test + fun `a project with a live active region keeps it`() = runBlocking { + seed("alpha") + seed("beta") + project.writeActiveRegionId(projectDir, "beta") + val outcome = completion.complete("alpha") as DownloadCompletion.Outcome.Finished + assertEquals(FirstRegionAutoActivator.Result.NoOpAlreadyActive, outcome.result) + assertEquals("beta", active()) + } + + @Test + fun `snackbar apply copies the region in and activates it`() = runBlocking { + seed("alpha") + project.writeActiveRegionId(projectDir, "beta") + assertTrue(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals(listOf("alpha"), project.applied) + assertEquals("alpha", active()) + } + + @Test + fun `snackbar apply of a region gone from the cache does nothing`() = runBlocking { + seed("beta") + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `snackbar apply with an unreadable cache does nothing`() = runBlocking { + seed("alpha") + listFails = true + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `a failed snackbar copy reports false and leaves active_txt alone`() = runBlocking { + seed("alpha") + project.writeActiveRegionId(projectDir, "beta") + project.applyResult = false + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals("beta", active()) + } +} diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt new file mode 100644 index 00000000..e002a9bc --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt @@ -0,0 +1,52 @@ +package org.appdevforall.maps.data + +import java.io.File + +/** + * A [ProjectRegions] over a temp project dir: the active id is the real `active.txt` through + * [ActiveRegionStore], and the region copy only records its call and returns [applyResult]. + */ +internal class FakeProjectRegions( + var projectDir: File?, + var applyResult: Boolean = true, +) : ProjectRegions { + + val applied = mutableListOf() + + override suspend fun currentProjectRoot(): File? = projectDir + + override suspend fun readActiveRegionId(projectDir: File): String? = + ActiveRegionStore.read(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + + override suspend fun writeActiveRegionId(projectDir: File, regionId: String) { + ActiveRegionStore.write(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, regionId) + } + + override suspend fun clearActiveRegionId(projectDir: File) { + ActiveRegionStore.clear(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + } + + override suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { + val ok = applyRegionToProject(info, projectDir) + if (ok) writeActiveRegionId(projectDir, info.regionId) + return ok + } + + override suspend fun applyRegionToProject(info: RegionInfo, projectDir: File): Boolean { + applied += info.regionId + return applyResult + } +} + +/** A [RegionInfo] whose directory need not exist; enough for code that only reads its fields. */ +internal fun regionInfo(id: String, name: String = "Name of $id", bbox: DoubleArray? = null) = + RegionInfo( + regionId = id, + displayName = name, + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = File("/nonexistent/$id"), + bbox = bbox, + ) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt new file mode 100644 index 00000000..4d6cae94 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt @@ -0,0 +1,152 @@ +package org.appdevforall.maps.data + +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.runBlocking +import org.appdevforall.maps.data.RegionListActions.ToggleResult +import org.appdevforall.maps.domain.RegionWizardStateMachine.Event +import org.junit.After +import org.junit.Assert.assertArrayEquals +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 + +/** [RegionListActions] against a real `active.txt` in a temp project and an in-memory cache. */ +class RegionListActionsTest { + + private lateinit var projectDir: File + private lateinit var project: FakeProjectRegions + private val alpha = regionInfo("alpha", "Alpha Town") + private val beta = regionInfo("beta", "Beta City") + private var cached = listOf(alpha, beta) + private var listFails = false + private var deleteResult = true + private val deleted = mutableListOf() + + private val actions by lazy { + RegionListActions( + project = project, + listRegions = { if (listFails) error("cache unreadable") else cached }, + deleteRegion = { + deleted += it.regionId + deleteResult + }, + ) + } + + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-rla").toFile() + project = FakeProjectRegions(projectDir) + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + } + + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + + private fun setActive(id: String) = runBlocking { project.writeActiveRegionId(projectDir, id) } + + @Test + fun `toggle with no open project reports it and touches nothing`() = runBlocking { + project.projectDir = null + assertEquals(ToggleResult.NoProject, actions.toggleActive(alpha, true)) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `toggle on with no prior active region activates it`() = runBlocking { + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + assertEquals(listOf("alpha"), project.applied) + } + + @Test + fun `toggle on over another active region names both`() = runBlocking { + setActive("beta") + assertEquals(ToggleResult.Switched("Beta City", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `a prior region missing from the cache is named by its id`() = runBlocking { + setActive("gone") + assertEquals(ToggleResult.Switched("gone", "Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `an unreadable cache still switches, naming the prior region by its id`() = runBlocking { + setActive("beta") + listFails = true + assertEquals(ToggleResult.Switched("beta", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `toggling on the region that is already active is an activation, not a switch`() = + runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `a failed copy reports it and leaves the prior active region in place`() = runBlocking { + setActive("beta") + project.applyResult = false + assertEquals(ToggleResult.ApplyFailed, actions.toggleActive(alpha, true)) + assertEquals("beta", active()) + } + + @Test + fun `toggle off clears the active region without copying`() = runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Deactivated("Alpha Town"), actions.toggleActive(alpha, false)) + assertNull(active()) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `deleting the active region clears active_txt`() = runBlocking { + setActive("alpha") + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + assertNull(active()) + } + + @Test + fun `deleting another region keeps the active one`() = runBlocking { + setActive("beta") + assertTrue(actions.delete(alpha)) + assertEquals("beta", active()) + } + + @Test + fun `a failed delete reports false and keeps active_txt`() = runBlocking { + setActive("alpha") + deleteResult = false + assertFalse(actions.delete(alpha)) + assertEquals("alpha", active()) + } + + @Test + fun `deleting with no open project still deletes`() = runBlocking { + project.projectDir = null + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + } + + @Test + fun `redownload re-opens the wizard on the region's id, name and bounds`() { + val bounds = doubleArrayOf(1.0, 2.0, 3.0, 4.0) + val event = actions.redownloadRequest(regionInfo("gamma", "Gamma", bounds)) + event as Event.RedownloadRequested + assertEquals("gamma", event.regionId) + assertEquals("Gamma", event.displayName) + assertArrayEquals(bounds, event.bbox, 0.0) + } +} From fca0a2c50fd42bb9c739f76b3bd205316377cfd8 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 18:56:15 -0700 Subject: [PATCH 68/81] fix(maps): escape the quotes aapt2 was stripping from two strings (ADFA-2436) aapt2 drops an unescaped double quote from a string resource, and " counts as unescaped. The device walk showed the duplicate-name warning as 'A region named Lalibela already exists'; the empty-list hint lost the quotes round "+ Download new region" the same way. Both now use \". A JVM test reads every values*/strings.xml and fails on any quote aapt2 would drop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../main/res/layout/fragment_save_region.xml | 2 +- plugins/Maps/src/main/res/values/strings.xml | 4 +- .../maps/res/StringResourceQuotesTest.kt | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt diff --git a/plugins/Maps/src/main/res/layout/fragment_save_region.xml b/plugins/Maps/src/main/res/layout/fragment_save_region.xml index a5a215f2..82553fba 100644 --- a/plugins/Maps/src/main/res/layout/fragment_save_region.xml +++ b/plugins/Maps/src/main/res/layout/fragment_save_region.xml @@ -85,7 +85,7 @@ android:textColor="?attr/colorError" android:visibility="gone" tools:visibility="visible" - tools:text="A region named 'Lalibela' already exists. Pick another name." /> + tools:text="A region named "Lalibela" already exists. Pick another name." /> Pick one active region to build into your app - No regions cached yet. Tap "+ Download new region" to add one. + No regions cached yet. Tap \"+ Download new region\" to add one. No project open. Open a Map project to apply a region. Delete Deleted %1$s @@ -89,7 +89,7 @@ Center Extent Total to download - A region named "%1$s" already exists. Pick another name. + A region named \"%1$s\" already exists. Pick another name. diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt new file mode 100644 index 00000000..114c4118 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/res/StringResourceQuotesTest.kt @@ -0,0 +1,40 @@ +package org.appdevforall.maps.res + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +/** + * aapt2 drops an unescaped `"` from a string resource, `"` included, so the device shows the + * text without its quotes. Every literal double quote in a plugin string must be written `\"`. + */ +class StringResourceQuotesTest { + + @Test + fun `every double quote in a string resource is backslash-escaped`() { + val files = + File("src/main/res").listFiles { f -> f.name.startsWith("values") }!!.mapNotNull { + File(it, "strings.xml").takeIf(File::isFile) + } + assertTrue("no strings.xml found under src/main/res", files.isNotEmpty()) + var checked = 0 + val bad = mutableListOf() + for (file in files) { + val strings = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).getElementsByTagName("string") + for (i in 0 until strings.length) { + val element = strings.item(i) as Element + checked++ + // After XML decoding, a quote aapt2 keeps is one preceded by a backslash. + if (Regex("""(? 0) + assertEquals("strings with a quote aapt2 will drop (of $checked checked)", emptyList(), bad) + } +} From 3c059eae350d5d4fbd0e0914b584ebabbd76a76d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 19:30:25 -0700 Subject: [PATCH 69/81] fix(maps): keep save-summary keys and download snackbars readable at font scale 2.0 (ADFA-2436) At 2.0 the save screen's "Source" key broke mid-word because the value row claimed the width, and the download-complete snackbar cut off "Project's active region is unchanged" at Material's 2-line default. The value now takes the spare width and wraps; snackbars allow 5 lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/ui/RegionManagerFragment.kt | 10 +++++++--- plugins/Maps/src/main/res/layout/item_summary_row.xml | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt index d6a05418..ee32cb90 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt @@ -66,6 +66,7 @@ class RegionManagerFragment : Fragment(), const val BBOX_PICKER_TAG = "maps_bbox_picker" const val STEP3_SAVE_TAG = "maps_step3_save" const val DOWNLOAD_PROGRESS_TAG = "maps_download_progress" + const val SNACK_MAX_LINES = 5 } private var listView: RecyclerView? = null @@ -359,8 +360,7 @@ class RegionManagerFragment : Fragment(), } val spec = DownloadCompleteMessage.forResult(outcome.result, regionId) // Indefinite iff there's an action to take, so the user is not racing a timeout to Apply. - val snackbar = Snackbar.make( - requireView(), + val snackbar = makeSnack( getString(spec.messageRes, spec.messageArg), if (spec.showApplyAction) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_LONG, ) @@ -396,9 +396,13 @@ class RegionManagerFragment : Fragment(), } private fun snack(message: String, length: Int) { - Snackbar.make(requireView(), message, length).show() + makeSnack(message, length).show() } + // Material's default of 2 lines cut "active region is unchanged" off at font scale 2.0. + private fun makeSnack(message: String, length: Int): Snackbar = + Snackbar.make(requireView(), message, length).setTextMaxLines(SNACK_MAX_LINES) + // ----- RegionAdapter.Listener ----- override fun onRegionToggleActive(info: RegionInfo, newActive: Boolean) { diff --git a/plugins/Maps/src/main/res/layout/item_summary_row.xml b/plugins/Maps/src/main/res/layout/item_summary_row.xml index 738b8eeb..6100f521 100644 --- a/plugins/Maps/src/main/res/layout/item_summary_row.xml +++ b/plugins/Maps/src/main/res/layout/item_summary_row.xml @@ -2,6 +2,7 @@ From 298216a96c7c746c7edba3c08f16726eef0bb11f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 19:48:47 -0700 Subject: [PATCH 70/81] style(maps): 4-space indent in the files split out of the two fragments (ADFA-2436) The split in d3290e7 wrote seven new files with 2-space indents; the rest of the module uses 4. Whitespace only (git diff -w is empty). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/data/DownloadCompletion.kt | 64 +-- .../maps/data/RegionListActions.kt | 122 +++--- .../maps/ui/EstimateLabelBinder.kt | 142 +++---- .../maps/ui/PickerMapController.kt | 376 +++++++++--------- .../maps/data/DownloadCompletionTest.kt | 192 ++++----- .../maps/data/FakeProjectRegions.kt | 62 +-- .../maps/data/RegionListActionsTest.kt | 260 ++++++------ 7 files changed, 609 insertions(+), 609 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt index f627c552..f2ef9619 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt @@ -13,41 +13,41 @@ import org.appdevforall.maps.util.runCatchingCancellable * @param cacheRoot the cache root [FirstRegionAutoActivator] reads; the default is the shared one */ internal class DownloadCompletion( - private val project: ProjectRegions, - private val listRegions: suspend () -> List = { RegionCache.list() }, - private val cacheRoot: suspend () -> File = { RegionCache.rootDir() }, + private val project: ProjectRegions, + private val listRegions: suspend () -> List = { RegionCache.list() }, + private val cacheRoot: suspend () -> File = { RegionCache.rootDir() }, ) { - /** Where the completion left the project. */ - sealed class Outcome { - object NoProject : Outcome() + /** Where the completion left the project. */ + sealed class Outcome { + object NoProject : Outcome() - data class Finished(val projectDir: File, val result: FirstRegionAutoActivator.Result) : - Outcome() - } - - /** Auto-activates [regionId] in the open project when that project has no active region yet. */ - suspend fun complete(regionId: String): Outcome = - withContext(Dispatchers.IO) { - val projectDir = project.currentProjectRoot() ?: return@withContext Outcome.NoProject - val result = - FirstRegionAutoActivator.maybeAutoActivate( - projectDir = projectDir, - mapsSubpath = ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, - regionsCacheRoot = cacheRoot(), - downloadedRegionId = regionId, - applyRegionToProject = project::applyRegionToProject, - writeActiveRegion = project::writeActiveRegionId, - ) - Outcome.Finished(projectDir, result) + data class Finished(val projectDir: File, val result: FirstRegionAutoActivator.Result) : + Outcome() } - /** Copies [regionId] into [projectDir] and makes it active; false if it is gone from the cache. */ - suspend fun applyFromSnackbar(projectDir: File, regionId: String): Boolean = - withContext(Dispatchers.IO) { - val info = - runCatchingCancellable { listRegions() }.getOrNull()?.firstOrNull { it.regionId == regionId } - ?: return@withContext false - project.applyAndActivate(info, projectDir) - } + /** Auto-activates [regionId] in the open project when that project has no active region yet. */ + suspend fun complete(regionId: String): Outcome = + withContext(Dispatchers.IO) { + val projectDir = project.currentProjectRoot() ?: return@withContext Outcome.NoProject + val result = + FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, + regionsCacheRoot = cacheRoot(), + downloadedRegionId = regionId, + applyRegionToProject = project::applyRegionToProject, + writeActiveRegion = project::writeActiveRegionId, + ) + Outcome.Finished(projectDir, result) + } + + /** Copies [regionId] into [projectDir] and makes it active; false if it is gone from the cache. */ + suspend fun applyFromSnackbar(projectDir: File, regionId: String): Boolean = + withContext(Dispatchers.IO) { + val info = + runCatchingCancellable { listRegions() }.getOrNull()?.firstOrNull { it.regionId == regionId } + ?: return@withContext false + project.applyAndActivate(info, projectDir) + } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt index 39c7c42f..5a7e54d4 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionListActions.kt @@ -13,76 +13,76 @@ import org.appdevforall.maps.util.runCatchingCancellable * @param deleteRegion deletes one cached region, returning false on failure */ internal class RegionListActions( - private val project: ProjectRegions, - private val listRegions: suspend () -> List = { RegionCache.list() }, - private val deleteRegion: suspend (RegionInfo) -> Boolean = { RegionCache.delete(it) }, + private val project: ProjectRegions, + private val listRegions: suspend () -> List = { RegionCache.list() }, + private val deleteRegion: suspend (RegionInfo) -> Boolean = { RegionCache.delete(it) }, ) { - /** What a toggle did, for the fragment to turn into a snackbar. */ - sealed class ToggleResult { - object NoProject : ToggleResult() + /** What a toggle did, for the fragment to turn into a snackbar. */ + sealed class ToggleResult { + object NoProject : ToggleResult() - object ApplyFailed : ToggleResult() + object ApplyFailed : ToggleResult() - data class Activated(val name: String) : ToggleResult() + data class Activated(val name: String) : ToggleResult() - data class Switched(val fromName: String, val toName: String) : ToggleResult() + data class Switched(val fromName: String, val toName: String) : ToggleResult() - data class Deactivated(val name: String) : ToggleResult() - } + data class Deactivated(val name: String) : ToggleResult() + } - /** - * Makes [info] the open project's active region, or clears the active region when [newActive] - * is false. Activating copies the region in first and leaves `active.txt` alone if that fails. - */ - suspend fun toggleActive(info: RegionInfo, newActive: Boolean): ToggleResult = - withContext(Dispatchers.IO) { - val projectDir = project.currentProjectRoot() ?: return@withContext ToggleResult.NoProject - val priorActive = project.readActiveRegionId(projectDir) - val priorName = - priorActive?.let { activeId -> - runCatchingCancellable { listRegions() } - .getOrNull() - ?.firstOrNull { it.regionId == activeId } - ?.displayName - } - val ok = - if (newActive) { - project.applyAndActivate(info, projectDir) - } else { - project.clearActiveRegionId(projectDir) - true + /** + * Makes [info] the open project's active region, or clears the active region when [newActive] + * is false. Activating copies the region in first and leaves `active.txt` alone if that fails. + */ + suspend fun toggleActive(info: RegionInfo, newActive: Boolean): ToggleResult = + withContext(Dispatchers.IO) { + val projectDir = project.currentProjectRoot() ?: return@withContext ToggleResult.NoProject + val priorActive = project.readActiveRegionId(projectDir) + val priorName = + priorActive?.let { activeId -> + runCatchingCancellable { listRegions() } + .getOrNull() + ?.firstOrNull { it.regionId == activeId } + ?.displayName + } + val ok = + if (newActive) { + project.applyAndActivate(info, projectDir) + } else { + project.clearActiveRegionId(projectDir) + true + } + when { + !ok -> ToggleResult.ApplyFailed + newActive && priorActive != null && priorActive != info.regionId -> + ToggleResult.Switched(priorName ?: priorActive, info.displayName) + newActive -> ToggleResult.Activated(info.displayName) + else -> ToggleResult.Deactivated(info.displayName) + } } - when { - !ok -> ToggleResult.ApplyFailed - newActive && priorActive != null && priorActive != info.regionId -> - ToggleResult.Switched(priorName ?: priorActive, info.displayName) - newActive -> ToggleResult.Activated(info.displayName) - else -> ToggleResult.Deactivated(info.displayName) - } - } - /** - * Deletes [info] from the cache and, if it was the open project's active region, clears - * `active.txt` so the next download can auto-activate. Returns whether the delete succeeded. - */ - suspend fun delete(info: RegionInfo): Boolean = - withContext(Dispatchers.IO) { - val ok = deleteRegion(info) - if (ok) { - val projectDir = project.currentProjectRoot() - if (projectDir != null && project.readActiveRegionId(projectDir) == info.regionId) { - project.clearActiveRegionId(projectDir) + /** + * Deletes [info] from the cache and, if it was the open project's active region, clears + * `active.txt` so the next download can auto-activate. Returns whether the delete succeeded. + */ + suspend fun delete(info: RegionInfo): Boolean = + withContext(Dispatchers.IO) { + val ok = deleteRegion(info) + if (ok) { + val projectDir = project.currentProjectRoot() + if (projectDir != null && project.readActiveRegionId(projectDir) == info.regionId) { + project.clearActiveRegionId(projectDir) + } + } + ok } - } - ok - } - /** The wizard event that re-opens the picker on [info]'s saved name and bounds. */ - fun redownloadRequest(info: RegionInfo): Event = - Event.RedownloadRequested( - regionId = info.regionId, - displayName = info.displayName, - bbox = info.bbox, - ) + /** The wizard event that re-opens the picker on [info]'s saved name and bounds. */ + fun redownloadRequest(info: RegionInfo): Event = + Event.RedownloadRequested( + regionId = info.regionId, + displayName = info.displayName, + bbox = info.bbox, + ) } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt index 90126777..38b91e9c 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt @@ -17,83 +17,83 @@ import org.appdevforall.maps.domain.ZoomLabel * @param isViewAlive false once the picker's view is gone, so a late estimate renders nothing */ internal class EstimateLabelBinder( - private val selectionModel: BboxSelectionModel, - private val sizeLine: TextView, - private val dimsLine: TextView, - private val overlay: BboxOverlayView, - private val nextButton: View, - scope: CoroutineScope, - private val isViewAlive: () -> Boolean, + private val selectionModel: BboxSelectionModel, + private val sizeLine: TextView, + private val dimsLine: TextView, + private val overlay: BboxOverlayView, + private val nextButton: View, + scope: CoroutineScope, + private val isViewAlive: () -> Boolean, ) { - private var sizeEstimator: RegionSizeEstimator? = - RegionSizeEstimator( - scope = scope, - httpClient = RegionDownloader.httpClient, - onState = ::onEstimateState, - ) + private var sizeEstimator: RegionSizeEstimator? = + RegionSizeEstimator( + scope = scope, + httpClient = RegionDownloader.httpClient, + onState = ::onEstimateState, + ) - /** Re-renders both lines for a changed bbox and restarts the estimate. */ - fun onBboxChanged() { - renderEstimate() - renderDimensions() - scheduleRealEstimate() - } + /** Re-renders both lines for a changed bbox and restarts the estimate. */ + fun onBboxChanged() { + renderEstimate() + renderDimensions() + scheduleRealEstimate() + } - /** Stops the estimate; call from `onDestroyView`. */ - fun cancel() { - sizeEstimator?.cancel() - sizeEstimator = null - } + /** Stops the estimate; call from `onDestroyView`. */ + fun cancel() { + sizeEstimator?.cancel() + sizeEstimator = null + } - /** - * The size line, its colour, the overlay's over-budget tint and Next's enabled state. The - * decisions (1 GB cap, calculating / failed / done) are [EstimateDisplay]'s. - */ - private fun renderEstimate() { - val state = - EstimateDisplay.compute( - realBytes = selectionModel.realByteEstimate, - estimateFailed = selectionModel.realEstimateFailed, - hasSliceableSource = selectionModel.hasSliceableSource(), - ) - sizeLine.text = state.text - // The view's own context is the themed plugin one: the host context cannot resolve plugin - // colour ids, and only the plugin context picks up the values-night variants. - sizeLine.setTextColor( - sizeLine.context.getColor( - if (state.isError) R.color.plugin_error else R.color.plugin_on_surface_variant - ) - ) - overlay.setOverBudget(state.overBudget) - nextButton.isEnabled = state.nextEnabled - } + /** + * The size line, its colour, the overlay's over-budget tint and Next's enabled state. The + * decisions (1 GB cap, calculating / failed / done) are [EstimateDisplay]'s. + */ + private fun renderEstimate() { + val state = + EstimateDisplay.compute( + realBytes = selectionModel.realByteEstimate, + estimateFailed = selectionModel.realEstimateFailed, + hasSliceableSource = selectionModel.hasSliceableSource(), + ) + sizeLine.text = state.text + // The view's own context is the themed plugin one: the host context cannot resolve plugin + // colour ids, and only the plugin context picks up the values-night variants. + sizeLine.setTextColor( + sizeLine.context.getColor( + if (state.isError) R.color.plugin_error else R.color.plugin_on_surface_variant + ) + ) + overlay.setOverBudget(state.overBudget) + nextButton.isEnabled = state.nextEnabled + } - /** - * The "2220km × 2140km — max zoom level 12 (town)" line. Zoom names follow the OpenStreetMap - * wiki's "Zoom levels" page, with Mapbox / Bing names where it has none. - */ - fun renderDimensions() { - val width = selectionModel.currentBbox.widthKm().toInt() - val height = selectionModel.currentBbox.heightKm().toInt() - val label = ZoomLabel.forZoom(selectionModel.zoomMax) - dimsLine.text = "${width}km × ${height}km — max zoom level ${selectionModel.zoomMax} ($label)" - dimsLine.setTextColor(dimsLine.context.getColor(R.color.plugin_on_surface_variant)) - } + /** + * The "2220km × 2140km — max zoom level 12 (town)" line. Zoom names follow the OpenStreetMap + * wiki's "Zoom levels" page, with Mapbox / Bing names where it has none. + */ + fun renderDimensions() { + val width = selectionModel.currentBbox.widthKm().toInt() + val height = selectionModel.currentBbox.heightKm().toInt() + val label = ZoomLabel.forZoom(selectionModel.zoomMax) + dimsLine.text = "${width}km × ${height}km — max zoom level ${selectionModel.zoomMax} ($label)" + dimsLine.setTextColor(dimsLine.context.getColor(R.color.plugin_on_surface_variant)) + } - private fun scheduleRealEstimate() { - if (!selectionModel.hasSliceableSource()) return - sizeEstimator?.estimate( - tilesUrl = - RegionDownloader.buildTilesUrl(selectionModel.sourceKind, selectionModel.sourceHost), - bbox = selectionModel.currentBbox, - zoomMin = selectionModel.zoomMin, - zoomMax = selectionModel.zoomMax, - ) - } + private fun scheduleRealEstimate() { + if (!selectionModel.hasSliceableSource()) return + sizeEstimator?.estimate( + tilesUrl = + RegionDownloader.buildTilesUrl(selectionModel.sourceKind, selectionModel.sourceHost), + bbox = selectionModel.currentBbox, + zoomMin = selectionModel.zoomMin, + zoomMax = selectionModel.zoomMax, + ) + } - private fun onEstimateState(state: RegionSizeEstimator.State) { - selectionModel.onEstimateState(state) - if (isViewAlive()) renderEstimate() - } + private fun onEstimateState(state: RegionSizeEstimator.State) { + selectionModel.onEstimateState(state) + if (isViewAlive()) renderEstimate() + } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt index 61309ffd..bacce49d 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt @@ -45,209 +45,209 @@ import org.maplibre.android.maps.Style * @param onStyleReady runs after the first camera placement, for the picker's location wiring */ internal class PickerMapController( - private val map: MapLibreMap, - private val overlay: BboxOverlayView, - private val selectionModel: BboxSelectionModel, - private val scope: CoroutineScope, - private val mapWidth: () -> Int, - private val cacheDir: () -> File?, - private val isViewAlive: () -> Boolean, - private val onBboxChanged: () -> Unit, - private val onStyleReady: (Style) -> Unit, + private val map: MapLibreMap, + private val overlay: BboxOverlayView, + private val selectionModel: BboxSelectionModel, + private val scope: CoroutineScope, + private val mapWidth: () -> Int, + private val cacheDir: () -> File?, + private val isViewAlive: () -> Boolean, + private val onBboxChanged: () -> Unit, + private val onStyleReady: (Style) -> Unit, ) { - // Held so release() can unregister them: they close over the overlay and would leak across - // dialog re-creates otherwise. - private var cameraMoveListener: MapLibreMap.OnCameraMoveListener? = null - private var cameraIdleListener: MapLibreMap.OnCameraIdleListener? = null - private var cameraMoveStartedListener: MapLibreMap.OnCameraMoveStartedListener? = null + // Held so release() can unregister them: they close over the overlay and would leak across + // dialog re-creates otherwise. + private var cameraMoveListener: MapLibreMap.OnCameraMoveListener? = null + private var cameraIdleListener: MapLibreMap.OnCameraIdleListener? = null + private var cameraMoveStartedListener: MapLibreMap.OnCameraMoveStartedListener? = null - /** The pending auto-shrink check, restarted on every camera idle so only the last one runs. */ - private var autoShrinkJob: Job? = null + /** The pending auto-shrink check, restarted on every camera idle so only the last one runs. */ + private var autoShrinkJob: Job? = null - /** Loads the style, then wires gestures, camera listeners and projection. */ - fun start(prefillBbox: DoubleArray?) { - loadStyleThenPosition(prefillBbox) - // The bbox is stored in lat/lon: camera moves only re-project it to fresh pixels. - map.uiSettings.isRotateGesturesEnabled = false - // No annotations on the picker, so consume map clicks. (The AnnotationManager dimen-resolve - // crash is prevented by MapLibreBootstrap's plugin-resource context, not here.) - map.addOnMapClickListener { true } - wireCameraListeners() - wireProjection() - } + /** Loads the style, then wires gestures, camera listeners and projection. */ + fun start(prefillBbox: DoubleArray?) { + loadStyleThenPosition(prefillBbox) + // The bbox is stored in lat/lon: camera moves only re-project it to fresh pixels. + map.uiSettings.isRotateGesturesEnabled = false + // No annotations on the picker, so consume map clicks. (The AnnotationManager dimen-resolve + // crash is prevented by MapLibreBootstrap's plugin-resource context, not here.) + map.addOnMapClickListener { true } + wireCameraListeners() + wireProjection() + } - /** Cancels the auto-shrink check and unregisters the camera listeners. */ - fun release() { - autoShrinkJob?.cancel() - runCatching { - cameraMoveListener?.let { map.removeOnCameraMoveListener(it) } - cameraIdleListener?.let { map.removeOnCameraIdleListener(it) } - cameraMoveStartedListener?.let { map.removeOnCameraMoveStartedListener(it) } + /** Cancels the auto-shrink check and unregisters the camera listeners. */ + fun release() { + autoShrinkJob?.cancel() + runCatching { + cameraMoveListener?.let { map.removeOnCameraMoveListener(it) } + cameraIdleListener?.let { map.removeOnCameraIdleListener(it) } + cameraMoveStartedListener?.let { map.removeOnCameraMoveStartedListener(it) } + } + cameraMoveListener = null + cameraIdleListener = null + cameraMoveStartedListener = null } - cameraMoveListener = null - cameraIdleListener = null - cameraMoveStartedListener = null - } - /** - * Sets [bbox] and moves the camera to fit it to [GPS_DEFAULT_SCREEN_FRACTION] of the map width, - * or to [NO_GPS_WORLD_ZOOM] when [worldView] is true. - */ - fun applyInitialBbox(bbox: Bbox, worldView: Boolean = false) { - overlay.setBboxLatLon(bbox) - val targetZoom = - if (worldView) NO_GPS_WORLD_ZOOM - else ZoomFit.fitZoom(bbox, mapWidth(), GPS_DEFAULT_SCREEN_FRACTION) - map.cameraPosition = - CameraPosition.Builder() - .target(LatLng((bbox.south + bbox.north) / 2.0, (bbox.west + bbox.east) / 2.0)) - .zoom(targetZoom) - .build() - // The camera move re-projects the overlay through the listeners. - selectionModel.applyBboxChange(bbox) - onBboxChanged() - } + /** + * Sets [bbox] and moves the camera to fit it to [GPS_DEFAULT_SCREEN_FRACTION] of the map width, + * or to [NO_GPS_WORLD_ZOOM] when [worldView] is true. + */ + fun applyInitialBbox(bbox: Bbox, worldView: Boolean = false) { + overlay.setBboxLatLon(bbox) + val targetZoom = + if (worldView) NO_GPS_WORLD_ZOOM + else ZoomFit.fitZoom(bbox, mapWidth(), GPS_DEFAULT_SCREEN_FRACTION) + map.cameraPosition = + CameraPosition.Builder() + .target(LatLng((bbox.south + bbox.north) / 2.0, (bbox.west + bbox.east) / 2.0)) + .zoom(targetZoom) + .build() + // The camera move re-projects the overlay through the listeners. + selectionModel.applyBboxChange(bbox) + onBboxChanged() + } - /** - * Resolves the tiles URL and extracts glyphs on `Dispatchers.IO` (both touch disk), then applies - * the style. The URL is the one the download reads, so the basemap and estimate match it. - */ - private fun loadStyleThenPosition(prefillBbox: DoubleArray?) { - scope.launch { - val (pmtilesUrl, fontsRoot) = - withContext(Dispatchers.IO) { - val resources = MapsPlugin.pluginContext?.resources - val cacheDir = cacheDir() - val fonts = - if (resources != null && cacheDir != null) { - MapFontExtractor.extract(resources, cacheDir) - } else { - null + /** + * Resolves the tiles URL and extracts glyphs on `Dispatchers.IO` (both touch disk), then applies + * the style. The URL is the one the download reads, so the basemap and estimate match it. + */ + private fun loadStyleThenPosition(prefillBbox: DoubleArray?) { + scope.launch { + val (pmtilesUrl, fontsRoot) = + withContext(Dispatchers.IO) { + val resources = MapsPlugin.pluginContext?.resources + val cacheDir = cacheDir() + val fonts = + if (resources != null && cacheDir != null) { + MapFontExtractor.extract(resources, cacheDir) + } else { + null + } + // Resolve the current archive before the first estimate builds its URL. + when (selectionModel.sourceKind) { + SourceKind.INTERNET -> IiabArchiveResolver.shared.resolve() + // A box with no archive surfaces as an estimate error instead. + SourceKind.IIAB_LAN -> + runCatchingCancellable { + RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, selectionModel.sourceHost) + } + else -> Unit + } + resolveIiabTilesUrl() to fonts?.absolutePath + } + if (!isViewAlive()) return@launch + val styleJson = IiabStyleBuilder.buildStyle(pmtilesUrl, fontsRoot) + map.setStyle(Style.Builder().fromJson(styleJson)) { style -> + applyStyleReady(style, prefillBbox) } - // Resolve the current archive before the first estimate builds its URL. - when (selectionModel.sourceKind) { - SourceKind.INTERNET -> IiabArchiveResolver.shared.resolve() - // A box with no archive surfaces as an estimate error instead. - SourceKind.IIAB_LAN -> - runCatchingCancellable { - RegionDownloader.resolveTilesUrl(SourceKind.IIAB_LAN, selectionModel.sourceHost) - } - else -> Unit - } - resolveIiabTilesUrl() to fonts?.absolutePath } - if (!isViewAlive()) return@launch - val styleJson = IiabStyleBuilder.buildStyle(pmtilesUrl, fontsRoot) - map.setStyle(Style.Builder().fromJson(styleJson)) { style -> - applyStyleReady(style, prefillBbox) - } } - } - /** - * Places the first bbox: the saved one on the Refresh flow, else a 20-degree box at world view. - * A location fix later swaps in its own box through [applyInitialBbox]. - */ - private fun applyStyleReady(style: Style, prefillBbox: DoubleArray?) { - 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 - } - applyInitialBbox(initialBbox, worldView = prefillBbox == null) - onStyleReady(style) - } + /** + * Places the first bbox: the saved one on the Refresh flow, else a 20-degree box at world view. + * A location fix later swaps in its own box through [applyInitialBbox]. + */ + private fun applyStyleReady(style: Style, prefillBbox: DoubleArray?) { + 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 + } + applyInitialBbox(initialBbox, worldView = prefillBbox == null) + onStyleReady(style) + } - private fun wireCameraListeners() { - val moveListener = MapLibreMap.OnCameraMoveListener { overlay.recomputePixelRect() } - val idleListener = - MapLibreMap.OnCameraIdleListener { - val center = map.cameraPosition.target ?: return@OnCameraIdleListener - selectionModel.updateAnchor(center.latitude, center.longitude) - overlay.recomputePixelRect() - scheduleAutoShrinkBbox() - } - // Only a REASON_API_GESTURE move is the user driving the viewport. Without this gate the idle - // after the Refresh flow's own saved-bounds fit collapsed a 1054x1047 km region to 98x160 km. - val moveStartedListener = - MapLibreMap.OnCameraMoveStartedListener { reason -> - if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { - selectionModel.noteUserCameraGesture() - } - } - map.addOnCameraMoveListener(moveListener) - map.addOnCameraIdleListener(idleListener) - map.addOnCameraMoveStartedListener(moveStartedListener) - cameraMoveListener = moveListener - cameraIdleListener = idleListener - cameraMoveStartedListener = moveStartedListener - } + private fun wireCameraListeners() { + val moveListener = MapLibreMap.OnCameraMoveListener { overlay.recomputePixelRect() } + val idleListener = + MapLibreMap.OnCameraIdleListener { + val center = map.cameraPosition.target ?: return@OnCameraIdleListener + selectionModel.updateAnchor(center.latitude, center.longitude) + overlay.recomputePixelRect() + scheduleAutoShrinkBbox() + } + // Only a REASON_API_GESTURE move is the user driving the viewport. Without this gate the idle + // after the Refresh flow's own saved-bounds fit collapsed a 1054x1047 km region to 98x160 km. + val moveStartedListener = + MapLibreMap.OnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { + selectionModel.noteUserCameraGesture() + } + } + map.addOnCameraMoveListener(moveListener) + map.addOnCameraIdleListener(idleListener) + map.addOnCameraMoveStartedListener(moveStartedListener) + cameraMoveListener = moveListener + cameraIdleListener = idleListener + cameraMoveStartedListener = moveStartedListener + } - /** Lets the overlay convert between geographic and screen coordinates. */ - private fun wireProjection() { - overlay.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(PointF(x, y)) - ll.latitude to ll.longitude - }, - ) - } + /** Lets the overlay convert between geographic and screen coordinates. */ + private fun wireProjection() { + overlay.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(PointF(x, y)) + ll.latitude to ll.longitude + }, + ) + } - /** - * The tiles URL for the source picked in Step 1, from [RegionDownloader.buildTilesUrl] so it is - * the file the download reads. Null when no source is set yet, which loads a blank background. - */ - private fun resolveIiabTilesUrl(): String? = - runCatching { - when (selectionModel.sourceKind) { - SourceKind.INTERNET -> RegionDownloader.buildTilesUrl(selectionModel.sourceKind, null) - SourceKind.IIAB_LAN -> { - val host = selectionModel.sourceHost?.trim().orEmpty() - if (host.isBlank()) null - else RegionDownloader.buildTilesUrl(selectionModel.sourceKind, host) - } - else -> null - } - } - .onFailure { MapsPlugin.pluginContext?.logger?.warn("resolveIiabTilesUrl failed: ${it.message}") } - .getOrNull() + /** + * The tiles URL for the source picked in Step 1, from [RegionDownloader.buildTilesUrl] so it is + * the file the download reads. Null when no source is set yet, which loads a blank background. + */ + private fun resolveIiabTilesUrl(): String? = + runCatching { + when (selectionModel.sourceKind) { + SourceKind.INTERNET -> RegionDownloader.buildTilesUrl(selectionModel.sourceKind, null) + SourceKind.IIAB_LAN -> { + val host = selectionModel.sourceHost?.trim().orEmpty() + if (host.isBlank()) null + else RegionDownloader.buildTilesUrl(selectionModel.sourceKind, host) + } + else -> null + } + } + .onFailure { MapsPlugin.pluginContext?.logger?.warn("resolveIiabTilesUrl failed: ${it.message}") } + .getOrNull() - /** - * Shrinks the bbox to the viewport [AUTO_SHRINK_DEBOUNCE_MS] after the camera settles, if an - * edge left the view. The model ignores idles before the user's first gesture. - */ - private fun scheduleAutoShrinkBbox() { - autoShrinkJob?.cancel() - autoShrinkJob = - scope.launch { - delay(AUTO_SHRINK_DEBOUNCE_MS) - if (!isViewAlive()) return@launch - maybeShrinkBboxToFit() - } - } + /** + * Shrinks the bbox to the viewport [AUTO_SHRINK_DEBOUNCE_MS] after the camera settles, if an + * edge left the view. The model ignores idles before the user's first gesture. + */ + private fun scheduleAutoShrinkBbox() { + autoShrinkJob?.cancel() + autoShrinkJob = + scope.launch { + delay(AUTO_SHRINK_DEBOUNCE_MS) + if (!isViewAlive()) return@launch + maybeShrinkBboxToFit() + } + } - private fun maybeShrinkBboxToFit() { - val bounds = runCatching { map.projection.visibleRegion.latLngBounds }.getOrNull() ?: return - // Null means the bbox was already fully visible, or the viewport was degenerate. - val newBbox = - selectionModel.maybeShrinkToFit( - viewN = bounds.latitudeNorth, - viewS = bounds.latitudeSouth, - viewE = bounds.longitudeEast, - viewW = bounds.longitudeWest, - margin = AUTO_SHRINK_MARGIN, - ) ?: return - overlay.setBboxLatLon(newBbox) - onBboxChanged() - } + private fun maybeShrinkBboxToFit() { + val bounds = runCatching { map.projection.visibleRegion.latLngBounds }.getOrNull() ?: return + // Null means the bbox was already fully visible, or the viewport was degenerate. + val newBbox = + selectionModel.maybeShrinkToFit( + viewN = bounds.latitudeNorth, + viewS = bounds.latitudeSouth, + viewE = bounds.longitudeEast, + viewW = bounds.longitudeWest, + margin = AUTO_SHRINK_MARGIN, + ) ?: return + overlay.setBboxLatLon(newBbox) + onBboxChanged() + } } diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt index 47e085fb..9ad5f44a 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt @@ -15,112 +15,112 @@ import org.junit.Test /** [DownloadCompletion] against a temp project and a temp region cache. */ class DownloadCompletionTest { - private lateinit var projectDir: File - private lateinit var cacheRoot: File - private lateinit var project: FakeProjectRegions - private var listFails = false + private lateinit var projectDir: File + private lateinit var cacheRoot: File + private lateinit var project: FakeProjectRegions + private var listFails = false - private val completion by lazy { - DownloadCompletion( - project = project, - listRegions = { if (listFails) error("cache unreadable") else RegionCache.listFromRoot(cacheRoot) }, - cacheRoot = { cacheRoot }, - ) - } + private val completion by lazy { + DownloadCompletion( + project = project, + listRegions = { if (listFails) error("cache unreadable") else RegionCache.listFromRoot(cacheRoot) }, + cacheRoot = { cacheRoot }, + ) + } - @Before - fun setUp() { - projectDir = Files.createTempDirectory("maps-dc-project").toFile() - cacheRoot = Files.createTempDirectory("maps-dc-cache").toFile() - project = FakeProjectRegions(projectDir) - } + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-dc-project").toFile() + cacheRoot = Files.createTempDirectory("maps-dc-cache").toFile() + project = FakeProjectRegions(projectDir) + } - @After - fun tearDown() { - projectDir.deleteRecursively() - cacheRoot.deleteRecursively() - } + @After + fun tearDown() { + projectDir.deleteRecursively() + cacheRoot.deleteRecursively() + } - private fun seed(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(16)) - } + private fun seed(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(16)) + } - private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } - @Test - fun `no open project is reported and nothing is copied`() = runBlocking { - seed("alpha") - project.projectDir = null - assertEquals(DownloadCompletion.Outcome.NoProject, completion.complete("alpha")) - assertTrue(project.applied.isEmpty()) - } + @Test + fun `no open project is reported and nothing is copied`() = runBlocking { + seed("alpha") + project.projectDir = null + assertEquals(DownloadCompletion.Outcome.NoProject, completion.complete("alpha")) + assertTrue(project.applied.isEmpty()) + } - @Test - fun `the first region in a project is activated in that project`() = runBlocking { - seed("alpha", "Alpha Town") - val outcome = completion.complete("alpha") - assertEquals( - DownloadCompletion.Outcome.Finished( - projectDir, - FirstRegionAutoActivator.Result.Activated("alpha", "Alpha Town"), - ), - outcome, - ) - assertEquals("alpha", active()) - } + @Test + fun `the first region in a project is activated in that project`() = runBlocking { + seed("alpha", "Alpha Town") + val outcome = completion.complete("alpha") + assertEquals( + DownloadCompletion.Outcome.Finished( + projectDir, + FirstRegionAutoActivator.Result.Activated("alpha", "Alpha Town"), + ), + outcome, + ) + assertEquals("alpha", active()) + } - @Test - fun `a project with a live active region keeps it`() = runBlocking { - seed("alpha") - seed("beta") - project.writeActiveRegionId(projectDir, "beta") - val outcome = completion.complete("alpha") as DownloadCompletion.Outcome.Finished - assertEquals(FirstRegionAutoActivator.Result.NoOpAlreadyActive, outcome.result) - assertEquals("beta", active()) - } + @Test + fun `a project with a live active region keeps it`() = runBlocking { + seed("alpha") + seed("beta") + project.writeActiveRegionId(projectDir, "beta") + val outcome = completion.complete("alpha") as DownloadCompletion.Outcome.Finished + assertEquals(FirstRegionAutoActivator.Result.NoOpAlreadyActive, outcome.result) + assertEquals("beta", active()) + } - @Test - fun `snackbar apply copies the region in and activates it`() = runBlocking { - seed("alpha") - project.writeActiveRegionId(projectDir, "beta") - assertTrue(completion.applyFromSnackbar(projectDir, "alpha")) - assertEquals(listOf("alpha"), project.applied) - assertEquals("alpha", active()) - } + @Test + fun `snackbar apply copies the region in and activates it`() = runBlocking { + seed("alpha") + project.writeActiveRegionId(projectDir, "beta") + assertTrue(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals(listOf("alpha"), project.applied) + assertEquals("alpha", active()) + } - @Test - fun `snackbar apply of a region gone from the cache does nothing`() = runBlocking { - seed("beta") - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) - assertTrue(project.applied.isEmpty()) - } + @Test + fun `snackbar apply of a region gone from the cache does nothing`() = runBlocking { + seed("beta") + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } - @Test - fun `snackbar apply with an unreadable cache does nothing`() = runBlocking { - seed("alpha") - listFails = true - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) - assertTrue(project.applied.isEmpty()) - } + @Test + fun `snackbar apply with an unreadable cache does nothing`() = runBlocking { + seed("alpha") + listFails = true + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertTrue(project.applied.isEmpty()) + } - @Test - fun `a failed snackbar copy reports false and leaves active_txt alone`() = runBlocking { - seed("alpha") - project.writeActiveRegionId(projectDir, "beta") - project.applyResult = false - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) - assertEquals("beta", active()) - } + @Test + fun `a failed snackbar copy reports false and leaves active_txt alone`() = runBlocking { + seed("alpha") + project.writeActiveRegionId(projectDir, "beta") + project.applyResult = false + assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals("beta", active()) + } } diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt index e002a9bc..b4d9f288 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FakeProjectRegions.kt @@ -7,46 +7,46 @@ import java.io.File * [ActiveRegionStore], and the region copy only records its call and returns [applyResult]. */ internal class FakeProjectRegions( - var projectDir: File?, - var applyResult: Boolean = true, + var projectDir: File?, + var applyResult: Boolean = true, ) : ProjectRegions { - val applied = mutableListOf() + val applied = mutableListOf() - override suspend fun currentProjectRoot(): File? = projectDir + override suspend fun currentProjectRoot(): File? = projectDir - override suspend fun readActiveRegionId(projectDir: File): String? = - ActiveRegionStore.read(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + override suspend fun readActiveRegionId(projectDir: File): String? = + ActiveRegionStore.read(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) - override suspend fun writeActiveRegionId(projectDir: File, regionId: String) { - ActiveRegionStore.write(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, regionId) - } + override suspend fun writeActiveRegionId(projectDir: File, regionId: String) { + ActiveRegionStore.write(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH, regionId) + } - override suspend fun clearActiveRegionId(projectDir: File) { - ActiveRegionStore.clear(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) - } + override suspend fun clearActiveRegionId(projectDir: File) { + ActiveRegionStore.clear(projectDir, ProjectRegionCoordinator.DEFAULT_PROJECT_MAPS_SUBPATH) + } - override suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { - val ok = applyRegionToProject(info, projectDir) - if (ok) writeActiveRegionId(projectDir, info.regionId) - return ok - } + override suspend fun applyAndActivate(info: RegionInfo, projectDir: File): Boolean { + val ok = applyRegionToProject(info, projectDir) + if (ok) writeActiveRegionId(projectDir, info.regionId) + return ok + } - override suspend fun applyRegionToProject(info: RegionInfo, projectDir: File): Boolean { - applied += info.regionId - return applyResult - } + override suspend fun applyRegionToProject(info: RegionInfo, projectDir: File): Boolean { + applied += info.regionId + return applyResult + } } /** A [RegionInfo] whose directory need not exist; enough for code that only reads its fields. */ internal fun regionInfo(id: String, name: String = "Name of $id", bbox: DoubleArray? = null) = - RegionInfo( - regionId = id, - displayName = name, - sizeBytes = 0L, - downloadedAt = null, - lastUsedAt = null, - source = "internet", - directory = File("/nonexistent/$id"), - bbox = bbox, - ) + RegionInfo( + regionId = id, + displayName = name, + sizeBytes = 0L, + downloadedAt = null, + lastUsedAt = null, + source = "internet", + directory = File("/nonexistent/$id"), + bbox = bbox, + ) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt index 4d6cae94..d2caf15a 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionListActionsTest.kt @@ -17,136 +17,136 @@ import org.junit.Test /** [RegionListActions] against a real `active.txt` in a temp project and an in-memory cache. */ class RegionListActionsTest { - private lateinit var projectDir: File - private lateinit var project: FakeProjectRegions - private val alpha = regionInfo("alpha", "Alpha Town") - private val beta = regionInfo("beta", "Beta City") - private var cached = listOf(alpha, beta) - private var listFails = false - private var deleteResult = true - private val deleted = mutableListOf() - - private val actions by lazy { - RegionListActions( - project = project, - listRegions = { if (listFails) error("cache unreadable") else cached }, - deleteRegion = { - deleted += it.regionId - deleteResult - }, - ) - } - - @Before - fun setUp() { - projectDir = Files.createTempDirectory("maps-rla").toFile() - project = FakeProjectRegions(projectDir) - } - - @After - fun tearDown() { - projectDir.deleteRecursively() - } - - private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } - - private fun setActive(id: String) = runBlocking { project.writeActiveRegionId(projectDir, id) } - - @Test - fun `toggle with no open project reports it and touches nothing`() = runBlocking { - project.projectDir = null - assertEquals(ToggleResult.NoProject, actions.toggleActive(alpha, true)) - assertTrue(project.applied.isEmpty()) - } - - @Test - fun `toggle on with no prior active region activates it`() = runBlocking { - assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) - assertEquals("alpha", active()) - assertEquals(listOf("alpha"), project.applied) - } - - @Test - fun `toggle on over another active region names both`() = runBlocking { - setActive("beta") - assertEquals(ToggleResult.Switched("Beta City", "Alpha Town"), actions.toggleActive(alpha, true)) - assertEquals("alpha", active()) - } - - @Test - fun `a prior region missing from the cache is named by its id`() = runBlocking { - setActive("gone") - assertEquals(ToggleResult.Switched("gone", "Alpha Town"), actions.toggleActive(alpha, true)) - } - - @Test - fun `an unreadable cache still switches, naming the prior region by its id`() = runBlocking { - setActive("beta") - listFails = true - assertEquals(ToggleResult.Switched("beta", "Alpha Town"), actions.toggleActive(alpha, true)) - assertEquals("alpha", active()) - } - - @Test - fun `toggling on the region that is already active is an activation, not a switch`() = - runBlocking { - setActive("alpha") - assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + private lateinit var projectDir: File + private lateinit var project: FakeProjectRegions + private val alpha = regionInfo("alpha", "Alpha Town") + private val beta = regionInfo("beta", "Beta City") + private var cached = listOf(alpha, beta) + private var listFails = false + private var deleteResult = true + private val deleted = mutableListOf() + + private val actions by lazy { + RegionListActions( + project = project, + listRegions = { if (listFails) error("cache unreadable") else cached }, + deleteRegion = { + deleted += it.regionId + deleteResult + }, + ) } - @Test - fun `a failed copy reports it and leaves the prior active region in place`() = runBlocking { - setActive("beta") - project.applyResult = false - assertEquals(ToggleResult.ApplyFailed, actions.toggleActive(alpha, true)) - assertEquals("beta", active()) - } - - @Test - fun `toggle off clears the active region without copying`() = runBlocking { - setActive("alpha") - assertEquals(ToggleResult.Deactivated("Alpha Town"), actions.toggleActive(alpha, false)) - assertNull(active()) - assertTrue(project.applied.isEmpty()) - } - - @Test - fun `deleting the active region clears active_txt`() = runBlocking { - setActive("alpha") - assertTrue(actions.delete(alpha)) - assertEquals(listOf("alpha"), deleted) - assertNull(active()) - } - - @Test - fun `deleting another region keeps the active one`() = runBlocking { - setActive("beta") - assertTrue(actions.delete(alpha)) - assertEquals("beta", active()) - } - - @Test - fun `a failed delete reports false and keeps active_txt`() = runBlocking { - setActive("alpha") - deleteResult = false - assertFalse(actions.delete(alpha)) - assertEquals("alpha", active()) - } - - @Test - fun `deleting with no open project still deletes`() = runBlocking { - project.projectDir = null - assertTrue(actions.delete(alpha)) - assertEquals(listOf("alpha"), deleted) - } - - @Test - fun `redownload re-opens the wizard on the region's id, name and bounds`() { - val bounds = doubleArrayOf(1.0, 2.0, 3.0, 4.0) - val event = actions.redownloadRequest(regionInfo("gamma", "Gamma", bounds)) - event as Event.RedownloadRequested - assertEquals("gamma", event.regionId) - assertEquals("Gamma", event.displayName) - assertArrayEquals(bounds, event.bbox, 0.0) - } + @Before + fun setUp() { + projectDir = Files.createTempDirectory("maps-rla").toFile() + project = FakeProjectRegions(projectDir) + } + + @After + fun tearDown() { + projectDir.deleteRecursively() + } + + private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } + + private fun setActive(id: String) = runBlocking { project.writeActiveRegionId(projectDir, id) } + + @Test + fun `toggle with no open project reports it and touches nothing`() = runBlocking { + project.projectDir = null + assertEquals(ToggleResult.NoProject, actions.toggleActive(alpha, true)) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `toggle on with no prior active region activates it`() = runBlocking { + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + assertEquals(listOf("alpha"), project.applied) + } + + @Test + fun `toggle on over another active region names both`() = runBlocking { + setActive("beta") + assertEquals(ToggleResult.Switched("Beta City", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `a prior region missing from the cache is named by its id`() = runBlocking { + setActive("gone") + assertEquals(ToggleResult.Switched("gone", "Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `an unreadable cache still switches, naming the prior region by its id`() = runBlocking { + setActive("beta") + listFails = true + assertEquals(ToggleResult.Switched("beta", "Alpha Town"), actions.toggleActive(alpha, true)) + assertEquals("alpha", active()) + } + + @Test + fun `toggling on the region that is already active is an activation, not a switch`() = + runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Activated("Alpha Town"), actions.toggleActive(alpha, true)) + } + + @Test + fun `a failed copy reports it and leaves the prior active region in place`() = runBlocking { + setActive("beta") + project.applyResult = false + assertEquals(ToggleResult.ApplyFailed, actions.toggleActive(alpha, true)) + assertEquals("beta", active()) + } + + @Test + fun `toggle off clears the active region without copying`() = runBlocking { + setActive("alpha") + assertEquals(ToggleResult.Deactivated("Alpha Town"), actions.toggleActive(alpha, false)) + assertNull(active()) + assertTrue(project.applied.isEmpty()) + } + + @Test + fun `deleting the active region clears active_txt`() = runBlocking { + setActive("alpha") + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + assertNull(active()) + } + + @Test + fun `deleting another region keeps the active one`() = runBlocking { + setActive("beta") + assertTrue(actions.delete(alpha)) + assertEquals("beta", active()) + } + + @Test + fun `a failed delete reports false and keeps active_txt`() = runBlocking { + setActive("alpha") + deleteResult = false + assertFalse(actions.delete(alpha)) + assertEquals("alpha", active()) + } + + @Test + fun `deleting with no open project still deletes`() = runBlocking { + project.projectDir = null + assertTrue(actions.delete(alpha)) + assertEquals(listOf("alpha"), deleted) + } + + @Test + fun `redownload re-opens the wizard on the region's id, name and bounds`() { + val bounds = doubleArrayOf(1.0, 2.0, 3.0, 4.0) + val event = actions.redownloadRequest(regionInfo("gamma", "Gamma", bounds)) + event as Event.RedownloadRequested + assertEquals("gamma", event.regionId) + assertEquals("Gamma", event.displayName) + assertArrayEquals(bounds, event.bbox, 0.0) + } } From d4eaf480583ecd3f84375cc620da012ebbcc5a59 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 19:53:15 -0700 Subject: [PATCH 71/81] fix(maps): turn Next off when the chosen box holds no map tiles (ADFA-2436) An empty tile list still estimates 8 KB of header overhead, so the size line read "0.0 MB download size", Next stayed on, and the download then failed with "No tiles intersect bbox Bbox(...". The estimate now flags an empty result: the size line says there is no map data in the area and Next is disabled. If a download still hits an empty box, the failure snackbar uses a plain message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/data/RegionDownloader.kt | 5 +++- .../maps/domain/EstimateDisplay.kt | 14 +++++++++++ .../maps/ui/BboxSelectionModel.kt | 6 +++++ .../maps/ui/DownloadProgressFragment.kt | 7 +++++- .../maps/ui/EstimateLabelBinder.kt | 1 + plugins/Maps/src/main/res/values/strings.xml | 1 + .../maps/data/RegionDownloaderEndToEndTest.kt | 2 +- .../maps/domain/EstimateDisplayTest.kt | 25 +++++++++++++++++-- .../maps/ui/BboxSelectionModelTest.kt | 10 ++++++++ 9 files changed, 66 insertions(+), 5 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt index 458c71c4..9119c48a 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/RegionDownloader.kt @@ -55,6 +55,9 @@ import org.appdevforall.maps.util.MAPS_TRAFFIC_STATS_TAG */ internal object RegionDownloader { + /** The archive holds no tiles inside the chosen box; the UI shows its own plain message. */ + class NoTilesInRegionException(message: String) : IOException(message) + /** Schema version. Bump when adding required fields. */ internal const val META_SCHEMA_VERSION = 1 @@ -289,7 +292,7 @@ internal object RegionDownloader { ) val regionTiles = PmtilesRegionSlicer.tilesInRegionImpl(fetcher, bbox, zoomMin, zoomMax) if (regionTiles.isEmpty()) { - throw IOException( + throw NoTilesInRegionException( "No tiles intersect bbox $bbox at zoom $zoomMin..$zoomMax in $globalUrl" ) } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/EstimateDisplay.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/EstimateDisplay.kt index 036c5d91..df231507 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/EstimateDisplay.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/EstimateDisplay.kt @@ -23,6 +23,9 @@ object EstimateDisplay { */ const val DEFAULT_NON_VECTOR_ALLOWANCE_BYTES: Long = 4L * 1024L * 1024L + /** The size line when the box holds no map tiles. */ + const val NO_TILES_TEXT = "No map data in this area. Move or enlarge the box" + /** * @param text the line to display (empty before a source is picked). * @param isError true when [text] should use the error color. @@ -46,6 +49,7 @@ object EstimateDisplay { * @param hasSliceableSource whether a source is chosen yet — when false, nothing is shown. * @param nonVectorAllowanceBytes headroom added for the basemap + metadata when checking the cap. * @param maxDownloadBytes the hard download cap (1 GB in production). + * @param noTiles true when the finished estimate found no tiles in the box. */ fun compute( realBytes: Long?, @@ -53,11 +57,21 @@ object EstimateDisplay { hasSliceableSource: Boolean, nonVectorAllowanceBytes: Long = DEFAULT_NON_VECTOR_ALLOWANCE_BYTES, maxDownloadBytes: Long = DEFAULT_MAX_DOWNLOAD_BYTES, + noTiles: Boolean = false, ): State { if (!hasSliceableSource) { // No source picked yet — blank line, Next disabled. return State(text = "", isError = false, overBudget = false, nextEnabled = false) } + if (realBytes != null && noTiles) { + // The archive has no tiles in this box (open sea, or past its zoom range). + return State( + text = NO_TILES_TEXT, + isError = true, + overBudget = false, + nextEnabled = false, + ) + } if (realBytes != null) { val totalBytes = realBytes + nonVectorAllowanceBytes val mb = realBytes / (1024.0 * 1024.0) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt index ce2743bc..9376af15 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt @@ -70,6 +70,10 @@ internal class BboxSelectionModel(initialBbox: Bbox) { var realEstimateFailed: Boolean = false private set + /** True when the last finished estimate found no tiles in [currentBbox]. */ + var realEstimateHasNoTiles: Boolean = false + private set + /** Source context piped in from Step 1, needed for the slicer-driven estimate. */ var sourceKind: SourceKind = SourceKind.UNKNOWN private set @@ -131,6 +135,7 @@ internal class BboxSelectionModel(initialBbox: Bbox) { zoomMax = ZoomCap.pickZoomMax(currentBbox, zoomMin) realByteEstimate = null realEstimateFailed = false + realEstimateHasNoTiles = false } /** @@ -193,6 +198,7 @@ internal class BboxSelectionModel(initialBbox: Bbox) { RegionSizeEstimator.State.Failed -> realEstimateFailed = true is RegionSizeEstimator.State.Done -> { realByteEstimate = PmtilesRegionSlicer.estimateRegionBytes(state.tiles) + realEstimateHasNoTiles = state.tiles.isEmpty() realEstimateFailed = false } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt index 83490846..bb4441a1 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/DownloadProgressFragment.kt @@ -180,7 +180,12 @@ class DownloadProgressFragment : Fragment() { // Re-throw to allow structured cancellation to propagate. throw cancel } catch (t: Throwable) { - val msg = t.message ?: t.javaClass.simpleName + val msg = + if (t is RegionDownloader.NoTilesInRegionException) { + getString(R.string.maps_download_no_tiles) + } else { + t.message ?: t.javaClass.simpleName + } (parentFragment as? Listener)?.onDownloadFailed(msg) } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt index 38b91e9c..2350c877 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/EstimateLabelBinder.kt @@ -56,6 +56,7 @@ internal class EstimateLabelBinder( realBytes = selectionModel.realByteEstimate, estimateFailed = selectionModel.realEstimateFailed, hasSliceableSource = selectionModel.hasSliceableSource(), + noTiles = selectionModel.realEstimateHasNoTiles, ) sizeLine.text = state.text // The view's own context is the themed plugin one: the host context cannot resolve plugin diff --git a/plugins/Maps/src/main/res/values/strings.xml b/plugins/Maps/src/main/res/values/strings.xml index 7a3fe4c9..ce13d967 100644 --- a/plugins/Maps/src/main/res/values/strings.xml +++ b/plugins/Maps/src/main/res/values/strings.xml @@ -113,6 +113,7 @@ Apply Download cancelled — partial files removed Download failed: %1$s + there is no map data in this area Switched active region to: %1$s Calculating download size… %1$.1f MB diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt index 0639febd..39016878 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/RegionDownloaderEndToEndTest.kt @@ -138,7 +138,7 @@ class RegionDownloaderEndToEndTest { val thrown = runCatching { download(root, zoomMin = 10, zoomMax = 12) }.exceptionOrNull() - assertTrue("got $thrown", thrown is IOException) + assertTrue("got $thrown", thrown is RegionDownloader.NoTilesInRegionException) assertTrue(thrown!!.message!!.startsWith("No tiles intersect bbox")) assertFalse(File(root, "world").exists()) assertFalse(File(root, "world.partial").exists()) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt index ac7d1e67..34f83528 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/EstimateDisplayTest.kt @@ -11,8 +11,12 @@ class EstimateDisplayTest { private val allowance = 5L * 1024 * 1024 // 5 MB non-vector headroom private val cap = 1024L * 1024 * 1024 // 1 GB - private fun compute(realBytes: Long?, failed: Boolean = false, hasSource: Boolean = true) = - EstimateDisplay.compute(realBytes, failed, hasSource, allowance, cap) + private fun compute( + realBytes: Long?, + failed: Boolean = false, + hasSource: Boolean = true, + noTiles: Boolean = false, + ) = EstimateDisplay.compute(realBytes, failed, hasSource, allowance, cap, noTiles) @Test fun `no source - blank line, Next disabled`() { @@ -23,6 +27,23 @@ class EstimateDisplayTest { assertFalse(s.nextEnabled) } + @Test + fun `no tiles - error line, Next disabled`() { + // An empty tile list still estimates 8 KB of header overhead, which used to read "0.0 MB". + val s = compute(realBytes = 8L * 1024, noTiles = true) + assertEquals(EstimateDisplay.NO_TILES_TEXT, s.text) + assertTrue(s.isError) + assertFalse(s.overBudget) + assertFalse("Next must stay off when the box holds no tiles", s.nextEnabled) + } + + @Test + fun `no tiles is ignored until an estimate has finished`() { + val s = compute(realBytes = null, noTiles = true) + assertEquals("Calculating download size…", s.text) + assertTrue(s.nextEnabled) + } + @Test fun `calculating - allows Next`() { val s = compute(realBytes = null, failed = false) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt index b21588e4..20bcf44b 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/BboxSelectionModelTest.kt @@ -155,6 +155,16 @@ class BboxSelectionModelTest { // PmtilesRegionSlicer.estimateRegionBytes = Σ byteLength + 8 KB overhead. assertEquals(1000L + 500L + 8L * 1024L, m.realByteEstimate) assertFalse(m.realEstimateFailed) + assertFalse(m.realEstimateHasNoTiles) + } + + @Test + fun `an empty estimate is flagged until the bbox changes`() { + val m = newModel() + m.onEstimateState(RegionSizeEstimator.State.Done(emptyList())) + assertTrue("an empty tile list must be flagged", m.realEstimateHasNoTiles) + m.applyBboxChange(Bbox.aroundPoint(5.0, 5.0, 50.0)) + assertFalse("a bbox change must clear the flag", m.realEstimateHasNoTiles) } @Test From 936ec67022170e20ba2b1362a8ff088232c4f513 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 27 Oct 2026 19:53:15 -0700 Subject: [PATCH 72/81] fix(maps): name regions in download snackbars, and say when Apply hits the active one (ADFA-2436) The post-download snackbars and the "Apply" confirmation showed the region id ("lalibela-2") instead of its name. Applying a re-download of the region that was already active said "Switched active region to", which was false. Snackbars now use the display name, and that case says the region is already active and its map in the project is updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/data/DownloadCompleteMessage.kt | 8 ++-- .../maps/data/DownloadCompletion.kt | 46 ++++++++++++++----- .../maps/ui/RegionManagerFragment.kt | 23 +++++++--- plugins/Maps/src/main/res/values/strings.xml | 1 + .../maps/data/DownloadCompleteMessageTest.kt | 12 ++--- .../maps/data/DownloadCompletionTest.kt | 46 ++++++++++++++----- 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt index 3b491dc1..b72eaa72 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt @@ -35,7 +35,7 @@ internal object DownloadCompleteMessage { ) /** - * Map the auto-activation [result] for [regionId] onto the Snackbar spec. + * Map the auto-activation [result] for the region named [displayName] onto the Snackbar spec. * * Branch behavior (mirrors [FirstRegionAutoActivator]'s decision table): * - First region (no active.txt) → confirm apply + activation. @@ -44,13 +44,13 @@ internal object DownloadCompleteMessage { * - Apply-failed / region-not-found → surface the reason so the user * knows why nothing happened. */ - fun forResult(result: FirstRegionAutoActivator.Result, regionId: String): Spec = when (result) { + fun forResult(result: FirstRegionAutoActivator.Result, displayName: String): Spec = when (result) { is FirstRegionAutoActivator.Result.Activated -> Spec(R.string.maps_download_done_applied, result.displayName) is FirstRegionAutoActivator.Result.NoOpAlreadyActive -> - Spec(R.string.maps_download_done_unchanged, regionId, showApplyAction = true) + Spec(R.string.maps_download_done_unchanged, displayName, showApplyAction = true) is FirstRegionAutoActivator.Result.NoOpRegionNotFound -> - Spec(R.string.maps_download_done_not_found, regionId) + Spec(R.string.maps_download_done_not_found, displayName) is FirstRegionAutoActivator.Result.ApplyFailed -> Spec(R.string.maps_download_done_apply_failed, result.reason) } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt index f2ef9619..89d5e90d 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompletion.kt @@ -18,18 +18,34 @@ internal class DownloadCompletion( private val cacheRoot: suspend () -> File = { RegionCache.rootDir() }, ) { - /** Where the completion left the project. */ + /** Where the completion left the project; [displayName] is the region's name for the snackbar. */ sealed class Outcome { - object NoProject : Outcome() + abstract val displayName: String - data class Finished(val projectDir: File, val result: FirstRegionAutoActivator.Result) : - Outcome() + data class NoProject(override val displayName: String) : Outcome() + + data class Finished( + val projectDir: File, + val result: FirstRegionAutoActivator.Result, + override val displayName: String, + ) : Outcome() + } + + /** What the snackbar's "Apply" did. */ + sealed class ApplyResult { + object Failed : ApplyResult() + + data class Switched(val name: String) : ApplyResult() + + /** The region was already active; the apply refreshed the project's copy. */ + data class AlreadyActive(val name: String) : ApplyResult() } /** Auto-activates [regionId] in the open project when that project has no active region yet. */ suspend fun complete(regionId: String): Outcome = withContext(Dispatchers.IO) { - val projectDir = project.currentProjectRoot() ?: return@withContext Outcome.NoProject + val name = findRegion(regionId)?.displayName ?: regionId + val projectDir = project.currentProjectRoot() ?: return@withContext Outcome.NoProject(name) val result = FirstRegionAutoActivator.maybeAutoActivate( projectDir = projectDir, @@ -39,15 +55,21 @@ internal class DownloadCompletion( applyRegionToProject = project::applyRegionToProject, writeActiveRegion = project::writeActiveRegionId, ) - Outcome.Finished(projectDir, result) + Outcome.Finished(projectDir, result, name) } - /** Copies [regionId] into [projectDir] and makes it active; false if it is gone from the cache. */ - suspend fun applyFromSnackbar(projectDir: File, regionId: String): Boolean = + /** Copies [regionId] into [projectDir] and makes it active; [ApplyResult.Failed] if it is gone. */ + suspend fun applyFromSnackbar(projectDir: File, regionId: String): ApplyResult = withContext(Dispatchers.IO) { - val info = - runCatchingCancellable { listRegions() }.getOrNull()?.firstOrNull { it.regionId == regionId } - ?: return@withContext false - project.applyAndActivate(info, projectDir) + val info = findRegion(regionId) ?: return@withContext ApplyResult.Failed + val wasActive = project.readActiveRegionId(projectDir) == regionId + when { + !project.applyAndActivate(info, projectDir) -> ApplyResult.Failed + wasActive -> ApplyResult.AlreadyActive(info.displayName) + else -> ApplyResult.Switched(info.displayName) + } } + + private suspend fun findRegion(regionId: String): RegionInfo? = + runCatchingCancellable { listRegions() }.getOrNull()?.firstOrNull { it.regionId == regionId } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt index ee32cb90..a7afb230 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionManagerFragment.kt @@ -355,10 +355,13 @@ class RegionManagerFragment : Fragment(), val outcome = downloadCompletion.complete(regionId) render(wizard.onEvent(Event.DownloadDone)) if (outcome !is DownloadCompletion.Outcome.Finished) { - snack(getString(R.string.maps_download_done_no_project, regionId), Snackbar.LENGTH_LONG) + snack( + getString(R.string.maps_download_done_no_project, outcome.displayName), + Snackbar.LENGTH_LONG, + ) return@launch } - val spec = DownloadCompleteMessage.forResult(outcome.result, regionId) + val spec = DownloadCompleteMessage.forResult(outcome.result, outcome.displayName) // Indefinite iff there's an action to take, so the user is not racing a timeout to Apply. val snackbar = makeSnack( getString(spec.messageRes, spec.messageArg), @@ -376,12 +379,18 @@ class RegionManagerFragment : Fragment(), /** The post-download snackbar's "Apply", for a project that already had an active region. */ private fun applyDownloadedRegionFromSnackbar(projectDir: File, regionId: String) { viewLifecycleOwner.lifecycleScope.launch { - if (downloadCompletion.applyFromSnackbar(projectDir, regionId)) { - refresh() - snack(getString(R.string.maps_regions_switched, regionId), Snackbar.LENGTH_SHORT) - } else { - snack(getString(R.string.maps_regions_apply_failed), Snackbar.LENGTH_LONG) + val message = when (val result = downloadCompletion.applyFromSnackbar(projectDir, regionId)) { + DownloadCompletion.ApplyResult.Failed -> { + snack(getString(R.string.maps_regions_apply_failed), Snackbar.LENGTH_LONG) + return@launch + } + is DownloadCompletion.ApplyResult.Switched -> + getString(R.string.maps_regions_switched, result.name) + is DownloadCompletion.ApplyResult.AlreadyActive -> + getString(R.string.maps_regions_already_active, result.name) } + refresh() + snack(message, Snackbar.LENGTH_SHORT) } } diff --git a/plugins/Maps/src/main/res/values/strings.xml b/plugins/Maps/src/main/res/values/strings.xml index ce13d967..ce6be988 100644 --- a/plugins/Maps/src/main/res/values/strings.xml +++ b/plugins/Maps/src/main/res/values/strings.xml @@ -115,6 +115,7 @@ Download failed: %1$s there is no map data in this area Switched active region to: %1$s + %1$s is already the active region. Its map in the project is updated. Calculating download size… %1$.1f MB LAN download stopped diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt index 59e356ec..0823e104 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt @@ -18,7 +18,7 @@ class DownloadCompleteMessageTest { fun `Activated - confirms the apply, no action`() { val spec = DownloadCompleteMessage.forResult( FirstRegionAutoActivator.Result.Activated(regionId = "lagos", displayName = "Lagos"), - regionId = "lagos", + displayName = "Lagos", ) assertEquals(R.string.maps_download_done_applied, spec.messageRes) assertEquals("Lagos", spec.messageArg) @@ -29,10 +29,10 @@ class DownloadCompleteMessageTest { fun `NoOpAlreadyActive - active region untouched, offers the Apply action`() { val spec = DownloadCompleteMessage.forResult( FirstRegionAutoActivator.Result.NoOpAlreadyActive, - regionId = "lagos", + displayName = "Lagos", ) assertEquals(R.string.maps_download_done_unchanged, spec.messageRes) - assertEquals("lagos", spec.messageArg) + assertEquals("Lagos", spec.messageArg) assertTrue("the only branch with a one-tap Apply escape hatch", spec.showApplyAction) } @@ -40,10 +40,10 @@ class DownloadCompleteMessageTest { fun `NoOpRegionNotFound - surfaces the cache miss, no action`() { val spec = DownloadCompleteMessage.forResult( FirstRegionAutoActivator.Result.NoOpRegionNotFound, - regionId = "lagos", + displayName = "Lagos", ) assertEquals(R.string.maps_download_done_not_found, spec.messageRes) - assertEquals("lagos", spec.messageArg) + assertEquals("Lagos", spec.messageArg) assertFalse(spec.showApplyAction) } @@ -51,7 +51,7 @@ class DownloadCompleteMessageTest { fun `ApplyFailed - surfaces the reason, no action`() { val spec = DownloadCompleteMessage.forResult( FirstRegionAutoActivator.Result.ApplyFailed(regionId = "lagos", reason = "disk full"), - regionId = "lagos", + displayName = "Lagos", ) assertEquals(R.string.maps_download_done_apply_failed, spec.messageRes) assertEquals("disk full", spec.messageArg) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt index 9ad5f44a..bf6c7c4d 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompletionTest.kt @@ -7,7 +7,6 @@ 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.assertTrue import org.junit.Before import org.junit.Test @@ -60,10 +59,10 @@ class DownloadCompletionTest { private fun active(): String? = runBlocking { project.readActiveRegionId(projectDir) } @Test - fun `no open project is reported and nothing is copied`() = runBlocking { - seed("alpha") + fun `no open project is reported by region name and nothing is copied`() = runBlocking { + seed("alpha", "Alpha Town") project.projectDir = null - assertEquals(DownloadCompletion.Outcome.NoProject, completion.complete("alpha")) + assertEquals(DownloadCompletion.Outcome.NoProject("Alpha Town"), completion.complete("alpha")) assertTrue(project.applied.isEmpty()) } @@ -75,6 +74,7 @@ class DownloadCompletionTest { DownloadCompletion.Outcome.Finished( projectDir, FirstRegionAutoActivator.Result.Activated("alpha", "Alpha Town"), + "Alpha Town", ), outcome, ) @@ -83,27 +83,49 @@ class DownloadCompletionTest { @Test fun `a project with a live active region keeps it`() = runBlocking { - seed("alpha") + seed("alpha", "Alpha Town") seed("beta") project.writeActiveRegionId(projectDir, "beta") val outcome = completion.complete("alpha") as DownloadCompletion.Outcome.Finished assertEquals(FirstRegionAutoActivator.Result.NoOpAlreadyActive, outcome.result) + assertEquals("Alpha Town", outcome.displayName) assertEquals("beta", active()) } @Test - fun `snackbar apply copies the region in and activates it`() = runBlocking { - seed("alpha") + fun `a region missing from an unreadable cache is named by its id`() = runBlocking { + listFails = true + project.projectDir = null + assertEquals(DownloadCompletion.Outcome.NoProject("alpha"), completion.complete("alpha")) + } + + @Test + fun `snackbar apply copies the region in, activates it and names it`() = runBlocking { + seed("alpha", "Alpha Town") project.writeActiveRegionId(projectDir, "beta") - assertTrue(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals( + DownloadCompletion.ApplyResult.Switched("Alpha Town"), + completion.applyFromSnackbar(projectDir, "alpha"), + ) assertEquals(listOf("alpha"), project.applied) assertEquals("alpha", active()) } + @Test + fun `snackbar apply of the already-active region says so and refreshes its copy`() = runBlocking { + seed("alpha", "Alpha Town") + project.writeActiveRegionId(projectDir, "alpha") + assertEquals( + DownloadCompletion.ApplyResult.AlreadyActive("Alpha Town"), + completion.applyFromSnackbar(projectDir, "alpha"), + ) + assertEquals(listOf("alpha"), project.applied) + } + @Test fun `snackbar apply of a region gone from the cache does nothing`() = runBlocking { seed("beta") - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) assertTrue(project.applied.isEmpty()) } @@ -111,16 +133,16 @@ class DownloadCompletionTest { fun `snackbar apply with an unreadable cache does nothing`() = runBlocking { seed("alpha") listFails = true - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) assertTrue(project.applied.isEmpty()) } @Test - fun `a failed snackbar copy reports false and leaves active_txt alone`() = runBlocking { + fun `a failed snackbar copy reports failure and leaves active_txt alone`() = runBlocking { seed("alpha") project.writeActiveRegionId(projectDir, "beta") project.applyResult = false - assertFalse(completion.applyFromSnackbar(projectDir, "alpha")) + assertEquals(DownloadCompletion.ApplyResult.Failed, completion.applyFromSnackbar(projectDir, "alpha")) assertEquals("beta", active()) } } From dc5407394303952ea7e011daa1f4dcecf8f00cfb Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 28 Oct 2026 01:03:26 -0700 Subject: [PATCH 73/81] fix(maps): frame a saved region's whole box when Refresh opens the picker (ADFA-2436) ZoomFit used the 256-px Web Mercator constant with the map's width in physical pixels, but MapLibre's camera zoom is based on a 512-dp world. On the Galaxy A06 (density 1.875) that zoomed in 3.75 times too far, so a saved region's edges were off screen. It also fitted the width only, so a tall region could overflow. Fit in dp with MapLibre's constant, on both axes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../org/appdevforall/maps/domain/ZoomFit.kt | 32 ++++++++---- .../maps/ui/BboxPickerFragment.kt | 5 +- .../maps/ui/PickerMapController.kt | 14 +++-- .../appdevforall/maps/domain/ZoomFitTest.kt | 51 ++++++++++++++++--- 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomFit.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomFit.kt index 34bb4e87..2b57df4b 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomFit.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/ZoomFit.kt @@ -2,6 +2,7 @@ package org.appdevforall.maps.domain import kotlin.math.cos import kotlin.math.log2 +import kotlin.math.min /** * Web-Mercator "zoom that fits a bbox to a fraction of the viewport" math. @@ -11,19 +12,28 @@ import kotlin.math.log2 */ object ZoomFit { - /** Web-Mercator meters-per-pixel at zoom 0 at the equator (2 × π × earthRadius / 256). */ - private const val MERCATOR_M_PER_PX_Z0 = 156543.034 + /** + * Meters per dp at zoom 0 at the equator in MapLibre's camera, whose world is 512 dp wide at + * zoom 0 (2 × π × earthRadius / 512). Using the 256-px figure with physical pixels over-zooms + * by 2 × density, which put a saved region's edges off screen on the Refresh flow. + */ + private const val MERCATOR_M_PER_DP_Z0 = 78271.517 + + /** Viewport assumed when the map view has not been laid out yet. */ + private const val FALLBACK_WIDTH_DP = 360.0 + private const val FALLBACK_HEIGHT_DP = 640.0 /** - * The zoom level at which [bbox] occupies [screenFraction] of a [mapWidthPx]-wide viewport. - * Clamped to `[0, 16]`. A non-positive [mapWidthPx] (view not laid out yet) falls back to 1080. + * The zoom at which [bbox] fits inside [screenFraction] of a [viewWidthDp] × [viewHeightDp] + * viewport in both directions. Clamped to `[0, 16]`; a non-positive size uses 360 × 640 dp. */ - fun fitZoom(bbox: Bbox, mapWidthPx: Int, screenFraction: Double): Double { - val centerLat = (bbox.south + bbox.north) / 2.0 - val widthMeters = bbox.widthKm() * 1000.0 - val targetPx = (mapWidthPx.takeIf { it > 0 } ?: 1080) * screenFraction - val cosLat = cos(Math.toRadians(centerLat)).coerceAtLeast(0.01) - val twoToZ = MERCATOR_M_PER_PX_Z0 * cosLat * targetPx / widthMeters - return log2(twoToZ).coerceIn(0.0, 16.0) + fun fitZoom(bbox: Bbox, viewWidthDp: Double, viewHeightDp: Double, screenFraction: Double): Double { + val widthDp = viewWidthDp.takeIf { it > 0 } ?: FALLBACK_WIDTH_DP + val heightDp = viewHeightDp.takeIf { it > 0 } ?: FALLBACK_HEIGHT_DP + val cosLat = cos(Math.toRadians((bbox.south + bbox.north) / 2.0)).coerceAtLeast(0.01) + val metersPerDpAtZ0 = MERCATOR_M_PER_DP_Z0 * cosLat + val zoomForWidth = log2(metersPerDpAtZ0 * widthDp * screenFraction / (bbox.widthKm() * 1000.0)) + val zoomForHeight = log2(metersPerDpAtZ0 * heightDp * screenFraction / (bbox.heightKm() * 1000.0)) + return min(zoomForWidth, zoomForHeight).coerceIn(0.0, 16.0) } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index 584baa33..fb8d820a 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -300,7 +300,10 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { overlay = bboxOverlay, selectionModel = selectionModel, scope = viewLifecycleOwner.lifecycleScope, - mapWidth = { mapView.width }, + mapSizeDp = { + val density = resources.displayMetrics.density + mapView.width / density.toDouble() to mapView.height / density.toDouble() + }, cacheDir = { context?.cacheDir }, isViewAlive = { this.view != null }, onBboxChanged = binder::onBboxChanged, diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt index bacce49d..3d97aac2 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt @@ -38,7 +38,7 @@ import org.maplibre.android.maps.Style * bbox, keeps [overlay] projected through pans and zooms, and shrinks the bbox to fit after the * user's own pans. * - * @param mapWidth the map view's current width in pixels, for fitting the first bbox + * @param mapSizeDp the map view's current width and height in dp, for fitting the first bbox * @param cacheDir where [MapFontExtractor] unpacks glyphs, or null once the fragment detaches * @param isViewAlive false once the picker's view is gone, so late callbacks do nothing * @param onBboxChanged re-renders whatever depends on the selection after the bbox changes @@ -49,7 +49,7 @@ internal class PickerMapController( private val overlay: BboxOverlayView, private val selectionModel: BboxSelectionModel, private val scope: CoroutineScope, - private val mapWidth: () -> Int, + private val mapSizeDp: () -> Pair, private val cacheDir: () -> File?, private val isViewAlive: () -> Boolean, private val onBboxChanged: () -> Unit, @@ -91,14 +91,18 @@ internal class PickerMapController( } /** - * Sets [bbox] and moves the camera to fit it to [GPS_DEFAULT_SCREEN_FRACTION] of the map width, + * Sets [bbox] and moves the camera to fit it inside [GPS_DEFAULT_SCREEN_FRACTION] of the map, * or to [NO_GPS_WORLD_ZOOM] when [worldView] is true. */ fun applyInitialBbox(bbox: Bbox, worldView: Boolean = false) { overlay.setBboxLatLon(bbox) val targetZoom = - if (worldView) NO_GPS_WORLD_ZOOM - else ZoomFit.fitZoom(bbox, mapWidth(), GPS_DEFAULT_SCREEN_FRACTION) + if (worldView) { + NO_GPS_WORLD_ZOOM + } else { + val (widthDp, heightDp) = mapSizeDp() + ZoomFit.fitZoom(bbox, widthDp, heightDp, GPS_DEFAULT_SCREEN_FRACTION) + } map.cameraPosition = CameraPosition.Builder() .target(LatLng((bbox.south + bbox.north) / 2.0, (bbox.west + bbox.east) / 2.0)) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt index d0be7e68..b241c8e3 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/ZoomFitTest.kt @@ -3,28 +3,65 @@ package org.appdevforall.maps.domain import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.tan class ZoomFitTest { + // The saved Lalibela region from the 10-27 device walk, and the Galaxy A06 map view in dp + // (720 x 1600 px at density 1.875). + private val lalibela = Bbox(9.961346470706417, 36.77746496147344, 14.919443564155998, 41.06244902809138) + private val a06WidthDp = 384.0 + private val a06HeightDp = 853.3 + + /** The bbox's on-screen size in dp at [zoom], from MapLibre's 512-dp-wide world, not ZoomFit's math. */ + private fun projectedSizeDp(bbox: Bbox, zoom: Double): Pair { + val worldDp = 512.0 * 2.0.pow(zoom) + fun mercY(lat: Double) = ln(tan(PI / 4 + Math.toRadians(lat) / 2)) + val width = (bbox.east - bbox.west) / 360.0 * worldDp + val height = (mercY(bbox.north) - mercY(bbox.south)) / (2 * PI) * worldDp + return width to height + } + + @Test + fun `a saved region fits inside the requested fraction of the screen on both axes`() { + val zoom = ZoomFit.fitZoom(lalibela, a06WidthDp, a06HeightDp, 0.5) + val (width, height) = projectedSizeDp(lalibela, zoom) + assertTrue("box is $width dp wide on a $a06WidthDp dp screen", width <= a06WidthDp * 0.5 * 1.02) + assertTrue("box is $height dp tall on a $a06HeightDp dp screen", height <= a06HeightDp * 0.5 * 1.02) + // A tight fit: the limiting axis fills the fraction rather than leaving the box tiny. + assertEquals(a06WidthDp * 0.5, width, a06WidthDp * 0.02) + } + + @Test + fun `a box taller than the screen's shape is fitted by its height`() { + val tall = Bbox(0.0, 0.0, 10.0, 1.0) + val zoom = ZoomFit.fitZoom(tall, a06WidthDp, a06WidthDp, 0.5) + val (_, height) = projectedSizeDp(tall, zoom) + assertEquals(a06WidthDp * 0.5, height, a06WidthDp * 0.02) + } + @Test fun `a smaller bbox fits at a higher zoom than a larger one`() { val small = Bbox.aroundPoint(0.0, 0.0, 10.0) val large = Bbox.aroundPoint(0.0, 0.0, 1000.0) - assertTrue(ZoomFit.fitZoom(small, 1080, 0.5) > ZoomFit.fitZoom(large, 1080, 0.5)) + assertTrue(ZoomFit.fitZoom(small, 360.0, 640.0, 0.5) > ZoomFit.fitZoom(large, 360.0, 640.0, 0.5)) } @Test fun `zoom is clamped to 0_16`() { val tiny = Bbox.aroundPoint(0.0, 0.0, 0.001) val huge = Bbox.aroundPoint(0.0, 0.0, 20000.0) - assertTrue(ZoomFit.fitZoom(tiny, 1080, 0.5) <= 16.0) - assertTrue(ZoomFit.fitZoom(huge, 1080, 0.5) >= 0.0) + assertTrue(ZoomFit.fitZoom(tiny, 360.0, 640.0, 0.5) <= 16.0) + assertTrue(ZoomFit.fitZoom(huge, 360.0, 640.0, 0.5) >= 0.0) } @Test - fun `non-positive map width falls back to 1080 (no crash, finite zoom)`() { - val z = ZoomFit.fitZoom(Bbox.aroundPoint(0.0, 0.0, 100.0), mapWidthPx = 0, screenFraction = 0.5) - assertTrue(z.isFinite()) - assertEquals(ZoomFit.fitZoom(Bbox.aroundPoint(0.0, 0.0, 100.0), 1080, 0.5), z, 1e-9) + fun `a map view not laid out yet falls back to 360 x 640 dp`() { + val box = Bbox.aroundPoint(0.0, 0.0, 100.0) + val z = ZoomFit.fitZoom(box, viewWidthDp = 0.0, viewHeightDp = 0.0, screenFraction = 0.5) + assertEquals(ZoomFit.fitZoom(box, 360.0, 640.0, 0.5), z, 1e-9) } } From 1b6df802cf6c82103156d5f2af9fff4ff8bf8f94 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 28 Oct 2026 01:05:14 -0700 Subject: [PATCH 74/81] fix(maps): update the project's map when the active region is downloaded again (ADFA-2436) FirstRegionAutoActivator returned NoOpAlreadyActive whenever active.txt named a cached region, including the region that had just been downloaded. So a re-download of the active region left the old tiles in the project and the snackbar asked the user to tap Apply. Re-apply in that case and report Updated, which the snackbar shows as "The project's map is updated" with no Apply action. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/data/DownloadCompleteMessage.kt | 3 ++ .../maps/data/FirstRegionAutoActivator.kt | 15 ++++++-- plugins/Maps/src/main/res/values/strings.xml | 1 + .../maps/data/DownloadCompleteMessageTest.kt | 11 ++++++ .../maps/data/FirstRegionAutoActivatorTest.kt | 35 +++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt index b72eaa72..7ab7fcbe 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/DownloadCompleteMessage.kt @@ -39,6 +39,7 @@ internal object DownloadCompleteMessage { * * Branch behavior (mirrors [FirstRegionAutoActivator]'s decision table): * - First region (no active.txt) → confirm apply + activation. + * - The active region downloaded again → its copy in the project is replaced, no action. * - Subsequent region (active.txt already set) → active region untouched, * but surface an "Apply" action for a one-tap switch. * - Apply-failed / region-not-found → surface the reason so the user @@ -47,6 +48,8 @@ internal object DownloadCompleteMessage { fun forResult(result: FirstRegionAutoActivator.Result, displayName: String): Spec = when (result) { is FirstRegionAutoActivator.Result.Activated -> Spec(R.string.maps_download_done_applied, result.displayName) + is FirstRegionAutoActivator.Result.Updated -> + Spec(R.string.maps_download_done_updated, result.displayName) is FirstRegionAutoActivator.Result.NoOpAlreadyActive -> Spec(R.string.maps_download_done_unchanged, displayName, showApplyAction = true) is FirstRegionAutoActivator.Result.NoOpRegionNotFound -> diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt index cc51c57f..10502d49 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivator.kt @@ -16,7 +16,8 @@ import org.appdevforall.maps.util.runCatchingCancellable * | Project state | Behavior | * | ---------------------------------------------- | --------------------- | * | No `active.txt` (or blank) | Apply + write active | - * | `active.txt` already names a region | No-op (user's choice) | + * | `active.txt` already names this region | Re-apply (new tiles) | + * | `active.txt` already names another 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 @@ -31,6 +32,9 @@ internal object FirstRegionAutoActivator { /** [downloadedRegionId] wasn't found in the cache root. */ object NoOpRegionNotFound : Result() + /** The active region was downloaded again and its copy in the project replaced. */ + data class Updated(val regionId: String, val displayName: String) : Result() + /** First region auto-activated successfully. */ data class Activated(val regionId: String, val displayName: String) : Result() @@ -72,13 +76,20 @@ internal object FirstRegionAutoActivator { // 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@withContext Result.NoOpAlreadyActive + if (activeStillExists && existingActive != downloadedRegionId) { + return@withContext Result.NoOpAlreadyActive + } // else fall through — sentinel is stale. } val info = cacheEntries.firstOrNull { it.regionId == downloadedRegionId } ?: return@withContext Result.NoOpRegionNotFound val applied = applyRegionToProject(info, projectDir) + if (applied && existingActive == downloadedRegionId) { + // A re-download of the active region: the project now has the new tiles, and active.txt + // already names it. + return@withContext Result.Updated(info.regionId, info.displayName) + } 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 diff --git a/plugins/Maps/src/main/res/values/strings.xml b/plugins/Maps/src/main/res/values/strings.xml index ce6be988..eb0c1c1a 100644 --- a/plugins/Maps/src/main/res/values/strings.xml +++ b/plugins/Maps/src/main/res/values/strings.xml @@ -106,6 +106,7 @@ LAN unknown Region downloaded and applied to project: %1$s + Region downloaded: %1$s. The project\'s map is updated. Region downloaded: %1$s. Project\'s active region is unchanged. Region downloaded but couldn\'t be located in cache: %1$s Region downloaded; apply failed: %1$s diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt index 0823e104..c13b3b83 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/DownloadCompleteMessageTest.kt @@ -25,6 +25,17 @@ class DownloadCompleteMessageTest { assertFalse(spec.showApplyAction) } + @Test + fun `Updated - says the project's map is updated, no Apply action`() { + val spec = DownloadCompleteMessage.forResult( + FirstRegionAutoActivator.Result.Updated(regionId = "lagos", displayName = "Lagos"), + displayName = "lagos", + ) + assertFalse("the copy is already updated, so there is nothing to apply", spec.showApplyAction) + assertEquals(R.string.maps_download_done_updated, spec.messageRes) + assertEquals("Lagos", spec.messageArg) + } + @Test fun `NoOpAlreadyActive - active region untouched, offers the Apply action`() { val spec = DownloadCompleteMessage.forResult( diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt index 303b36d5..0d259fbb 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/FirstRegionAutoActivatorTest.kt @@ -138,6 +138,41 @@ class FirstRegionAutoActivatorTest { assertEquals("lalibela", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) } + // ----- The active region downloaded again (Bryan, 2026-10-28: update the copy on its own) ----- + + @Test + fun reapplies_when_the_active_region_is_downloaded_again() = runBlocking { + seedCachedRegion("lalibela", "Lalibela") + writeActiveSentinel("lalibela") + val applyCalls = mutableListOf() + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = { info, _ -> applyCalls += info; true }, + writeActiveRegion = writeActive, + ) + assertEquals(FirstRegionAutoActivator.Result.Updated("lalibela", "Lalibela"), result) + assertEquals("the new tiles must be copied into the project", listOf("lalibela"), applyCalls.map { it.regionId }) + assertEquals("lalibela", File(projectDir, "$mapsSubpath/active.txt").readText().trim()) + } + + @Test + fun reports_the_failure_when_reapplying_the_active_region_fails() = runBlocking { + seedCachedRegion("lalibela", "Lalibela") + writeActiveSentinel("lalibela") + val result = FirstRegionAutoActivator.maybeAutoActivate( + projectDir = projectDir, + mapsSubpath = mapsSubpath, + regionsCacheRoot = cacheRoot, + downloadedRegionId = "lalibela", + applyRegionToProject = applyFail, + writeActiveRegion = writeActive, + ) + assertTrue("Expected ApplyFailed, got $result", result is FirstRegionAutoActivator.Result.ApplyFailed) + } + /** * Bryan, 2026-05-26 — observed bug: deleted all regions, downloaded * Spain, got "active region is unchanged" because the previously-active From 7f5f63be461e601d2618db1e1604b9c893031ddb Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 28 Oct 2026 01:08:04 -0700 Subject: [PATCH 75/81] fix(maps): declare PACKAGE_NAME in the template metadata (ADFA-2436) The template's Java/Kotlin sources live under app/src/main/java/PACKAGE_NAME/, but the .cgt metadata did not list packageName among its required parameters. CoGo's ZipRecipeExecutor then logs "Missing packageName, defaulted to PACKAGE_NAME" and ends project creation with a finished-with-warnings banner. showPackageNameOption() adds the parameter, as the NDK and Python templates do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8WVMAhapvazMLrZ7XozrL --- .../maps/templates/MapTemplateBuilder.kt | 3 +++ .../templates/MapTemplateBuilderBuildTest.kt | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt index b12863e2..cf11734c 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt @@ -148,6 +148,9 @@ internal object MapTemplateBuilder { // tile renders blank. Lives beside the template dir; not emitted into projects. .thumbnailFromAssets("$ASSETS_REGION_MAP/thumb.png", ctx) .showLanguageOption() + // The source paths use PACKAGE_NAME, so the metadata must declare it; without it + // CoGo logs "Missing packageName" and shows a finished-with-warnings banner. + .showPackageNameOption() .let { populateScaffold(it, ctx) } .build(outputDir) diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt index 8647d946..97e5fdb0 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/MapTemplateBuilderBuildTest.kt @@ -111,6 +111,23 @@ class MapTemplateBuilderBuildTest { ) } + @Test + fun `the metadata declares PACKAGE_NAME, which the template's source paths use`() { + val svc = RecordingTemplateService(accept = true) + MapTemplateBuilder.buildAndRegister( + AssetPluginContext(TestAssetManagers.fromDirectory(assetsRoot)), svc, File(tmp.root, "out"), + ) + val cgt = svc.registered.single() + val metadata = ZipFile(cgt).use { zip -> + zip.getInputStream(zip.getEntry("OfflineOSMMap/template/template.json")).reader().readText() + } + // CoGo's ZipRecipeExecutor warns when a template uses PACKAGE_NAME without declaring it. + assertTrue(zipEntries(cgt).any { "PACKAGE_NAME" in it }) + val required = org.json.JSONObject(metadata).getJSONObject("parameters").getJSONObject("required") + assertTrue("required parameters lack packageName: $required", required.has("packageName")) + assertEquals("PACKAGE_NAME", required.getJSONObject("packageName").getString("identifier")) + } + @Test fun `returns zero when the host declines the registration`() { val svc = RecordingTemplateService(accept = false) From 6aa3a1e34be5c30f8378ee0554de03e0e30f4818 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 30 Oct 2026 17:44:27 -0700 Subject: [PATCH 76/81] fix(maps): template tile server stops queueing connections behind idle sockets (ADFA-2436) The fixed pool of 8 threads added in f905870 let sockets that MapLibre opens without sending on them hold every worker until the 5 s read timeout, so tile requests waited in the queue and the generated app's map stayed blank. The pool now has no queue and up to 64 threads; past that, a connection is closed with a warning instead of waiting. The bound and the read timeout from the review stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../PACKAGE_NAME/PmtilesHttpServer.java.peb | 21 ++++++++++----- .../PACKAGE_NAME/PmtilesHttpServer.kt.peb | 22 +++++++++++----- .../PmtilesHttpServerTemplateTest.kt | 26 +++++++++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb index e1d4ddd0..83306348 100644 --- a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb @@ -14,8 +14,10 @@ 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; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; /** * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets @@ -35,13 +37,13 @@ public class PmtilesHttpServer { private static final int BUF = 64 * 1024; /** Bounds each read, not the whole request: a client that sends nothing for this long is dropped. */ static final int READ_TIMEOUT_MS = 5_000; - /** MapLibre opens a handful of connections at once; further ones wait in the queue. */ - private static final int MAX_CLIENTS = 8; + /** Caps threads without queueing, since a queued tile request waits behind idle sockets and the map stays blank. */ + static final int MAX_CLIENTS = 64; private final AssetManager assets; private final String rootPrefix; private ServerSocket socket; - private ExecutorService pool; + private ThreadPoolExecutor pool; private int port; private volatile boolean running; @@ -54,12 +56,17 @@ public class PmtilesHttpServer { socket = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); port = socket.getLocalPort(); running = true; - pool = Executors.newFixedThreadPool(MAX_CLIENTS); + pool = new ThreadPoolExecutor(0, MAX_CLIENTS, 30, TimeUnit.SECONDS, new SynchronousQueue<>()); Thread accept = new Thread(() -> { while (running) { try { Socket client = socket.accept(); - pool.submit(() -> handle(client)); + try { + pool.execute(() -> handle(client)); + } catch (RejectedExecutionException e) { + Log.w(TAG, "all " + MAX_CLIENTS + " threads busy, dropping a connection"); + try { client.close(); } catch (IOException ignored) {} + } } catch (IOException e) { if (running) Log.w(TAG, "accept: " + e); } diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb index 928aa2a5..83cd6b80 100644 --- a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb @@ -10,8 +10,10 @@ 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 +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.SynchronousQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit /** * Tiny in-process HTTP/1.1 server (loopback only) that serves bundled APK assets @@ -32,7 +34,7 @@ class PmtilesHttpServer( ) { private val rootPrefix: String = if (rootPrefix.endsWith("/")) rootPrefix else "$rootPrefix/" private var socket: ServerSocket? = null - private var pool: ExecutorService? = null + private var pool: ThreadPoolExecutor? = null var port: Int = 0 private set @Volatile private var running = false @@ -43,12 +45,18 @@ class PmtilesHttpServer( socket = s port = s.localPort running = true - pool = Executors.newFixedThreadPool(MAX_CLIENTS) + val workers = ThreadPoolExecutor(0, MAX_CLIENTS, 30, TimeUnit.SECONDS, SynchronousQueue()) + pool = workers val accept = Thread({ while (running) { try { val client = s.accept() - pool?.submit { handle(client) } + try { + workers.execute { handle(client) } + } catch (e: RejectedExecutionException) { + Log.w(TAG, "all $MAX_CLIENTS threads busy, dropping a connection") + try { client.close() } catch (_: IOException) {} + } } catch (e: IOException) { if (running) Log.w(TAG, "accept: $e") } @@ -166,8 +174,8 @@ class PmtilesHttpServer( /** Bounds each read, not the whole request: a client that sends nothing for this long is dropped. */ const val READ_TIMEOUT_MS = 5_000 - /** MapLibre opens a handful of connections at once; further ones wait in the queue. */ - const val MAX_CLIENTS = 8 + /** Caps threads without queueing, since a queued tile request waits behind idle sockets and the map stays blank. */ + const val MAX_CLIENTS = 64 /** * The (start, end) bytes (inclusive) a `Range: bytes=` value asks for, or null when it diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt index bd5414fa..2453dd54 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt @@ -132,6 +132,32 @@ class PmtilesHttpServerTemplateTest { } } + @Test + fun `a tile request is answered at once while idle connections hold workers`() { + val data = File(outDir, "tiles.pmtiles").apply { writeBytes(ByteArray(100) { it.toByte() }) } + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(ShortAssetManager.create(data, claimedLength = 100), "maps") + val port = serverClass.getMethod("start").invoke(server) as Int + // MapLibre keeps several sockets open without sending on them; each holds a worker until the read timeout. + val idle = List(8) { Socket(InetAddress.getLoopbackAddress(), port) } + try { + Thread.sleep(200) + Socket(InetAddress.getLoopbackAddress(), port).use { client -> + client.soTimeout = 2_000 + client.getOutputStream().write("GET /maps/tiles.pmtiles HTTP/1.1\r\nRange: bytes=10-19\r\n\r\n".toByteArray()) + val statusLine = try { + client.getInputStream().bufferedReader().readLine() + } catch (e: java.net.SocketTimeoutException) { + "no response within 2 s" + } + assertEquals("HTTP/1.1 206 Partial Content", statusLine) + } + } finally { + idle.forEach { it.close() } + serverClass.getMethod("stop").invoke(server) + } + } + private fun invokeOn(method: Method, target: Any, vararg args: Any?): Any? = try { method.invoke(target, *args) From e8ea0ffd19abcb944fec52145a1a8feda802dfd3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 30 Oct 2026 18:06:06 -0700 Subject: [PATCH 77/81] fix(maps): a glyph range the app does not bundle no longer blanks map tiles (ADFA-2436) MapLibre leaves blank every tile whose labels need a glyph range that failed to load. Only five Latin ranges are bundled, so regions with names such as Yoruba place names (combining tone marks, range 768-1023) opened on a blank launch view in the generated app. The generated app's tile server now answers a missing .pbf with an empty 200, and the picker's font extractor writes an empty file for every range it does not bundle. Characters outside the bundled ranges drop out of the label and the tile renders. The KDoc and template README claimed such labels showed tofu or went missing; both are corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../assets/templates/region-map/README.md.peb | 8 +++--- .../PACKAGE_NAME/PmtilesHttpServer.java.peb | 8 +++++- .../PACKAGE_NAME/PmtilesHttpServer.kt.peb | 6 ++++- .../maps/data/MapFontExtractor.kt | 19 +++++++++++--- .../maps/domain/IiabStyleBuilder.kt | 6 ++--- .../maps/templates/MapTemplateBuilder.kt | 3 ++- .../maps/data/MapFontExtractorTest.kt | 16 ++++++++++++ .../PmtilesHttpServerTemplateTest.kt | 26 +++++++++++++++++++ .../maps/testing/ShortAssetManager.kt | 11 +++++++- 9 files changed, 88 insertions(+), 15 deletions(-) diff --git a/plugins/Maps/src/main/assets/templates/region-map/README.md.peb b/plugins/Maps/src/main/assets/templates/region-map/README.md.peb index 41971076..12518c13 100644 --- a/plugins/Maps/src/main/assets/templates/region-map/README.md.peb +++ b/plugins/Maps/src/main/assets/templates/region-map/README.md.peb @@ -74,7 +74,7 @@ its on-map attribution control): — 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. +> Labels currently bundle **Latin** glyph ranges only. Characters outside them (for example +> Yoruba tone marks, Cyrillic, Arabic or CJK) are left out of the label, because the tile +> server answers any range it does not bundle with an empty glyph set. Add the matching Noto +> glyph ranges under `fonts/notosans/` if you target a region that uses them. diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb index 83306348..3a37fa4e 100644 --- a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.java.peb @@ -119,7 +119,13 @@ public class PmtilesHttpServer { AssetFileDescriptor afd; try { afd = assets.openFd(path); } - catch (IOException e) { writeStatus(out, 404, "Not Found"); return; } + catch (IOException e) { + // A glyph range the app does not bundle. MapLibre leaves blank every tile whose + // labels need a range that failed to load, so answer an empty glyph set instead. + if (path.endsWith(".pbf")) writeStatus(out, 200, "OK"); + else writeStatus(out, 404, "Not Found"); + return; + } long totalLen = afd.getLength(); try { long[] range = resolveRange(rangeSpec, totalLen); diff --git a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb index 83cd6b80..9eb45e60 100644 --- a/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb +++ b/plugins/Maps/src/main/assets/templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb @@ -108,7 +108,11 @@ class PmtilesHttpServer( val afd = try { assets.openFd(path) } catch (e: IOException) { - writeStatus(out, 404, "Not Found"); return + // A glyph range the app does not bundle. MapLibre leaves blank every tile whose + // labels need a range that failed to load, so answer an empty glyph set instead. + if (path.endsWith(".pbf")) writeStatus(out, 200, "OK") + else writeStatus(out, 404, "Not Found") + return } val totalLen = afd.length try { diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/MapFontExtractor.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/MapFontExtractor.kt index 0c974279..2ee2c65b 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/MapFontExtractor.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/data/MapFontExtractor.kt @@ -13,26 +13,31 @@ import org.appdevforall.maps.PluginLog * into a cache directory, then have `style.json` reference them via `file://` at the * cache path. * - * Latin coverage only; non-Latin scripts render as tofu boxes until those ranges are - * added to [BUNDLED_FONT_STACKS]. + * Only Latin ranges are bundled. Every other range gets an empty file, because MapLibre + * leaves blank every tile whose labels need a range that failed to load. */ object MapFontExtractor { /** * Glyph PBFs bundled in plugin assets under `assets/fonts/{stack}/{range}.pbf`. - * Latin coverage only; non-Latin scripts render as tofu boxes until those ranges - * are added. + * Latin coverage only. */ 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"), ) + /** Every range name MapLibre can request, "0-255" through "65280-65535". */ + private val ALL_RANGES: List = (0 until 256).map { "${it * 256}-${it * 256 + 255}" } + /** * Extract bundled glyph PBFs from plugin assets to filesystem cache on first * picker open. Copies each PBF once via [ResourceManager.openPluginAsset]; style.json * then references them via `file://` at the cache path. * + * Writes an empty glyph file for every range not bundled, so a label outside the + * bundled ranges drops those characters instead of blanking its tile. + * * 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)`. @@ -71,6 +76,12 @@ object MapFontExtractor { dest.delete() } } + for (range in ALL_RANGES - ranges.toSet()) { + val stub = File(stackDir, "$range.pbf") + if (stub.exists()) continue + runCatching { stub.createNewFile() } + .onFailure { PluginLog.warn("Font cache: empty range failed for $stack/$range.pbf", it) } + } } return root } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/IiabStyleBuilder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/IiabStyleBuilder.kt index 6fe420f3..f261f80d 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/IiabStyleBuilder.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/IiabStyleBuilder.kt @@ -19,7 +19,7 @@ object IiabStyleBuilder { * * @param pmtilesHttpUrl in-process pmtiles HTTP URL, or null when tiles aren't available yet. * @param fontsRoot absolute filesystem path of the extracted glyph PBFs, or null — when - * null, labels render as tofu (MapLibre logs a benign style error) but geometry still draws. + * null, glyph loads fail and MapLibre leaves blank every tile that has labels. */ fun buildStyle(pmtilesHttpUrl: String?, fontsRoot: String?): String { if (pmtilesHttpUrl == null) return BACKGROUND_ONLY_STYLE @@ -30,8 +30,8 @@ object IiabStyleBuilder { 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. + // Fonts unavailable: an asset:// URL the host can't fulfil. Glyph loads fail, so tiles + // with labels stay blank; this only happens when the cache dir can't be created. "asset://fonts/{fontstack}/{range}.pbf" } return """ diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt index cf11734c..214a5e34 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/templates/MapTemplateBuilder.kt @@ -110,7 +110,8 @@ internal object MapTemplateBuilder { "app/src/main/assets/maps/style.json", "app/src/main/assets/maps/meta.json", // Glyph fonts for place/street labels — served by the loopback HTTP - // server (style `glyphs` points at it). Latin-covering Noto Sans ranges; + // server (style `glyphs` points at it), which answers any other range with + // an empty glyph set. Latin-covering Noto Sans ranges; // the dir name `notosans` (no space) is the symbol layers' text-font and // avoids URL-encoding in the glyph path. "app/src/main/assets/maps/fonts/notosans/0-255.pbf", diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt index c367ec11..ed742b08 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/data/MapFontExtractorTest.kt @@ -58,6 +58,22 @@ class MapFontExtractorTest { } } + @Test + fun `every unbundled range gets an empty file and bundled ranges keep their bytes`() { + val root = MapFontExtractor.extract(FakeAssets(), tmp.root)!! + + for (stack in stacks) { + val dir = File(root, stack) + // MapLibre leaves a tile blank when a range its labels need fails to load. + val combining = File(dir, "768-1023.pbf") + assertTrue("$combining was not written", combining.isFile) + assertEquals(0L, combining.length()) + assertTrue(File(dir, "65280-65535.pbf").isFile) + assertEquals(256, dir.listFiles()!!.size) + assertArrayEquals("fonts/$stack/0-255.pbf".toByteArray(), File(dir, "0-255.pbf").readBytes()) + } + } + @Test fun `a second run reuses cached files but re-extracts an empty one`() { val root = MapFontExtractor.extract(FakeAssets(), tmp.root)!! diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt index 2453dd54..9501ce93 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/templates/PmtilesHttpServerTemplateTest.kt @@ -132,6 +132,32 @@ class PmtilesHttpServerTemplateTest { } } + private fun statusFor(request: String): String? { + val server = serverClass.getDeclaredConstructor(android.content.res.AssetManager::class.java, String::class.java) + .newInstance(ShortAssetManager.missing(), "maps") + val handle = serverClass.getDeclaredMethod("handle", Socket::class.java).apply { isAccessible = true } + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { listener -> + Socket(listener.inetAddress, listener.localPort).use { client -> + val accepted = listener.accept() + client.getOutputStream().write("GET $request HTTP/1.1\r\n\r\n".toByteArray()) + invokeOn(handle, server, accepted) + client.soTimeout = 2_000 + return client.getInputStream().bufferedReader().readLine() + } + } + } + + @Test + fun `an unbundled glyph range answers an empty 200 so MapLibre still draws the tile`() { + // A 404 here makes MapLibre leave blank every tile whose labels need this range. + assertEquals("HTTP/1.1 200 OK", statusFor("/maps/fonts/notosans/768-1023.pbf")) + } + + @Test + fun `a missing file that is not a glyph range is still a 404`() { + assertEquals("HTTP/1.1 404 Not Found", statusFor("/maps/tiles.pmtiles")) + } + @Test fun `a tile request is answered at once while idle connections hold workers`() { val data = File(outDir, "tiles.pmtiles").apply { writeBytes(ByteArray(100) { it.toByte() }) } diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt index 27248d63..9e10442d 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/testing/ShortAssetManager.kt @@ -10,6 +10,7 @@ import javax.tools.ToolProvider /** * An [AssetManager] whose `openFd` claims a length the file behind it does not have, and whose * stream's `skip` always returns 0. Drives the template server's failed-skip path on a plain JVM. + * [missing] instead has no assets at all, for the server's not-found paths. */ object ShortAssetManager { @@ -34,7 +35,10 @@ object ShortAssetManager { private final java.io.File file; private final long claimed; public MapsShortAssetManager(java.io.File file, long claimed) { this.file = file; this.claimed = claimed; } - @Override public AssetFileDescriptor openFd(String path) { return new MapsShortFd(file, claimed); } + @Override public AssetFileDescriptor openFd(String path) throws java.io.IOException { + if (file == null) throw new java.io.FileNotFoundException(path); + return new MapsShortFd(file, claimed); + } } """.trimIndent() @@ -45,6 +49,11 @@ object ShortAssetManager { managerClass.getConstructor(File::class.java, Long::class.javaPrimitiveType) .newInstance(file, claimedLength) as AssetManager + /** An [AssetManager] whose `openFd` throws for every path, as for an asset the APK lacks. */ + fun missing(): AssetManager = + managerClass.getConstructor(File::class.java, Long::class.javaPrimitiveType) + .newInstance(null, 0L) as AssetManager + private fun compileAndDefine(): Class<*> { val compiler = checkNotNull(ToolProvider.getSystemJavaCompiler()) { "needs a JDK compiler" } val androidJar = File(AssetManager::class.java.protectionDomain.codeSource.location.toURI()) From f57fb9592b47c2a9f926e6eb4d3835935fc55d20 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 30 Oct 2026 18:10:56 -0700 Subject: [PATCH 78/81] fix(maps): shrink the picker's bbox 250 ms after the map settles, not 1 s (ADFA-2436) Bryan reported the auto-shrink lagging badly on the A56. The shrink waited a fixed 1 s after MapLibre's camera idle, which itself only arrives once a fling has decelerated. Nothing on the shrink path is heavy; the delay was the wait. The wait was there so a multi-step pan/zoom did not shrink between steps, but a pending shrink was only cancelled by the next idle, so one could also land in the middle of the next drag. A new camera move now cancels it, which makes a short wait safe: 250 ms covers lifting a finger between two pans. The timing moves into AutoShrinkScheduler so it is JVM-testable. With the move-start cancel disabled, AutoShrinkSchedulerTest fails with "shrink ran during the user's next gesture expected:<0> but was:<1>". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../maps/ui/AutoShrinkScheduler.kt | 35 +++++++++ .../maps/ui/BboxPickerFragment.kt | 8 +-- .../maps/ui/PickerMapController.kt | 36 +++++----- .../maps/ui/AutoShrinkSchedulerTest.kt | 71 +++++++++++++++++++ 4 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/AutoShrinkScheduler.kt create mode 100644 plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/AutoShrinkScheduler.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/AutoShrinkScheduler.kt new file mode 100644 index 00000000..b8d2fbde --- /dev/null +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/AutoShrinkScheduler.kt @@ -0,0 +1,35 @@ +package org.appdevforall.maps.ui + +/** + * Times the bbox picker's auto-shrink: runs [shrink] [delayMs] after the camera goes idle, and + * drops the pending run as soon as the camera starts moving again. + * + * @param postDelayed runs an action after a delay and returns a function that cancels it + */ +internal class AutoShrinkScheduler( + private val delayMs: Long, + private val postDelayed: (delayMs: Long, action: () -> Unit) -> () -> Unit, + private val shrink: () -> Unit, +) { + + private var cancelPending: (() -> Unit)? = null + + /** Restarts the wait, so only the last idle of a pan-and-zoom sequence shrinks. */ + fun onCameraIdle() { + cancel() + cancelPending = + postDelayed(delayMs) { + cancelPending = null + shrink() + } + } + + /** A new gesture means the user is not done, so a shrink now would jump the box mid-drag. */ + fun onCameraMoveStarted() = cancel() + + /** Drops any pending shrink. */ + fun cancel() { + cancelPending?.invoke() + cancelPending = null + } +} diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index fb8d820a..91955d73 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -94,11 +94,11 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { internal const val GPS_DEFAULT_SCREEN_FRACTION = 0.5 /** - * 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. + * Wait this long after the camera goes idle before shrinking the bbox to the viewport. Idle + * already follows the fling, and a new gesture cancels the wait, so this only covers the + * finger lift between two pans. */ - internal const val AUTO_SHRINK_DEBOUNCE_MS = 1_000L + internal const val AUTO_SHRINK_DEBOUNCE_MS = 250L /** * Inset the auto-shrunk bbox by this fraction of the viewport on each side. diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt index 3d97aac2..d6f640ea 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/PickerMapController.kt @@ -4,7 +4,6 @@ import android.graphics.PointF import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,8 +61,20 @@ internal class PickerMapController( private var cameraIdleListener: MapLibreMap.OnCameraIdleListener? = null private var cameraMoveStartedListener: MapLibreMap.OnCameraMoveStartedListener? = null - /** The pending auto-shrink check, restarted on every camera idle so only the last one runs. */ - private var autoShrinkJob: Job? = null + private val autoShrinkScheduler = + AutoShrinkScheduler( + delayMs = AUTO_SHRINK_DEBOUNCE_MS, + postDelayed = { delayMs, action -> + val job = + scope.launch { + delay(delayMs) + if (isViewAlive()) action() + } + val cancel: () -> Unit = { job.cancel() } + cancel + }, + shrink = ::maybeShrinkBboxToFit, + ) /** Loads the style, then wires gestures, camera listeners and projection. */ fun start(prefillBbox: DoubleArray?) { @@ -79,7 +90,7 @@ internal class PickerMapController( /** Cancels the auto-shrink check and unregisters the camera listeners. */ fun release() { - autoShrinkJob?.cancel() + autoShrinkScheduler.cancel() runCatching { cameraMoveListener?.let { map.removeOnCameraMoveListener(it) } cameraIdleListener?.let { map.removeOnCameraIdleListener(it) } @@ -175,12 +186,13 @@ internal class PickerMapController( val center = map.cameraPosition.target ?: return@OnCameraIdleListener selectionModel.updateAnchor(center.latitude, center.longitude) overlay.recomputePixelRect() - scheduleAutoShrinkBbox() + autoShrinkScheduler.onCameraIdle() } // Only a REASON_API_GESTURE move is the user driving the viewport. Without this gate the idle // after the Refresh flow's own saved-bounds fit collapsed a 1054x1047 km region to 98x160 km. val moveStartedListener = MapLibreMap.OnCameraMoveStartedListener { reason -> + autoShrinkScheduler.onCameraMoveStarted() if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { selectionModel.noteUserCameraGesture() } @@ -227,19 +239,9 @@ internal class PickerMapController( .getOrNull() /** - * Shrinks the bbox to the viewport [AUTO_SHRINK_DEBOUNCE_MS] after the camera settles, if an - * edge left the view. The model ignores idles before the user's first gesture. + * Shrinks the bbox to the viewport if an edge left the view. The model ignores idles before the + * user's first gesture. */ - private fun scheduleAutoShrinkBbox() { - autoShrinkJob?.cancel() - autoShrinkJob = - scope.launch { - delay(AUTO_SHRINK_DEBOUNCE_MS) - if (!isViewAlive()) return@launch - maybeShrinkBboxToFit() - } - } - private fun maybeShrinkBboxToFit() { val bounds = runCatching { map.projection.visibleRegion.latLngBounds }.getOrNull() ?: return // Null means the bbox was already fully visible, or the viewport was degenerate. diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt new file mode 100644 index 00000000..cef192d0 --- /dev/null +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/ui/AutoShrinkSchedulerTest.kt @@ -0,0 +1,71 @@ +package org.appdevforall.maps.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for [AutoShrinkScheduler], with a fake clock standing in for the coroutine delay. */ +class AutoShrinkSchedulerTest { + + private class FakeTimer { + private class Pending(val dueAt: Long, val action: () -> Unit, var cancelled: Boolean = false) + + private val pending = mutableListOf() + var now = 0L + private set + + fun postDelayed(delayMs: Long, action: () -> Unit): () -> Unit { + val p = Pending(now + delayMs, action) + pending += p + return { p.cancelled = true } + } + + fun advanceBy(ms: Long) { + now += ms + val due = pending.filter { !it.cancelled && it.dueAt <= now } + pending.removeAll(due) + due.forEach { it.action() } + } + } + + private val timer = FakeTimer() + private var shrinks = 0 + private val scheduler = + AutoShrinkScheduler(delayMs = 250L, postDelayed = timer::postDelayed, shrink = { shrinks++ }) + + @Test + fun `idle shrinks once the delay has passed`() { + scheduler.onCameraIdle() + timer.advanceBy(249L) + assertEquals("shrink ran before the delay", 0, shrinks) + timer.advanceBy(1L) + assertEquals("shrink count after the delay", 1, shrinks) + } + + @Test + fun `a second idle restarts the wait so only the last one shrinks`() { + scheduler.onCameraIdle() + timer.advanceBy(200L) + scheduler.onCameraIdle() + timer.advanceBy(200L) + assertEquals("shrink ran on the superseded idle", 0, shrinks) + timer.advanceBy(50L) + assertEquals("shrink count after the last idle", 1, shrinks) + } + + @Test + fun `a gesture starting during the wait cancels the pending shrink`() { + scheduler.onCameraIdle() + timer.advanceBy(100L) + scheduler.onCameraMoveStarted() + timer.advanceBy(1_000L) + assertEquals("shrink ran during the user's next gesture", 0, shrinks) + } + + @Test + fun `cancel drops the pending shrink`() { + scheduler.onCameraIdle() + scheduler.cancel() + timer.advanceBy(1_000L) + assertEquals("shrink ran after cancel", 0, shrinks) + } +} From 1a2baf14ae9f7f593831a6ff42b5dcf098128a55 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 30 Oct 2026 18:11:05 -0700 Subject: [PATCH 79/81] fix(maps): keep region-list snackbars inside the Maps panel (ADFA-2436) Bryan saw the Maps tab shake, expand and contract while deleting old regions. Snackbar.make(requireView()) on the fragment's FrameLayout root walked up to CoGo's realContainer CoordinatorLayout, which sets animateLayoutChanges. Each snackbar added or removed there starts a LayoutTransition on the editor bottom sheet, and BottomSheetBehavior lays the sheet out at top 0 before offsetting it, so the transition animates the whole sheet open and back. A CoordinatorLayout root keeps the snackbar in the panel, where the FAB also moves up to clear it. Inferred from the code; not yet confirmed on a device. DiffUtil now identifies a row by its region folder rather than its meta.json id, which can repeat across folders; delete is keyed on the folder too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt | 4 +++- .../Maps/src/main/res/layout/fragment_region_manager.xml | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt index fbb09ecd..96b20732 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/RegionAdapter.kt @@ -53,8 +53,10 @@ class RegionAdapter( val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { override fun getOldListSize() = items.size override fun getNewListSize() = newItems.size + // The folder, not the meta.json id: two folders can carry the same id, and delete is + // keyed on the folder too. override fun areItemsTheSame(oldPos: Int, newPos: Int) = - items[oldPos].info.regionId == newItems[newPos].info.regionId + items[oldPos].info.directory == newItems[newPos].info.directory override fun areContentsTheSame(oldPos: Int, newPos: Int) = items[oldPos] == newItems[newPos] }) diff --git a/plugins/Maps/src/main/res/layout/fragment_region_manager.xml b/plugins/Maps/src/main/res/layout/fragment_region_manager.xml index 2d3b1db8..84b65e23 100644 --- a/plugins/Maps/src/main/res/layout/fragment_region_manager.xml +++ b/plugins/Maps/src/main/res/layout/fragment_region_manager.xml @@ -1,5 +1,7 @@ - + - + From 33e6b4fbffed70ab36fa89207b39c10f7ddf8324 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 30 Oct 2026 18:11:11 -0700 Subject: [PATCH 80/81] fix(maps): a pinch that starts on the bbox zooms the map (ADFA-2436) Bryan saw the box keep its screen size while zooming out instead of staying on its patch of map. The overlay sits above the MapView and takes any touch inside the box as a drag, and the container split multi-touch, so a pinch with one finger on the box dragged the box with that finger while the map only panned under the other. Auto-shrink leaves the box over the middle of the view, where pinch fingers land. Inferred from the code; not yet confirmed on a device. The picker container no longer splits touches, so both fingers reach one view. When a second finger lands during a box drag, the overlay puts the box back where the drag began and hands the gesture to the MapView. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../appdevforall/maps/ui/BboxOverlayView.kt | 65 +++++++++++++++++-- .../maps/ui/BboxPickerFragment.kt | 1 + .../main/res/layout/fragment_bbox_picker.xml | 6 +- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt index 613c2fec..a851c555 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxOverlayView.kt @@ -37,6 +37,9 @@ import kotlin.math.sqrt * 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. + * - A second finger during a box drag: the box goes back to where the drag + * started and the gesture is handed to [setMultiTouchTarget], so a pinch + * that starts on the box zooms the map. * * The opposite corner stays anchored; the two adjacent corners each share one * coordinate with the dragged corner. Result is always a valid axis-aligned @@ -102,6 +105,10 @@ class BboxOverlayView @JvmOverloads constructor( private var listener: Listener? = null private var dragMode: DragMode = DragMode.NONE + /** The box when the current drag began, restored if the drag turns into a pinch. */ + private var dragStartBbox: Bbox? = null + /** Receives a gesture the overlay gives up; wired to the map view. */ + private var multiTouchTarget: ((MotionEvent) -> Boolean)? = null /** 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. */ @@ -181,6 +188,11 @@ class BboxOverlayView @JvmOverloads constructor( listener = l } + /** Sets the view that takes over a gesture once a second finger lands during a box drag. */ + fun setMultiTouchTarget(target: ((MotionEvent) -> Boolean)?) { + multiTouchTarget = target + } + /** Current pixel-projected rect. Mostly used for hit-tests + tests. */ fun currentBboxPx(): RectF = RectF(rect) @@ -221,6 +233,7 @@ class BboxOverlayView @JvmOverloads constructor( val mode = hitTest(event.x, event.y) if (mode == DragMode.NONE) return false dragMode = mode + dragStartBbox = geoBbox if (mode == DragMode.TRANSLATE) { lastTouchPx = PointF(event.x, event.y) } @@ -228,8 +241,21 @@ class BboxOverlayView @JvmOverloads constructor( return true } + MotionEvent.ACTION_POINTER_DOWN -> { + if (dragMode == DragMode.NONE) return false + if (dragMode == DragMode.FORWARD) return forward(event) + handOffToMap(event) + return true + } + + MotionEvent.ACTION_POINTER_UP -> { + if (dragMode == DragMode.FORWARD) forward(event) + return dragMode != DragMode.NONE + } + MotionEvent.ACTION_MOVE -> { if (dragMode == DragMode.NONE) return false + if (dragMode == DragMode.FORWARD) return forward(event) if (dragMode == DragMode.TRANSLATE) { handleTranslateMove(event.x, event.y) } else { @@ -239,14 +265,17 @@ class BboxOverlayView @JvmOverloads constructor( } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (dragMode == DragMode.FORWARD) { + forward(event) + endDrag() + return true + } 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) + endDrag() return true } return false @@ -255,6 +284,32 @@ class BboxOverlayView @JvmOverloads constructor( return super.onTouchEvent(event) } + /** + * Puts the box back where the drag started and replays the gesture to [multiTouchTarget]: a + * synthetic down for the first finger, then the real second-finger down. + */ + private fun handOffToMap(event: MotionEvent) { + dragStartBbox?.let { setBboxLatLon(it) } + lastTouchPx = null + dragMode = DragMode.FORWARD + val first = if (event.actionIndex == 0) 1 else 0 + val down = MotionEvent.obtain( + event.downTime, event.eventTime, MotionEvent.ACTION_DOWN, event.getX(first), event.getY(first), 0, + ) + forward(down) + down.recycle() + forward(event) + } + + private fun forward(event: MotionEvent): Boolean = multiTouchTarget?.invoke(event) ?: false + + private fun endDrag() { + dragMode = DragMode.NONE + dragStartBbox = null + lastTouchPx = null + parent?.requestDisallowInterceptTouchEvent(false) + } + /** * Hit-test priority: * 1. Corner handles (with a size-scaled hit radius) → RESIZE_* @@ -391,7 +446,7 @@ class BboxOverlayView @JvmOverloads constructor( Bbox(south = newSouth, west = current.west, north = current.north, east = newEast) }.getOrNull() } - DragMode.NONE, DragMode.TRANSLATE -> null + DragMode.NONE, DragMode.TRANSLATE, DragMode.FORWARD -> null } ?: return geoBbox = updated recomputePixelRect() @@ -403,5 +458,5 @@ class BboxOverlayView @JvmOverloads constructor( 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 } + private enum class DragMode { NONE, RESIZE_TL, RESIZE_TR, RESIZE_BL, RESIZE_BR, TRANSLATE, FORWARD } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index 91955d73..dcd873bd 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -255,6 +255,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { // the selection (onBboxResized); camera pan/zoom just re-projects the fixed // lat/lon bbox to new pixels, it never mutates the selection. bboxOverlay.setListener { newBbox -> onBboxResized(newBbox) } + bboxOverlay.setMultiTouchTarget { event -> mapView.dispatchTouchEvent(event) } btnBack.setOnClickListener { back() } btnNext.setOnClickListener { next() } // The two estimate lines are what needs explaining, and they stay pressable while Next is disabled. diff --git a/plugins/Maps/src/main/res/layout/fragment_bbox_picker.xml b/plugins/Maps/src/main/res/layout/fragment_bbox_picker.xml index 4f19b3cc..e03d24a9 100644 --- a/plugins/Maps/src/main/res/layout/fragment_bbox_picker.xml +++ b/plugins/Maps/src/main/res/layout/fragment_bbox_picker.xml @@ -54,12 +54,14 @@ android:layout_height="1dp" android:background="?attr/colorOutlineVariant" /> - + + android:layout_weight="1" + android:splitMotionEvents="false"> Date: Fri, 30 Oct 2026 18:55:22 -0700 Subject: [PATCH 81/81] fix(maps): the bbox auto-fit never makes the selection larger (ADFA-2436) maps-e2e's A06 check of the faster auto-fit panned a 1669x2223 km box past the right edge and got back 3131x4873 km, with max zoom dropping from 12 to 11. When any edge left the view, AutoShrinkBbox returned the view inset by 35% on each side whatever the box's size, so in a view much wider than the box the "shrink" grew it. The result is now capped at the old box's span on each axis, centred where the inset view is centred, so the box still moves to where the user is looking. Without the cap the new AutoShrinkBboxTest case fails with "lat span must not grow expected:<1.0> but was:<6.0>". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014Ed1X3QzdWExhALiU221EG --- .../appdevforall/maps/domain/AutoShrinkBbox.kt | 18 +++++++++++++++--- .../appdevforall/maps/ui/BboxPickerFragment.kt | 2 +- .../appdevforall/maps/ui/BboxSelectionModel.kt | 3 ++- .../maps/domain/AutoShrinkBboxTest.kt | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt index 9e16d43e..da822709 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/domain/AutoShrinkBbox.kt @@ -1,14 +1,16 @@ package org.appdevforall.maps.domain +import kotlin.math.min + /** * 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). + * - Triggered shortly 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. + * every side, capped at the old bbox's span on each axis so it never grows. * * The "is bbox fully inside viewport" check naturally no-ops the cases that * shouldn't change the selection: @@ -58,6 +60,16 @@ internal object AutoShrinkBbox { 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) + // In a view much wider than the box, the inset alone would enlarge the selection. + val halfLat = min(newN - newS, bbox.north - bbox.south) / 2 + val halfLon = min(newE - newW, bbox.east - bbox.west) / 2 + val midLat = (newN + newS) / 2 + val midLon = (newE + newW) / 2 + return Bbox( + south = midLat - halfLat, + west = midLon - halfLon, + north = midLat + halfLat, + east = midLon + halfLon, + ) } } diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt index dcd873bd..7db97bec 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxPickerFragment.kt @@ -102,7 +102,7 @@ class BboxPickerFragment : androidx.fragment.app.DialogFragment() { /** * 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 + * 0.35 leaves the selection at most ~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. */ diff --git a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt index 9376af15..630c96fa 100644 --- a/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt +++ b/plugins/Maps/src/main/kotlin/org/appdevforall/maps/ui/BboxSelectionModel.kt @@ -141,7 +141,8 @@ internal class BboxSelectionModel(initialBbox: Bbox) { /** * The state half of the auto-shrink check: if the current bbox has an edge * outside the viewport ([viewN]/[viewS]/[viewE]/[viewW]), shrink it to the - * viewport inset by [margin] and apply that as a normal bbox change. + * viewport inset by [margin], no larger than it was, and apply that as a + * normal bbox change. * Returns the new bbox for the Fragment to push onto the overlay, or null * when nothing changed (bbox already fully visible / degenerate viewport). * diff --git a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt index 32ad8166..7dc4c4fc 100644 --- a/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt +++ b/plugins/Maps/src/test/kotlin/org/appdevforall/maps/domain/AutoShrinkBboxTest.kt @@ -90,6 +90,23 @@ class AutoShrinkBboxTest { assertEquals(0.3, result.east, 1e-9) } + @Test + fun `pan a small box past the edge of a wide view — recentres without growing`() { + // A06 device repro, 2026-10-30: a 1669x2223 km box panned past the right edge of a much + // wider view came back as 3131x4873 km, because the inset viewport was bigger than the box. + val bbox = box(s = -0.5, w = 9.5, n = 0.5, e = 10.5) + val result = AutoShrinkBbox.computeShrunkBbox( + bbox = bbox, + viewN = 10.0, viewS = -10.0, viewE = 10.0, viewW = -10.0, + margin = M, + ) + assertNotNull("partial offscreen should still recentre", result) + assertEquals("lat span must not grow", 1.0, result!!.north - result.south, 1e-9) + assertEquals("lon span must not grow", 1.0, result.east - result.west, 1e-9) + assertEquals("recentred on the view", 0.0, (result.north + result.south) / 2, 1e-9) + assertEquals("recentred on the view", 0.0, (result.east + result.west) / 2, 1e-9) + } + @Test fun `bbox exactly at viewport edge — counts as inside, no shrink`() { // Boundary-inclusive: bbox edges touch viewport edges exactly. Should