Skip to content

fix(map): show a message instead of crashing when a device has no map engine, and fail CI on the gap - #7005

Merged
jamesarich merged 3 commits into
mainfrom
fix/maplibre-missing-native-guard
Sep 3, 2026
Merged

fix(map): show a message instead of crashing when a device has no map engine, and fail CI on the gap#7005
jamesarich merged 3 commits into
mainfrom
fix/maplibre-missing-native-guard

Conversation

@jamesarich

@jamesarich jamesarich commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Why

MapLibre Compose reaches its renderer through a JNI shim, and an app can be built for an architecture that shim was never published for. maplibre-native-ffi 0.202608.3 publishes arm64-v8a and x86_64 only, so the F-Droid armeabi-v7a split carries no engine — 18.2 MB against the arm64 split's 32.5 MB, with CI green — and the first frame of a map takes the whole app down:

FATAL EXCEPTION: main
java.lang.UnsatisfiedLinkError: dlopen failed: library "libjniMaplibreNativeC.so" not found
  at org.maplibre.compose.mlnffi.EnsureMlnFfiConfigured(EnsureMlnFfiConfigured.android.kt:12)
  at org.meshtastic.feature.map.maplibre.MapLibreMapViewProvider.MapView(MapLibreMapViewProvider.kt:150)

Losing the map on those devices is unavoidable until upstream ships a 32-bit build. Losing every other screen is not, and neither is shipping the gap without knowing. Two commits:

  1. Runtime — ask whether the engine can load before composing a map, and say so plainly when it cannot.
  2. Build time — fail CI when an ABI split ships fewer native libraries than its siblings, with the current gap recorded as a known exception that fails the other way once it closes.

Relates to #7001.

🐛 Bug Fixes

  • isMapLibreRuntimeAvailable() — an expect/actual probe. Android loads the JNI shim inside runCatching; UnsatisfiedLinkError is an Error, so a catch (e: Exception) would have missed it and kept the crash.
  • Guarded at all three places that compose MaplibreMap: MapLibreMapViewProvider (so the toolbar and zoom controls do not float over an empty screen driving a map that is not there), plus MeshMap and SecondaryMapSurface, which reach it directly.
  • MapEngineUnavailable — a deliberately actionless screen. There is nothing a user can do about their phone's architecture, so it states what happened, states the rest of the app is fine, and stops.

🧹 Chores

  • scripts/lib/abi-parity.sh — the classifier. The reference set is the union of native libs across a flavor's split APKs; every split must carry all of it unless a known-gap line says otherwise.
  • scripts/verify-abi-parity.sh — runs in android-check right after the debug assemble, where every PR already builds the splits.
  • scripts/verify-abi-parity-selftest.sh — fixture self-test in lint-check, mirroring verify-rb-selftest.sh.
  • Two strings, sorted with scripts/sort-strings.py; strings-index.txt regenerated.

