Skip to content

ADFA-2436: Add Maps (offline OSM) plugin - #22

Open
fryanpan wants to merge 12 commits into
mainfrom
feature/ADFA-2436-maps-plugin
Open

ADFA-2436: Add Maps (offline OSM) plugin#22
fryanpan wants to merge 12 commits into
mainfrom
feature/ADFA-2436-maps-plugin

Conversation

@fryanpan

@fryanpan fryanpan commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

ADFA-2436: Add Maps (offline OSM) plugin

Ticket

ADFA-2436

Description

Adds a Maps plugin that helps developers make a working offline mapping app in a few minutes.

Lets the developer download an OpenStreetMap (OSM) map region from an Internet in A Box (IIAB) server and build a read-only mapping app (annotation left for a future ticket)

Workflow

  1. User installs the Maps plugin in CodeOnTheGo
  2. Creates a New Project using the "Offline OSM Map" template (template provided by the plugin)
  3. In the Maps tab in the bottom sheet, they can download one or more regions and make this region activeThis downloads map data either from an IIAB server either on the internet, or local LAN
  4. Build the project
  5. Run the new app!

You need internet for steps 1 and 3, but the developer can start working offline after that. And the generated app also works offline.

Details by Commit

I asked Claude to reorganize the work into commits, to try and make a big PR easier to review. Each file is in exactly one commit. Some commits could benefit from more review than others.

In the "Files Changed" tab above, you can review by commit to get a more organized view:

Commit What it is LoC Review Notes
5049a68 README + HTML tutorial + plugin registration ~500 read Intro and docs. The tutorial could probably use another edit pass at some point -- lmk if there's anything to change here.
7981fa8 Scaffold + build config + entry point ~1,200 skim Gradle, JaCoCo + test deps, manifest (incl. the min_ide_version pin with no max), MapsPlugin. Mostly boilerplate.
c044a6a domain + util (+ tests) ~1,400 read The readable, correctness-critical pure logic — bbox/zoom math, region-id validation, atomic file writes. No Android deps, ~100% unit-tested.
44ce879 slicer — PMTiles bbox extraction (+ tests) ~4,100 skim This is the logic to parse the 87GB single PMTiles file in IIAB's OpenStreetMap of the whole world. I let Claude implement this...but if it gets too hard to maintain/is buggy, we can bring in the pmtiles CLI that IIAB uses (will increase plugin size a bit)
c30e5ca data layer (+ tests) ~2,700 read Region cache, downloader (refactored with injectable IO seams), installer, stores. Path-traversal + atomic-write guards.
b3b53f2 UI — tab, region manager, bbox picker, wizard ~4,700 read UI code. Tested in E2E, not fully unit-tested.
9c5acb4 project template + emitter (+ tests) ~2,600 read The emitter + the Kotlin and Java template sources for the generated region-map app. Includes a simple local HTTP for serving tiles (see notes below)
87d2746 offline-build support (vendored MapLibre repo) ~27,800 (199 files) skim briefly Vendored Maven repo (AARs + POMs). Verify licenses + the artifact set, not line-by-line. The reversible decision (alternative: require the user to be online for the first build).
36c9985 bundled assets ~180 skim Natural Earth basemap (CC0), Noto fonts (OFL), day/night icons (CC0), THIRD_PARTY_LICENSES. Check attribution + licensing is reasonable, not code.

Testing

Automated checks below in the table, plus I've manually tested the flow + reviewed the code.

Gate Status
Build clean (assemblePlugin + test) ✅ green — 359 tests, 0 failures
≥90% non-UI coverage + quality ✅ 98.8% line / 89.3% branch (pure non-UI logic); 8-check quality assessment
android-qa device walk ✅ test-cases.md reviewed; device-verified; StrictMode noise traced to CoGo host (ADFA-4121), not the plugin
UX review ✅ on the working e2e flow (2026-06-01); no UX changes this session
License audit ✅ 0 blockers; hilbert-curve attribution gap fixed
Code review ✅ 0 blockers; ran code review with Claude and Codex CLI
New plugin review skill ✅ GREEN-LIGHT — 7/7 rubric clauses Pass, security clean
Completeness sweep ✅ clean (naming / metadata / icons / help / deploy)
E2E Video Recording ✅ See links below in E2E section

P.S. The automated checks are a bit messy and defined in the wrapper repo. I'll make a future ticket to look into cleaning them up and adding them into the plugin repo. Let me know if there are parts of this you think are especially important (or not important!)

Unit Tests

Didn't spend too much effort trying to get to 100% coverage. Did try to get over 90% on most non-UI code....but it was a bit hard to test some of the areas that required Android integration or mocking out CodeOnTheGo integration. So I left it to the E2E tests to cover these areas.

./gradlew jacocoTestReport

Coverage by folder (full breakdown + the 8-check quality assessment in coverage-quality-assessment-2026-06-04.md):

Folder Line Branch
domain/ 100% 98%
slicer/ 100% 88%
util/ 96% 100%
data/ 77% 79%
templates/ 68% 56%
maps/ (MapsPlugin) 62% 29%
ui/ 0% (device-tested)
Pure non-UI logic (excl. the 4 Android-host-bound classes) 98.8% 89.3%

On-Device Automated E2E QA Walk

Was using this method regularly during development to have Claude do end-to-end testing using mobile-mcp.

Link to the test cases here which cover the whole flow from installing the plugin, using the project template, downloading and managing map regions, building the templated app offline, running the templated app, and testing the offline map in the templated map. The tests also went through both Kotlin and Java project templates.

Device recordings from running the tests:

StrictMode

Was getting a few warnings about this a few days ago (e.g. warnings/errors during project creation)...did a cleanup pass. The plugin should be StrictMode clean now -- no main-thread I/O. Sockets are traffic-stats-tagged.

But creating a project still triggers StrictMode warnings that are coming from the CodeOnTheGo side. Added a comment to ADFA-4121.

Notes & Questions for Reviewers

  • Ease of Testing + CoverageCouple of followup items would help in the future. I'll add some followup tickets:

    • Adding Kaspresso testing to the plugin repo (installs some version of CoGo and then installs + tests plugin) would helpThis would cover 80-90% of the E2E tests I ran
    • Adding mobile-mcp testing + the android QA skill from wrapper repo would also help with the tests that are a bit harder to arrange in Kaspresso (e.g. that require building an app and then starting the new app)
  • Decisions to Note

    • Offline Build Support: Decided it might be worth having in ~15 MB of vendored MapLibre libraries so a developer can build the templated map app offline. It may also be okay to drop that 15 MB download and ask the user to build once while they still have internet.
    • PMTiles SlicerClaude made a case that it could do this quickly and would save 5+ MB of CLI download of the pmtiles Go CLI that IIAB uses. So Claude was quite fast...and the code seems to work. But it also ended up being 4k lines! It might be better in the long run to rip this out and switch to the CLI to stay similar to IIAB.
    • Local HTTP Server: Ran into an issue getting MapLibre to read local-filesystem map tiles. In the MapLibre forums, running a simple local HTTP server seemed like an acceptable workaround. Maybe with more digging (or a future MapLibre version) it won't be needed.
  • Future Improvements — Two nice-to-haves tracked but not blocking v1

    • GPS location permission in CoGo's manifest so the bbox picker can show current location (needs a host change)
    • Let the user download their first region during New Project creation for a smoother one-step flow (complex — requires async work inside the Pebble wizard; current two-step create→Maps-tab→download is good enough for v1).

fryanpan and others added 10 commits June 4, 2026 20:59
…t tests)

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 <noreply@anthropic.com>
From the license-audit + code-review DoD pass (both 0-blocker):
- THIRD_PARTY_LICENSES.txt: add com.github.davidmoten:hilbert-curve:0.2.3 and its
  transitives (guava-mini, listenablefuture) — Apache-2.0 runtime deps bundled in
  the .cgp that were the one un-attributed entry.
- SourcePickerFragment.persistLanHost: use applicationContext (matching the off-main
  read) so the write shares the one process-wide SharedPreferencesImpl — a future
  reorder can't reintroduce a first-access disk read on Main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@fryanpan
fryanpan marked this pull request as ready for review June 5, 2026 16:13

@jatezzz jatezzz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these files are quite long. Please consider splitting them into smaller, more focused files to improve readability and adhere to the Single Responsibility Principle (SRP).

@@ -0,0 +1,93 @@
package org.appdevforall.maps.data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure file operations runs on Dispatchers.IO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks...no need to identify more of these examples. I'll go through and review all file operations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — and I swept the whole data layer rather than just the three files you flagged. Concretely:

data/ went from 4 suspend functions to 23 — 19 file-I/O entry points that previously ran on the caller's thread now run on Dispatchers.IO. By class:

Class I/O entry points moved to Dispatchers.IO
RegionCache 5
ActiveRegionStore (you flagged) 3 — read, write, clear
FirstRegionAutoActivator (you flagged) 1
MapFontExtractor 1
RegionInstaller 1 (a second one was already correct)
ProjectRegionCoordinator, ReachabilityProbe 1 each (new — extracted out of the Fragments)

And the fragment refactor (see other reply) also moved all I/O out to the data layer.

@@ -0,0 +1,95 @@
package org.appdevforall.maps.data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure file operations runs on Dispatchers.IO.

@@ -0,0 +1,280 @@
package org.appdevforall.maps.data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure file operations runs on Dispatchers.IO.

@fryanpan

Copy link
Copy Markdown
Contributor Author

Some of these files are quite long. Please consider splitting them into smaller, more focused files to improve readability and adhere to the Single Responsibility Principle (SRP).

@jatezzz thanks for reviewing. I already worked with Claude to do a couple of cohesion and coupling passes. Broke up many things and made them more testable.

Looks like I missed one file...BboxFragmentPicker needs help.

And I'll try to break up the RegionManagerFragment too...that popped up as a borderline judgement call last time. There's probably a better way to organize the wizard steps.

Anything else you spotted?

Also is there a guideline for how this team applies SRP? It's one of the more vague rules and I'm curious how you and the team apply it.

The more specific we can be, the more likely it is that agents can plan for and build the right thing on the first try.

@jatezzz

jatezzz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

@fryanpan Thanks for the open response and for doing those passes with Claude!

Regarding the files: BboxFragmentPicker and RegionManagerFragment are definitely the main ones. For the wizard, splitting it into separate smaller fragments (or custom views) orchestrated by a single shared ViewModel or a state machine usually keeps things much cleaner. I didn't spot any other major offenders, but those two are a great place to start!

You make a fantastic point about SRP being vague, and I completely agree that being specific helps both humans and AI agents. Here is how we generally look at SRP pragmatically on this team:

  1. Separation of Concerns (The "UI vs. Logic" rule): Fragments/Activities should strictly handle OS lifecycle, UI bindings, and user input routing. If a Fragment is formatting data, making decisions about business rules, or directly touching file I/O/Network (which also ties into the Dispatchers.IO comment), it's violating SRP. That logic belongs in a ViewModel or a UseCase/Domain class.
  2. The "And" Heuristic: If you describe what a class does and have to use the word "AND" (e.g., "It picks a bounding box AND formats the coordinates AND saves them to disk"), it probably has too many responsibilities.
  3. Dependency Bloat: If a class or ViewModel requires injecting more than 4-5 repositories or services to do its job, it’s usually a sign that it’s orchestrating too much and should be broken down into smaller, focused UseCases.
  4. Line Count (Soft limit): While not a hard rule, if a file starts crossing the 300-400 line mark, it's usually a strong smell that it's accumulating responsibilities.

I love your point about agents planning better with specific rules. We should probably document these exact heuristics in a guide file so the AI tools can pick them up automatically as context.

Let me know how the refactoring goes with those two fragments!

fryanpan added 2 commits July 13, 2026 11:23
…(ADFA-2436)

Responds to the review on PR #22.

Dispatchers.IO (3 inline comments). Every entry point in data/ is now
`suspend` + `withContext(Dispatchers.IO)` — ActiveRegionStore, RegionCache and
FirstRegionAutoActivator (the three called out), plus RegionDownloader,
RegionInstaller, ProjectRegionCoordinator, MapFontExtractor, RegionSizeEstimator
and ReachabilityProbe. The dispatch is pushed down into the data layer rather
than wrapped at the Fragment call sites, so a caller can't do file I/O on Main.

SRP / long files. Business logic pulled out of the Fragments:
  - domain/ (pure Kotlin, JVM-unit-tested): AutoShrinkBbox, ZoomFit, ZoomLabel,
    EstimateDisplay, IiabStyleBuilder, RegionWizardStateMachine
  - data/: RegionSizeEstimator, ProjectRegionCoordinator, ReachabilityProbe
  - ui/: BboxSelectionModel, MapLocationController, BboxPickerArgs
  - slicer/: TileMath extracted from PmtilesRegionSlicer
The download wizard is now per-step Fragments (SourcePicker, DownloadProgress,
Step3Save) orchestrated by RegionWizardStateMachine, per the reviewer's
suggestion.

Line counts, code only (comments + blanks stripped):
  BboxPickerFragment    1320 -> 762 raw / 446 code
  RegionManagerFragment  647 -> 542 raw / 401 code
  PmtilesRegionSlicer    643 -> 566 raw / 349 code

Also in this pass: dark-mode correctness, and dead branding tokens stripped from
PluginTheme (plugins take the host's Material colors — they can't re-theme
host-loaded Material widgets through plugin theme attrs).
@fryanpan

fryanpan commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Re: SRP

Tried to address SRP comments by splitting up UI vs. logic and break out responsibilities more sensibly:

  • BboxPickerFragment
    • Building the map style / IIAB tile source
      • ~160 lines moved to domain/IiabStyleBuilder
    • Location permissions + "center on me"
      • ~140 lines moved to ui/MapLocationController
    • Region size estimate
      • ~80 lines of logic moved to data/RegionSizeEstimator
      • ~50 lines of label formatting moved to domain/EstimateDisplay
    • Selection state
      • ~100 lines moved to ui/BboxSelectionModel
      • Includes bbox interaction logic (current box, zoom caps, estimate state and the transitions between them), previously scattered Fragment fields
    • Other misc. cleanup
      • ~45 lines of launch args to BboxPickerArgs
      • ~30 lines of MapLibre bootstrap to MapLibreBootstrap
      • ~30 lines of zoom math to ZoomLabel
    • LoC: 1320 → 762 (446 without comments)
      • Still a bit big, but smaller than before
  • RegionManagerFragment
    • Wizard orchestration
      • ~140 lines of step state and transitions moved to domain/RegionWizardStateMachine
    • The logic & IO to read/write active region for a project
      • ~50 lines moved out to data/ProjectRegionCoordinator
    • Download-result → snackbar-message mapping
      • ~20 lines moved to data/DownloadCompleteMessage, now pure and tested
    • LoC: 647 → 542

Re: Documentation

Will try to get more about SRP guidelines into a draft PR on the plugin-examples side, and also CoGo side.

@fryanpan fryanpan closed this Jul 13, 2026
@fryanpan fryanpan reopened this Jul 13, 2026
@fryanpan

fryanpan commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

How this was tested

Automated

  • 440 JVM unit tests, up from 342 — the classes extracted in the refactor are now independently tested
  • ./gradlew clean assemblePlugin test green on JDK 17

On device — Samsung A56 (Android 16), plugin installed from a clean state, both template languages:

  • Kotlin — new project from the "Offline OSM Map" template
  • Java — full flow: create project → Maps tab → download + activate a region (SF Bay area, 98.6 MB) → build → install → launch → map renders with street names

Recordings

@jatezzz jatezzz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overview

A new headless-plus-UI plugin that adds a Maps bottom-sheet tab and an Offline OSM Map project template. The interesting engineering is range-slicing a bbox out of IIAB's ~87 GB global PMTiles archive rather than downloading it, then bundling the slice into a generated MapLibre app that serves its own tiles over a loopback HTTP server. Region cache is shared across projects at /sdcard/CodeOnTheGo/maps/<id>/.

This is high-quality work. The comments explain why consistently (the native.code permission, the pmtiles://http:// scheme constraint, the ConnectivityReceiver.setConnected(true) hack, Pebble's newline trimming) — that's the kind of documentation that saves the next person a device round-trip. Day/night theming follows the reference pattern exactly (values-night/, themedPluginInflater + onGetLayoutInflater on all five fragments, zero hardcoded hex, zero hardcoded layout strings). Path-traversal guards are layered (regex validation plus canonical containment) in every path that resolves under the cache root.


Blocking

1. Tooltip category is wrong → tab tooltip will render n/a

maps/src/main/kotlin/org/appdevforall/maps/MapsPlugin.kt:189

override fun getTooltipCategory(): String = "plugin_maps"

The category must be "plugin_" + the full plugin.id, i.e. "plugin_org.appdevforall.maps". The host registers entries under the returned string but derives plugin_<pluginId> when resolving a lookup, so a short slug silently mismatches and long-press shows the literal string n/a. The build stays green and the DB row is present — only device long-press catches it.

override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID"

MapsPluginCoverageTest.kt:188 asserts the wrong value too, so it needs updating with the fix. Worth noting: several existing plugins in this repo (bookshelf, random-xkcd, keystore-generator) have the same short-slug shape, while compose-preview, sketch-to-ui-plugin, and apk-viewer derive from the id. If device testing showed the Maps tooltip working, then the host is more lenient than documented — say so and I'll drop this — but the documented contract and the majority-correct plugins both point at deriving from PLUGIN_ID, which is free to do either way.

2. Template tooltip tag has no registered entry

MapTemplateBuilder.kt:63,146 attach tooltipTag = "maps.template.region_map" to the template, but getTooltipEntries() (MapsPlugin.kt:191) returns exactly one entry, tagged maps.bottom_sheet.maps_tab. The New Project card's tooltip has nothing to resolve → n/a. Add a second PluginTooltipEntry for the template tag.

3. Hard-coded upstream archive date breaks every download on rotation

RegionDownloader.kt:54,327

private const val FALLBACK_VECTOR_DATE = "2026-04-01"
...
"openstreetmap-openmaptiles.$FALLBACK_VECTOR_DATE.z00-z14.pmtiles"

The constant is named FALLBACK_ but it's the only path — there is no discovery. HttpRangeByteCache.clear()'s own comment says "IIAB rotates the same URL weekly"; when that rotation drops the 2026-04-01 file, every download 404s with no recovery and the plugin looks broken in the field.

The fix is already half-built: ReachabilityProbe.buildLanProbeUrl (ReachabilityProbe.kt:47) HEAD-probes /maps/extracts.json, "the canonical IIAB inventory". Read that inventory to resolve the current filename and keep the constant as a genuine fallback.


Should fix

4. Play Services dependency contradicts the template and can crash on GMS-free devices

build.gradle.kts:92 pulls play-services-location:21.3.0, and MapLocationController.kt:165 calls LocationServices.getFusedLocationProviderClient(ctx) — the one unguarded call in a file that runCatchings everything else. On a de-Googled or AOSP device (a meaningful slice of ADFA's low-end target audience), client construction can throw before either listener is attached, and it propagates out of fetchLastKnownLocation().

Meanwhile the generated app deliberately avoids GMS — app/build.gradle.kts.peb says "The map's current-location dot uses MapLibre's built-in default location engine, so no Play Services Location dependency is needed", and MapRegionActivity proves it works. The plugin's own enableLocationComponent already uses useDefaultLocationEngine(true). Drop the dependency and use the existing fallbackToLocationManager() walk as the primary path — smaller .cgp, consistent with the template, and no GMS assumption. At minimum, wrap the client creation in runCatching { … } ?: fallbackToLocationManager().

5. PmtilesHttpServer: partial skip serves wrong bytes under a correct-looking header

templates/region-map/app/src/main/java/PACKAGE_NAME/PmtilesHttpServer.kt.peb:131-137

while (skipped < rangeStart) {
    val sk = fis.skip(rangeStart - skipped)
    if (sk <= 0) break          // <-- then serves from the wrong offset
    skipped += sk
}

InputStream.skip returning 0 does not imply EOF. On a short skip the loop breaks and the body is streamed from wherever the cursor landed, while the already-written Content-Range header promises rangeStart-rangeEnd. MapLibre gets structurally valid but wrong PMTiles bytes — silent tile corruption that is very hard to trace. Fail the request instead (500, or 416) rather than serving mismatched data.

Two smaller items in the same file:

  • No setSoTimeout on the accepted socket, feeding an unbounded newCachedThreadPool. A hung client pins a thread indefinitely. Loopback keeps the risk low, but this is template code students will copy.
  • Suffix ranges are silently ignored: if (dash > 0) (line 95) skips bytes=-500, which then serves the whole file under a 200. MapLibre doesn't emit suffix ranges, so this is latent, not live.

Worth an explicit note in osm-tutorial.html: this server is unauthenticated and reachable by any app on the device. It's harmless here (public map tiles already world-readable inside the APK), but the pattern is teaching material and a student who points rootPrefix at something sensitive has built a local data leak. One sentence in the tutorial and a comment on the class would cover it.

6. The 206 branch lacks the size cap the 200 branch has

RangeFetcher.kt:146-154. The 200 fallback carefully refuses unknown-length or oversized bodies before buffering (lines 162-168) — good. The 206 branch calls body.bytes() unconditionally and only checks the size in a require after the whole body is in memory. A misbehaving or hostile LAN IIAB host answering 206 with a multi-GB body OOMs the process. Check body.contentLength() against length before reading, mirroring the 200 path.

Also line 149: bytes.size == length || bytes.size == (end - offset + 1).toInt()end is defined as offset + length - 1, so the second disjunct is always identical to the first. Dead condition.

7. Rebase before merge

13 commits behind main. Both README.md (the Examples table) and .github/workflows/update-libs.yml (the MAP array) insert into regions that main has since changed, so expect conflicts in exactly those two spots. Trivial to resolve, but better done by the author than at merge time.


Polish

  • Bundled Gradle wrapper. maps/gradlew, maps/gradlew.bat, maps/gradle/wrapper/ duplicate the repo-root wrapper. Repo convention is to use the root one (cd maps && ../gradlew assemblePlugin), as flutter-template does. The ../libs/*.jar references are correct — just the wrapper to delete.
  • .cgp size. ~24 MB of assets before the plugin's own MapLibre .so. Two concrete trims: the vendored repo ships three kotlin-stdlib versions (1.7.10, 1.8.22, 2.2.10) where resolution needs one — ~3 MB; and the template pins abiFilters.add("arm64-v8a") with a clear rationale, but the plugin's own build.gradle.kts doesn't, so the .cgp carries every ABI MapLibre's AAR publishes. Applying the same filter to the plugin is consistent and free.
  • arm64-only generated app. Worth stating in the tutorial and template README — a student who reaches for an x86_64 emulator instead of a device gets a missing-.so failure, and the reason lives only in a Gradle comment.
  • Hardcoded English strings. MapLocationController.kt:126-144setTitle("Location is off for Code on the Go"), "Open Settings", "Not now", and the long message. The sibling maybeRequestLocation() (lines 107-117) correctly uses R.string.maps_location_perm_*. These are user-facing UI, so they belong in strings.xml alongside the rest; it's the only such offender in the plugin.
  • Bbox has no equals/hashCode/toString. It's a plain class (Bbox.kt:17). RegionDownloader.kt:123 logs bbox=$bbox, which prints Bbox@1a2b3c4 — the download log line loses its most useful field. Note the contrast with RegionInfo, where the hand-rolled equals/hashCode for the DoubleArray field is done carefully and correctly. Making Bbox a data class fixes the log and forecloses any future reference-equality surprise.
  • Delete keys off the meta-declared id. RegionManagerFragment.kt:505 calls RegionCache.delete(info.regionId), where regionId comes from meta.optString("regionId", dir.name) (RegionCache.kt:203). /sdcard is world-writable, so a planted meta.json can name a different valid region and delete the wrong directory. Not traversal — the containment check holds — but info.directory is already canonicalized and unambiguous, so passing that instead removes the class of bug.
  • HttpRangeByteCache.get hands back the internal array. RangeFetcher.kt:73 returns the cached ByteArray by reference from a process-global cache, though the KDoc calls them "instant in-memory copies". No current caller mutates it, but a .copyOf() (or a comment) would make that safe by construction rather than by convention. Separately, put's containsKey early-return (line 80) doesn't promote in an access-order map, so re-requesting a cached range doesn't refresh its LRU position.
  • compileSdk/targetSdk = 34. #54 is standardizing the toolchain across plugins — coordinate so this doesn't land needing an immediate follow-up.
  • mkdirs() precedes the containment check. ProjectMapEmitter.kt:90-95 creates the directory then verifies it doesn't escape. MAPS_ASSETS_SUBPATH is a constant so it can't, but ordering the check first makes the guard real rather than decorative.
  • Doc/code mismatch. Bbox.heightKm() (line 35) is documented as "latitude span × 111.32"; the body calls haversineKm.
  • Cleartext to LAN hosts. RegionDownloader.base() defaults to http:// for LAN (correct — IIAB boxes rarely run TLS), but the plugin declares no networkSecurityConfig and rides the host's. Worth confirming CoGo permits cleartext to arbitrary LAN hosts, or LAN downloads will fail on a host that doesn't. The template correctly scopes its own config to 127.0.0.1.

Testing

359 tests passing, 98.8% line / 89.3% branch on non-UI logic. The injectable-seam design (downloadInto's copyBasemap/sliceTiles/nowMillis, ProjectMapEmitter's Android-free purity, RegionCache's *FromRoot overloads) is the right way to get this testable without Robolectric, and it's applied consistently. UI at 0% with device E2E is a reasonable call and honestly labeled.

Gaps worth a follow-up: the PMTiles slicer is 4k lines of binary-format parsing whose failure mode is silently wrong tiles rather than a crash, and the tests exercise it against synthetic archives. A golden-file test — slice a known bbox from a checked-in small PMTiles and assert the output byte-for-byte against a pmtiles CLI extract — would be the cheapest guard against a regression here, and would also de-risk the "rip this out for the CLI later" option the PR description raises. I'd also add a regression test for #5 (a short-skip Range request) since the symptom is corruption rather than an exception.

Verdict

Approve after #1#3. Those are all small, mechanical fixes, and #1/#2 are the class of defect that only device long-press catches — worth doing before this ships. #4#6 are the ones I'd want addressed in this PR rather than deferred; the rest can be follow-ups.

I have not built or device-tested this — those findings come from reading the diff, so #1 in particular is worth a quick long-press check on the Maps tab to confirm.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants