feat(coverage): compute Site Planner coverage on device with kp1812 - #7264
Conversation
|
Baseline on this head ( Spotless made no changes. CI on this PR is expected to fail at dependency resolution until kp1812's first release is on Maven Central; that is the one thing standing between draft and ready. |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 🧰 Additional context used📚 Code guidelines (5)No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (1)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review. 📝 WalkthroughWalkthroughThe change adds a multiplatform feature for local RF coverage estimation using terrain data. It replaces the Site Planner browser handoff with an in-app estimate flow that imports generated GeoJSON into map layers. ChangesLocal RF coverage and Site Planner integration
Priority: ⬇️ Low Merge Risk: 🟡 Moderate · up to Coverage output can still be inaccurate for a corrupt terrain cache, while the demo exposes locations and invalid band settings can silently discard coverage. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Local computation removes the hosted planner handoff, and the inspected cancellation and failure paths contain unsuccessful estimates. The main privacy concern is limited to the explicitly invoked developer demo, which prints precise site coordinates. Terrain-cache recovery and downstream rendering remain incompletely established. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 5 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (5 passed)
Full details: Tests Prove The Path, Not The End StateExplanation Three added tests do not verify the production path they claim to cover. Resolution Make Full details: Regression Coverage For Changed BehaviorExplanation The PR adds useful tests for the propagation model, polar resampling, palette helpers, cache cancellation, and zoom fitting. Several new user-visible paths remain untested: 1. Resolution Add regression tests for the planner form boundaries and submit gating; planner-to-model and display-style mapping; Full details: Moved Code Diffed Against Its OriginalExplanation The change widens
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
17f4882 to
c1b7e26
Compare
c1b7e26 to
f1d41e0
Compare
|
There was a problem hiding this comment.
Actionable comments posted: 7
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository: meshtastic/Meshtastic-Android/.coderabbit.yaml
- Review profile: CHILL
- Plan: Advanced
- Run ID:
42f1100b-ae0e-467b-b366-e2006696fbdc
📒 Files selected for processing (40)
.github/workflows/reusable-check.yml.skills/compose-ui/strings-index.txtandroidApp/build.gradle.ktsandroidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.ktandroidApp/src/google/kotlin/org/meshtastic/app/map/MapView.ktandroidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.ktandroidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.ktbuild-logic/convention/src/main/kotlin/RootConventionPlugin.ktcore/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.ktcore/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.ktcore/resources/src/commonMain/composeResources/values/strings.xmldesktopApp/build.gradle.ktsdesktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.ktfeature/coverage/README.mdfeature/coverage/build.gradle.ktsfeature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.ktfeature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.ktfeature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.ktfeature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.ktfeature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.ktfeature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.ktgradle/libs.versions.tomlsettings.gradle.kts
💤 Files with no reviewable changes (4)
- feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
- androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt
- feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt
- androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
…View Adds feature:coverage, which sweeps radials from a site, builds a terrain profile per radial and runs org.meshtastic:kp1812 (ITU-R P.1812) per sample to predict received signal strength in-process. This is the replacement for SitePlannerRunner's hidden WebView: 319 lines that load site.meshtastic.org, wait up to 45 s for a JavaScript bridge, and need the network. Desktop cannot run that at all, so today it opens a browser and asks the user to export a .geojson and re-import it by hand. The seam is a one-method ElevationSource, backed by feature/map-terrain's Mapterhorn tiles in the app and by a lambda in tests - so the suite proves the behaviour with no network, no WebView and no terrain download: signal decays with distance, a 400 m ridge shadows what is behind it, more power reaches further, and reachability agrees with receiver sensitivity. Scoped to jvm() on purpose. kp1812 publishes no androidTarget - Android is meant to take its jvm artifact, as with kzstd - and that resolution question is separate from whether the model works. P.1812 is also a different model from the planner's ITM, so predictions will not match it pixel for pixel.
A module table is invalid in [bundles], so the catalog failed to parse - which Gradle surfaced two layers up as "Error resolving plugin [id: 'meshtastic.develocity']". The real cause was only visible in the stack trace, at TomlCatalogFileParser.throwVersionCatalogProblemException.
…mmonMain feature:coverage was jvm-only because I put the map-terrain dependency in jvmMain, not because anything required it. TerrainTileFetcher and decodeTerrariumTile are already expect/actual with android and jvm actuals, and TerrainTileMath and MapterhornEndpoints were always common - so MapterhornElevation moves across with one real change: Dispatchers.IO is JVM/Android-only, so it becomes Dispatchers.Default. Coverage.toGeoJson() moves out of the desktop demo into commonMain, where it belongs: it is pure string building and it is exactly what Android needs to feed the existing import path. Only CoverageDemo stays in jvmMain, since it renders with java.awt and javax.imageio. Adding the android target also forced the deferred question - the convention plugin gives every KMP module iOS targets, so the build immediately demanded kp1812-iossimulatorarm64. Publishing all thirteen kp1812 targets locally resolved it, which incidentally proves the iOS klibs are consumable. Kotlin/Native then rejected String.format the moment the export became common. Replaced with a toFixed1() helper. Its first test asserted -76.15 rounds to -76.2; roundToLong breaks ties toward positive infinity and -76.15 * 10 is -761.4999999999999 anyway, so the real answer is -76.1. The assertion was wrong, not the code, and ties are now deliberately unasserted - pinning them would test floating-point representation rather than behaviour. Note allTests reports green while iosSimulatorArm64Test is SKIPPED: this repo compile-checks Apple targets via kmpSmokeCompile rather than executing them. Eight tests execute on JVM; iOS is compile-verified only.
… terrain Two fixes from seeing it run in the app. The result now persists via MapLayersManager.addGeoJsonLayer - the same path the F-Droid flavour already uses for the WebView's GeoJSON - so it lands on the map, appears in the layers list as LayerType.COVERAGE and survives the dialog closing. That seam was already there and already injected into DesktopMapViewProvider; the previous commit built a dead-end dialog instead of looking for it. Terrain profile resolution is now decoupled from receiver spacing. One array served both, so a 30 km radius meant a 900 m profile step while P.1812 integrates diffraction across the whole profile. It now samples every 100 m, close to Mapterhorn z11's ~75 m/px, and strides receivers along it. Cost is unchanged because the expense is the number of predict() calls, not profile length. Measured on the Seattle demo, this moved the reachable fraction 91% -> 88% - so the earlier claim that aliasing was depressing the reachable count was wrong. What it did change is dynamic range, 84 dB -> 105 dB, consistent with a profile that now resolves peaks instead of averaging them away. sweepCoversEveryBearing asserted an exact 32 points and got 56: striding receivers along a dense profile yields a few more per radial when the stride rounds. It now asserts every bearing contributes equally, which is the property that actually matters.
Points were the wrong geometry. Exported as GeoJSON Point features they get clustered by the map and drawn as default node markers - marker-color is ignored - so the layer rendered as a swarm of identical blue dots with the sweep's radial structure showing through as rings and spokes. The hosted planner runs marching squares over a dBm grid and exports Polygon iso-bands rendered as fill + line layers. I had read that in its ARCHITECTURE.md earlier and then wrote points anyway, and described them as matching the planner's export, which they did not. So: sweepGrid() computes a regular lat/lon grid, each cell running its own terrain profile at 100 m, and CoverageContours merges in-band cells into MultiPolygon features with simplestyle fill and fill-opacity. Six bands from receiver sensitivity upward, weaker bands larger and fainter underneath. Band merging is deliberately a union of cell rectangles rather than a smoothed isoline: at ~100 m cells it reads as solid coverage and it avoids the ambiguous-saddle handling a real isoline tracer needs. Seattle demo: 4,898 cells in 7.0 s, 82% reachable, -148.1 to -52.6 dBm, six bands with 269 rings at the weakest threshold down to 5 at the strongest.
GeoJSON MultiPolygon is coordinates[polygon][ring][position]. The export emitted coordinates[ring][position], which is a Polygon-with-holes wearing a MultiPolygon label. MapLibre drops that without complaint, so the layer was created and persisted and nothing drew - while the result dialog looked fine, because it renders the grid directly rather than the GeoJSON. The earlier 'validation' counted rings per feature and reported six bands nesting nicely, which the malformed structure satisfies just as well. It now asserts the actual shape: every position a two-element numeric pair, every ring closed and at least four positions long.
The grid sweep walked an independent terrain profile per cell, so resolution was bounded by the prediction budget and every cell re-read the same terrain. Coverage is now computed on a polar lattice - one profile per bearing, every ring on it a prefix of that walk - and resampled onto the grid, which costs no predictions at all. Defaults go from an 80x80 grid to 256x256 over a 720x128 lattice, matched so radials and rings are each about one grid cell apart. Measuring where the time went showed it was not the model: dropping the prediction count 9x left the total unchanged. The archive is read over HTTP range requests through a single seekable channel, so on-demand lookups queued one behind another at ~300ms each. Terrain is now prefetched for the whole disc through a pool of readers - opened concurrently, because built serially the pool's own setup grew linearly with its size and ate the speed-up - and the cache holds the in-flight fetch rather than the result, so concurrent radials share one download. Sampling reads a published snapshot without the mutex. Seattle, 25km radius, 10 cores: was 4,898 cells in ~7s at z11 terrain. Now 51,198 cells in 4.3s of which 0.2s is the sweep, at z12 - the global archive's own maximum, four times the detail for the same tile count. A second sweep with terrain already decoded takes 190ms, so the desktop planner holds its elevation source across estimates instead of rebuilding it per run. Also drops Coverage.toGeoJson: CoverageGrid.toGeoJson replaced it, and the point export is what the map clustered into marker bubbles.
… needed Driving the desktop planner showed a second estimate still cost 8.8s against a 0.2s sweep. Two causes, both measured rather than guessed. The tile cache was per-instance, and the planner's composable is disposed when its sheet closes - so every estimate re-downloaded terrain it had decoded seconds earlier. A decoded tile is immutable data keyed by archive, zoom and position, so it now lives in a process-wide cache instead. Bounded at 128 tiles (~32MB) with insertion-order eviction: a sweep's working set is one contiguous disc, so the oldest entries are from a disc the user has moved away from, and tracking access order would cost a write on the hot read path for no better answer. That left 719ms per estimate opening a pmtiles reader - the archive header is read over the network in the constructor - for an estimate whose terrain was already resident and needed no reader at all. It is opened lazily now, and close() never forces it just to close it. Measured end to end on the Seattle demo, second sweep through a brand new elevation source, which is the app's real shape: 110ms. In the app itself the first estimate is ~13s and repeats were 4.5s before the lazy open.
…ncelled fetch The planner slot's remembered elevation source described a mechanism that was measured as broken - the slot is disposed with its sheet, so nothing survived in the instance. Terrain lives in the shared cache now and a source that never fetches costs nothing to open or close, so the estimate just builds one per run. TerrainCache kept whatever Deferred it was first given, including a cancelled one. The scope producing it belongs to a single MapterhornElevation, so dismissing the sheet mid-run left a dead Deferred that would fail every later sweep touching that tile. Reachable only through an on-demand miss, which a prefetched disc makes rare - which is exactly why it would have been hard to find later.
Chasing the dead regional archives turned up the real answer. Mapterhorn's own
migration guide documents a plain XYZ endpoint, tiles.mapterhorn.com/{z}/{x}/{y}
.webp. Probed at Seattle: z0-16 all 200 (~56-95KB, ~130ms each, Cloudflare-cached
for a week, CORS open), z17+ 404. So it already serves the regional detail the
per-z6-tile archives were meant to carry - and serves it as independent
cacheable requests instead of range reads into one seekable channel, which is
the thing that made bulk sampling serial in the first place.
Coverage now reads that endpoint over ktor, which suspends rather than blocking,
so a sweep's fetches never occupy the compute dispatcher - the previous pooled
prefetch did blocking HTTP on Dispatchers.Default and would have starved a
four-core phone. Okio's file access is blocking, so that goes to ioDispatcher.
Tiles are also written through to a TerrainTileStore, the same Okio-backed store
the map's offline region download already uses, in its own directory so coverage
downloads do not inflate the size a downloaded region reports. Terrain now
survives a restart.
Confirmed the endpoint serves the same data the archive did: at z12 the Seattle
demo reproduces the pmtiles run's rx range exactly, -147.8..-41.3 dBm.
z12 stays the default. Measured against z13: identical reachable fraction and max
range, rx range -147.8..-41.3 against -148.2..-41.3, for 72 tiles rather than
256. The extra detail lands below the 50m step the model samples the profile at.
z13-16 remain available.
Seattle, 25km radius:
terrain, cold 7148ms -> 1183ms
terrain, second launch 7148ms -> 242ms (disk cache)
sweep 310ms
repeat estimate 125ms
The HTTP client is shared process-wide too; building one per estimate cost
seconds in engine setup for something stateless once connected.
download() turned every non-success into null, and null is cached as flat ground for the rest of the process - so one throttled request (24 in flight against Cloudflare makes 429 plausible), one timeout or one cancellation would quietly turn a mountain into sea level and the prediction would look fine. Only 404 is real absence now; anything else throws, after one retry for a transient blip, and the caller surfaces a failed estimate. Also rewrites the regionalUrlFor KDoc a scripted edit had garbled, and drops MapterhornElevation.isPersistent, which nothing read.
…de the cache Two fixes replayed from the radius-scaling work that was rolled back. Both are reachable at the 30 km default and neither was caused by the wider one. max_range is a free-text field, so it could ask the hosted planner for more than its SPLAT!-in-wasm accepts - 150 km, or 70 in high-resolution mode. The URL is clamped now; the local engine keeps its own behaviour, which has no ceiling. The terrain zoom was fixed at z12 whatever area was being sampled, but tiles scale with the square of the radius and with 1/cos(latitude): a 30 km disc is ~120 tiles at mid latitudes and ~360 above 65N, and 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful - eviction is insertion order, so the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured once at ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds. zoomFitting drops a zoom until the box fits, which quarters the count each step and so converges immediately; a 30 km disc at mid latitudes still samples at z12, unchanged.
…overage looks The sheet has offered a palette picker, a min/max dBm range and an overlay transparency all along, and the local engine ignored every one of them: toSite() carried only the RF settings, and toGeoJson hardcoded a three-colour red-amber-green ramp, derived its own range from rx sensitivity to the strongest cell, and fixed opacity at 0.15..0.60. Whatever you picked, you got the same picture. All three are wired through now. The six palettes are the matplotlib colormaps the hosted planner renders with, stored as evenly spaced anchors and interpolated between - close to the hosted output rather than identical, which is the trade for not embedding six 256-entry tables. min/max dBm fix the ends of the ramp rather than letting the strongest cell define them, so two sites are comparable and the picker means what it says. Bands had to stop nesting for any of this to be visible. They were cumulative - "everything at or above this" - which survived a three-colour ramp at low opacity, but six nested fills paint over each other and the chosen colours never appear. A single overlay transparency is only meaningful when each pixel is painted once, too. Bands are disjoint now, [lo, hi), with the strongest running to infinity so a cell above the picker's ceiling does not punch a hole through the middle of the plot. The FeatureCollection records palette, min_dbm and max_dbm, so a saved layer says how it was rendered.
Colouring each band by where its floor sits means the strongest band samples at (n-1)/n, so the top of the palette is never drawn - plasma stopped at orange and never reached its yellow. Bands are spread across the band index instead, so the first and last bands are the ends of the chosen ramp. At the default six bands this now emits plasma and viridis exactly on their matplotlib anchors rather than interpolated: plasma #0d0887 #6a00a8 #b12a90 #e16462 #fca636 #f0f921 viridis #440154 #414487 #2a788e #22a884 #7ad151 #fde725
detekt's MagicNumber flagged the two ARGB literals in the coverage preview. They are the plot's dark background and the shade of a cell the receiver cannot hear; both now sit beside the file's other constants.
…ed run as failed The desktop slot caught the LaunchedEffect's own cancellation through runCatching, so closing the dialog mid-estimate set a failure. It now rethrows cancellation and takes its dispatcher from Koin.
…form Android joins desktop on the local ITU-R P.1812 engine, so the hidden WebView that drove the hosted planner is gone. All three map hosts share one SitePlannerHost in feature:map: the form, an estimating dialog that cancels back to the form, and a failure note on the form instead of a toast. The form validates frequency against P.1812's 30 MHz to 6 GHz and range against 1 to 150 km, and drops the hosted planner's high-resolution switch, browser hand-off and URL builder.
- Escape the site name in the exported GeoJSON. - A tile that fails to decode is downloaded once more, then fails the estimate, instead of reading as sea level. - A caller still running retries a shared tile fetch another estimate canceled. - Reject non-positive transmit power before the dBm conversion. - Keep the planner form from being reseeded by node updates mid-edit.
f1d41e0 to
2e84b57
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository: meshtastic/Meshtastic-Android/.coderabbit.yaml
- Review profile: CHILL
- Plan: Advanced
- Run ID:
f4137236-2b7d-4256-be4e-4a034d5b2eaf
📒 Files selected for processing (9)
feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.ktfeature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.ktfeature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.ktfeature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.
Without bounds the zoom guard never ran, so a wide range decoded more tiles than the cache holds.
The Site Planner computes coverage on the device instead of driving the hosted planner. On Android a hidden
WebViewloaded site.meshtastic.org, waited up to 45 s for a JavaScript bridge, and needed the network. Desktop couldn't run it at all, so it opened a browser and asked the user to export a.geojsonand import it by hand. All three map hosts now runorg.meshtastic:kp1812, a Kotlin Multiplatform implementation of ITU-R P.1812, over Mapterhorn terrain. The result lands on the map as a coverage layer, offline once the terrain is cached.P.1812 is a different model from SPLAT! ITM, so predictions don't match the hosted planner pixel for pixel.
🌟 New Features
feature:coveragecomputes received signal on a polar lattice (one terrain profile per bearing), resamples it onto a grid, and exports disjoint dBm bands as GeoJSONMultiPolygonfeatures with simplestyle fills. The form's palette, dBm range, and transparency drive the render.SitePlannerHostinfeature:mapserves the Google and F-Droid flavors and desktop: the form, an estimating dialog whose Cancel returns to the form, and a failure note on the form instead of a toast.🛠️ Refactoring & Architecture
rememberCoverageEstimate()binds the estimate to the app's compute dispatcher and aterrain/coveragedisk cache kept apart from downloaded map regions.ElevationSourceseam, and the GeoJSON export arecommonMain. Android takes kp1812'sjvmartifact. iOS is compile-checked throughkmpSmokeCompile, not run.core:konsistallowscoverageto importfeature:map(for the form's params) andfeature:map-terrain, and allowlists the scopeMapterhornElevationowns and cancels onclose().🧹 Chores
SitePlannerRunner(the WebView),SitePlannerBrowserSheet, the planner URL builder and its tests, the WebView recovery test, the high-resolution switch, and the three strings only they used.kp18120.1.0 added to the version catalog.Testing Performed
feature:coveragetests check behavior, since kp1812 checks itself against the ITU reference: decay with distance, a 400 m ridge shadowing what's behind it, transmit power, receiver sensitivity, equal bearings, geodesy, GeoJSON structure, palettes, and the zoom guard.spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests kmpSmokeCompile,-PuseMavenLocal), then the androidApp unit tests after removing the WebView test.Summary by CodeRabbit