Notes for review

  • Known gaps are checked both ways. The two recorded lines are the MapLibre gap on fdroid/armeabi-v7a. An entry must be absent from its split, and the check fails once the library turns up, so an entry cannot outlive its reason. Renovate tracks the 8 org.maplibre.compose catalog entries and will bump maplibre-compose when a release with the 32-bit build lands (upstream merged it 2026-08-24: maplibre-native-ffi #658, #659, #660; no release yet). Without this, that bump lands green and nothing marks [Bug]: v2.8.2-closed.1 (29322131) map tab crash the FDroid app #7001 closed. With it, the bump goes red with "the gap has closed, delete those lines" — and deleting them is the proof the v7a APK really has an engine.
  • The probe loads the shim, not the 13 MB engine. libjniMaplibreNativeC.so's DT_NEEDED lists libmaplibre-native-c.so, so one load answers for both. System.loadLibrary is process-wide and idempotent — MapLibre's own load later finds it already done.
  • The shim name is hardcoded in two places (the probe and the known-gap list), and that is the maintenance edge. An upstream rename fails the probe closed — fallback screen on every device — with no JVM-side test to catch it. The parity check's stale direction is the backstop: a renamed shim shows up as a new lib in the arm64 split and a stale entry for the old name.
  • Off Android nothing changes — every published desktop and iOS artifact carries its own engine, so those actuals return true unconditionally.
  • Why not pin the upstream snapshot (fix(map): pin the maplibre native ffi snapshot so 32-bit F-Droid gets a map engine #7003, closed): it was re-published overnight with different bytes under the same version, timestamps are per artifact so a fixed pin cannot address the group, and Central prunes snapshots after 90 days. rb-check assembles once and would never have noticed.

Screenshots

Testing Performed

Runtime guard

  • MapLibreRuntimeTestprobeNativeRuntime returns false for a thrown UnsatisfiedLinkError (an Error, which is the whole point) and true for a load that returns; SecondaryMapSurface under a LocalMapLibreRuntimeProbe that says no composes the fallback and its content zero times. Mutation-checked: deleting the guard fails the test with No ComposeMapHost is installed — MapLibre being reached. MeshMap and the provider carry the identical one-line guard but need a seven-repository view model and Koin to compose, so they are not harnessed.
  • MapEngineUnavailableTest — both strings displayed, and onAllNodes(hasClickAction()) is empty.
  • ScreenshotMapEngineUnavailable added to MapScreenshotTests, light and dark references committed; :screenshot-tests:validateDebugScreenshotTest green.
  • Probe passes (engine present): the arm64 build installs on a Pixel 6a and renders as before — maplibre-compose: Rendered the first map frame with OPENGL, no fallback in the view tree.
  • Probe fails (engine absent): stripping the two MapLibre libs from that same APK — exactly the lib set the v7a split ships — re-signing and installing shows "Map unavailable on this device" instead of the FATAL, with Nodes, Messages and Settings still working.
  • Baseline green: spotlessApply spotlessCheck detekt assembleDebug test allTests kmpSmokeCompile. detekt's LongMethod caught the first draft of the guard at 63 lines against a 60 limit, hence the single-line early return.

Parity check, run against three real APK sets:

input result
shipped v2.8.2-closed.1 fdroid splits + google splits ✅ google clean; ✅ fdroid "splits match apart from 2 recorded known gap(s)"
the #7003 snapshot build, where v7a has the engine ❌ "known-gap entries whose library is now present" — the self-healing signal
shipped splits with the two lines deleted ❌ "native libraries missing from a split: armeabi-v7a/libjniMaplibreNativeC.so armeabi-v7a/libmaplibre-native-c.so"
  • Self-test: 22 checks green — 15 on the classifier, including wrong-ABI and wrong-flavor entries that must not excuse a gap, and 7 that run verify-abi-parity.sh itself against fixture output trees (universal APK ignored, unrecorded gap, the checked-in gap held and then closed, a lone split, an empty directory, two failing flavors summing in the exit status).
  • shellcheck -x over scripts/ (the lint-check invocation) and actionlint on both workflows: clean.

Not verified: execution on real 32-bit ARM hardware. armeabi-v7a emulator images stop at API 25 and minSdk is 26, and the only device here is arm64-only — the probe-fail path was exercised by removing the libraries rather than by running on a v7a phone.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a clear fallback screen when maps are unavailable on a device, with an explanation that other app features remain usable.
    • Added localized messaging and visual coverage for the unavailable-map experience.
  • Bug Fixes

    • Prevented map screens from crashing when the required native map renderer is unavailable.
    • Added validation to detect missing native libraries across Android ABI splits during builds and pull-request checks.

… engine

MapLibre Compose reaches its renderer through a JNI shim, and an app can be
built for an architecture that shim was never published for. maplibre-native-ffi
0.202608.3 ships arm64-v8a and x86_64 only, so the armeabi-v7a split carries no
engine and the first frame of a map takes the whole app down with
UnsatisfiedLinkError (#7001).

Ask whether the engine can load before composing a map, and render a short
explanation when it cannot. The probe loads the JNI shim, whose DT_NEEDED pulls
the 13 MB engine in behind it, so one load answers for both; System.loadLibrary
is process-wide and idempotent, so MapLibre's own load later finds it done.
Guarded at all three points that compose MaplibreMap: the provider, so the
toolbar and zoom controls do not float over an empty screen, and MeshMap and
SecondaryMapSurface, which reach it directly.

Off Android every published artifact carries its own engine, so those actuals
answer true and nothing changes.

This does not restore the map on 32-bit devices — only an upstream release with
armeabi-v7a can (see #7003). It stops a missing renderer from costing the user
every other screen in the app.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: dc727787-2339-4da7-973b-e1430c4bfbd4

📥 Commits

Reviewing files that changed from the base of the PR and between 86ca854 and 06d546b.

📒 Files selected for processing (8)
  • feature/map-maplibre/src/androidMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreRuntime.android.kt
  • feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt
  • feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreRuntime.kt
  • feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt
  • feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt
  • feature/map-maplibre/src/jvmTest/kotlin/org/meshtastic/feature/map/maplibre/MapLibreRuntimeTest.kt
  • feature/map/src/jvmTest/kotlin/org/meshtastic/feature/map/component/MapEngineUnavailableTest.kt
  • scripts/verify-abi-parity-selftest.sh

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The change adds MapLibre runtime detection with a localized fallback UI and adds Android ABI parity checks. Build workflows run the parity verifier and self-test. Tests cover runtime probing, fallback rendering, UI behavior, and screenshot previews.

Changes

MapLibre runtime fallback

Layer / File(s) Summary
Runtime detection and map guards
feature/map-maplibre/src/.../MapLibreRuntime.kt, feature/map-maplibre/src/.../MapLibreRuntime.android.kt, feature/map-maplibre/src/.../MapLibreRuntime.ios.kt, feature/map-maplibre/src/.../MapLibreRuntime.jvm.kt, feature/map-maplibre/src/.../MeshMap.kt, feature/map-maplibre/src/.../SecondaryMapScaffold.kt, feature/map-maplibre/src/.../MapLibreMapViewProvider.kt
The MapLibre runtime contract has platform implementations. Map entry points use the composition-local probe and render MapEngineUnavailable when the runtime is unavailable.
Unavailable map UI and localization
feature/map/src/.../MapEngineUnavailable.kt, core/resources/src/.../strings.xml, .skills/compose-ui/strings-index.txt, screenshot-tests/src/.../MapScreenshotTests.kt, feature/map/src/.../MapEngineUnavailableTest.kt, feature/map-maplibre/src/.../MapLibreRuntimeTest.kt
The fallback screen displays localized status text and an error icon. Tests cover the unavailable state, interaction behavior, runtime probing, and light/dark previews.

ABI parity validation

Layer / File(s) Summary
ABI parity classifier
scripts/lib/abi-parity.sh
The shared library compares native libraries across ABI APK splits, applies known gaps, and reports missing or stale entries.
ABI verification and self-tests
scripts/verify-abi-parity.sh, scripts/verify-abi-parity-selftest.sh
The verifier scans built APK outputs and reports parity failures. The self-test covers fixture scenarios, known gaps, wrapper scanning, and exit aggregation.
ABI verification workflow wiring
.github/workflows/reusable-check.yml, .github/workflows/pull-request.yml
The Android build runs ABI verification after APK assembly. The pull-request workflow runs the parity self-test.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 06d54

The PR replaces a map-engine crash with a map-only fallback and adds CI checks for missing native libraries; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant MapScreen
  participant LocalMapLibreRuntimeProbe
  participant MapEngineUnavailable
  MapScreen->>LocalMapLibreRuntimeProbe: Check runtime availability
  LocalMapLibreRuntimeProbe-->>MapScreen: Return availability
  MapScreen->>MapEngineUnavailable: Render fallback when unavailable
Loading
sequenceDiagram
  participant AndroidBuild
  participant verify_abi_parity
  participant check_abi_parity
  participant ABISplitAPKs
  AndroidBuild->>verify_abi_parity: Run after APK assembly
  verify_abi_parity->>ABISplitAPKs: Collect per-ABI APKs
  verify_abi_parity->>check_abi_parity: Compare native libraries
  check_abi_parity-->>verify_abi_parity: Return parity results
  verify_abi_parity-->>AndroidBuild: Report success or failure
Loading
🚥 Pre-merge checks | ✅ 6 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Tests Prove The Path, Not The End State ⚠️ Warning The ABI wrapper self-test has one non-diagnostic case. tree even creates the universal APK with exactly the same liba.so and libb.so set as both ABI splits, then expects success. If the producti… Make the universal fixture contain a library that is absent from both ABI splits, such as universal=liba.so,libb.so,libuniversal-only.so. Keep the expected result as success when universal APKs are skipped. Then including universal APKs w…
Regression Coverage For Changed Behavior ⚠️ Warning Coverage gaps remain in the new MapLibre fallback paths. MapLibreRuntimeTest covers only SecondaryMapSurface and probeNativeRuntime; no test covers the other changed entry points or the Android … Add these regression tests: - Compose MapLibreMapViewProvider.MapView with LocalMapLibreRuntimeProbe returning false. Assert MapEngineUnavailable is shown and Koin/map content is not reached. This must fail if the provider guard is …
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both main changes: showing a fallback message when the map engine is unavailable and failing CI for ABI gaps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sibling Call Sites And Presence Semantics ✅ Passed PASS. The PR does not change any metric field representation, nullable declaration, or zero-guard. The PR diff contains no changes to NodeItem.kt or NodeItemCompact.kt. Both files already use nullable…
Moved Code Diffed Against Its Original ✅ Passed PASS — The PR range adds new files and guards. It does not delete or relocate an existing type or function. The only extraction-like change replaces runCatching { System.loadLibrary(...) }.isSuccess
Full details: Sibling Call Sites And Presence Semantics

Explanation

PASS. The PR does not change any metric field representation, nullable declaration, or zero-guard. The PR diff contains no changes to NodeItem.kt or NodeItemCompact.kt. Both files already use nullable presence checks for ambient temperature; NodeItemCompact.kt does not retain an ambient-temperature zero-guard. The only new presence-style behavior is the MapLibre runtime probe. All three probe call sites are updated, and the only two direct MaplibreMap invocations are behind those guards. No new RSSI, temperature, current, voltage, or particulate field defaults to 0.

Full details: Tests Prove The Path, Not The End State

Explanation

The ABI wrapper self-test has one non-diagnostic case. tree even creates the universal APK with exactly the same liba.so and libb.so set as both ABI splits, then expects success. If the production line that skips universal APKs is reverted, the union and parity result remain unchanged, so this test still passes. The other added tests use meaningful probes, fallback composition counters, UI assertions, and ABI classifications; no fake backing-store or Dispatchers.Unconfined order pattern is present.

Resolution

Make the universal fixture contain a library that is absent from both ABI splits, such as universal=liba.so,libb.so,libuniversal-only.so. Keep the expected result as success when universal APKs are skipped. Then including universal APKs would add a missing library to the parity union and make the test fail, proving that the skip path is active.

Full details: Regression Coverage For Changed Behavior

Explanation

Coverage gaps remain in the new MapLibre fallback paths. MapLibreRuntimeTest covers only SecondaryMapSurface and probeNativeRuntime; no test covers the other changed entry points or the Android actual.

Resolution

Add these regression tests: - Compose MapLibreMapViewProvider.MapView with LocalMapLibreRuntimeProbe returning false. Assert MapEngineUnavailable is shown and Koin/map content is not reached. This must fail if the provider guard is removed. - Compose MeshMap with a minimal fake BaseMapViewModel and the probe returning false. Assert the fallback is shown and no map content or state collection is reached. This must fail if the MeshMap guard is removed. - Compose complete secondary consumers (MapLibreInlineMap, traceroute, discovery, and node-track maps) with the probe returning false. Assert their map layers and MapZoom/SecondaryMapControls are absent. The current test exercises SecondaryMapSurface in isolation, while these consumers compose controls outside that guard. - Add platform coverage for MapLibreRuntime.android.kt that exercises the actual JNI-library probe on an APK with and without jniMaplibreNativeC, and assert the JVM/iOS actuals return true on their targets. The current tests call only the generic lambda helper, so a wrong library name or an incorrect platform actual can pass.

Full details: Moved Code Diffed Against Its Original

Explanation

PASS — The PR range adds new files and guards. It does not delete or relocate an existing type or function. The only extraction-like change replaces runCatching { System.loadLibrary(...) }.isSuccess with probeNativeRuntime { System.loadLibrary(...) }; the new helper still executes runCatching(load).isSuccess, so the exception contract is unchanged. MapLibreMapViewProvider still implements MapViewProvider and retains override fun MapView; existing default parameters are unchanged. All three map callers now use the composition-local probe, whose default is the original isMapLibreRuntimeAvailable function, so no caller retains an old assumption.

  • Fix all pre-merge checks with AI

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bugfix PR tag label Sep 2, 2026
…ings

A dependency published for only some of our ABIs builds and installs cleanly
on the rest and dies with UnsatisfiedLinkError the first time it is touched.
Nothing at build time says a word. That is #7001: maplibre-native-ffi
0.202608.3 ships arm64-v8a and x86_64 only, so the F-Droid armeabi-v7a split
had no map engine and opening the map took the app down.

Compare the split APKs in android-check, where every PR already builds them.
The reference set is the union across splits — a library any ABI ships is
one the app expects to load — and each split must carry all of it unless a
known-gap line in scripts/lib/abi-parity.sh says otherwise.

Known gaps are checked both ways. The library must be absent, and the check
fails once it turns up, so an entry cannot outlive its reason: the
maplibre-compose bump that closes this one goes red until its two lines are
deleted with it. Without that, Renovate would land the bump and nothing
would mark #7001 closed.

The two lines recorded now are the MapLibre gap on fdroid/armeabi-v7a. The
32-bit build upstream merged on 2026-08-24 (maplibre-native-ffi #658, #659,
#660) has no release yet; on those devices the app shows a message instead
of a map meanwhile.

A fixture self-test runs in lint-check, mirroring verify-rb-selftest.sh: the
real check only ever sees what the current dependencies happen to ship, so
both failure modes would otherwise go unexercised until they bit.
@github-actions github-actions Bot added the repo Repository maintenance label Sep 2, 2026
@jamesarich jamesarich changed the title fix(map): show a message instead of crashing when a device has no map engine fix(map): show a message instead of crashing when a device has no map engine, and fail CI on the gap Sep 2, 2026
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

@jamesarich
jamesarich marked this pull request as ready for review September 2, 2026 21:37
…apper

Review pointed out that the screenshot test renders the fallback directly, so
it would still pass with all three runtime guards removed, and that the
parity self-test never invokes the wrapper whose exit status CI acts on.

Give the probe a composition-local seam so a test can declare the engine
missing without a device that lacks it, and factor the load into
probeNativeRuntime so the one property that matters — an UnsatisfiedLinkError
is an Error, not an Exception, and must come back as false rather than
propagate — is asserted directly. The SecondaryMapSurface test counts
compositions of its content: zero when the engine is missing, and removing
the guard fails it with "No ComposeMapHost is installed", which is MapLibre
being reached. MeshMap and the provider carry the identical one-line guard
but need a seven-repository view model and Koin to compose, so they are not
harnessed here.

The fallback screen gets a component test for both strings and for having
nothing to tap. The self-test grows seven cases that run verify-abi-parity.sh
against fixture output trees: matching splits with a universal APK, an
unrecorded gap, the checked-in fdroid gap held and then closed, a lone split,
an empty directory, and failures in two flavors summing in the exit status.
@jamesarich

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jamesarich
jamesarich added this pull request to the merge queue Sep 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 3, 2026
@jamesarich
jamesarich added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 23e5e2a Sep 3, 2026
17 checks passed
@jamesarich
jamesarich deleted the fix/maplibre-missing-native-guard branch September 3, 2026 14:56
jamesarich added a commit that referenced this pull request Sep 3, 2026
Only visible in the merge group: #7005's engine-probe guard on main plus
the offline indicator here put MapView at 61 lines against a limit of 60,
so the queue run failed detekt while both sides passed alone. Inline the
layer-opacity collect into the MeshMap argument to get the line back.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag repo Repository maintenance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant