Skip to content

Handle ML Kit face detector native library load failures gracefully - #196

Merged
stozo04 merged 1 commit into
mainfrom
claude/festive-franklin-4pixad
Sep 17, 2026
Merged

stozo04 merged 1 commit into
mainfrom
claude/festive-franklin-4pixad

Conversation

@stozo04

@stozo04 stozo04 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Description

Fixes a fatal crash when ML Kit's bundled face detection native library (libface_detector_v2_jni.so) fails to load on certain devices.

Why a try around getClient() would not have fixed it. ML Kit loads that library from the static initializer of its own ThickFaceDetectorCreator, and it runs the model load off the caller's thread — on its own worker, inside a GMS Task. Task funnels only Exception into addOnFailureListener, so the UnsatisfiedLinkError escapes the worker's Runnable and kills the process. That the crash was reported as fatal rather than as a logged task failure is the evidence for this reading.

The fix. Load the library ourselves first, on our own thread, where the failure is catchable:

  • load succeeds → ML Kit's own System.loadLibrary is a no-op, nothing else changes;
  • load throws LinkageError → ML Kit would have crashed, so the detector is never built. detector is null, analyze() closes the proxy and publishes nothing, close() is a no-op, and ReverseCrashlytics.reportFaceTrackerUnavailable records the population as a non-fatal.

Face lenses go inert; capture, trim, save and gallery are untouched. That is the same trade CameraManager.bindWithLensEffect already makes when the analysis use case cannot bind ("a black viewfinder is not an acceptable trade for a novelty feature"), and the one HandTracker makes for MediaPipe (Lesson 040).

The proxy is still closed on every frame in that state — under KEEP_ONLY_LATEST an unclosed proxy stalls the stream the hand tracker shares, so "lenses are inert" would otherwise become "the analyzer is dead".

Loading by name couples one string to the dependency's jni/ folder, so the version-catalog pin and Lesson 040 now both say to re-check it on an ML Kit bump — a rename would read as "absent" and take face lenses off every device.

Changes Made

File Change
camera/lens/FaceTracker.kt detector is nullable, built by a LinkageError-guarded createDetector() that loads the native library first; analyze() bails but still closes the proxy; close() is null-safe
diagnostics/ReverseCrashlytics.kt reportFaceTrackerUnavailable() — sibling of the existing hand-tracker non-fatal
docs/lessons_learned/040-… New bullet: check the failure is reachable from a try at all; where it is not, load the library yourself
gradle/libs.versions.toml Bump note at the mlkit-face-detection pin
cspell.json, .idea/dictionaries/project.xml libface (dictionaries regenerated in sync)

Not done, deliberately

  • Not switched to play-services-mlkit-face-detection. The unbundled variant trades a missing .so for a missing Play-services module and contradicts the bundled-model decision in docs/PRD-camera-lenses.md §5.1. Larger change, different failure mode, not minimal.
  • No UI change when faces are unavailable — the lens carousel still shows. Out of scope for a crash fix.
  • No new test. The only place this is coverable is a Robolectric unit test (System.loadLibrary always fails on the JVM, so the guard is exercised for free): construct FaceTracker, assert it does not throw and that analyze() closes a mocked ImageProxy without publishing. It was not added because it could not be run in the authoring environment (below), and an unverified test is worse than none.

Related Issue

Fixes #195

Type of Change

  • 🐛 Bug Fix: Prevents a fatal crash when the face-detection native library fails to load
  • 📝 Documentation: Lesson 040 addendum and version-pin guidance

How Has This Been Tested?

Warning

The pre-PR sweep did NOT run, and this branch has NOT been built or run on a device. It was authored in a cloud session with no Android SDK, no pwsh, no emulator, and dl.google.com blocked by the egress policy. Gradle fails before reaching any project code:

Unable to download toolchain matching {languageVersion=21, vendor=JetBrains} …
Received status code 403 from server: Forbidden

scripts/hooks/require-sweep.mjs correctly blocked create_pull_request for this commit; the PR was opened separately from the Claude Code UI. Per docs/DEFINITION_OF_DONE.md this is not at "Ready for PR" — the sweep still has to run locally before it can merge.

What was actually verified

Check Result
Kotlin type-check of both changed files at -Werror PASSkotlinc 2.4.10 against hand-written stubs for the Android / ML Kit / Firebase surface, compiled alongside the real LensPhysics.kt, LensAnchor.kt, FaceRoster.kt
Name-shadowing + smart-cast through || at -Werror PASS — isolated probe, no warning
cspell over the sweep's file set (471 files) PASS — 0 unknown words
markdownlint-cli2 (198 files) PASS — 0 issues
Markdown table alignment PASS — 0 misaligned
markdown-link-check on the changed doc PASS
Harness skill trees byte-identical PASS
JSON validity, IDE dictionary in sync with cspell.json PASS
scripts/test-*.py self-checks 3 PASS; test-pre-pr-sweep.py errors on missing pwsh (environment, not this diff)
CI: Markdown · spelling · JSON, New .md file placement PASS

Stub-based type-checking proves the diff is well-formed Kotlin against the documented ML Kit signatures. It does not prove it links against the real AARs.

What was NOT verified — needs a local sweep

  • Debug + release build (allWarningsAsErrors, R8), zipalign -P 16
  • Android Lint, Inspect Code
  • Unit + instrumented tests (FaceTrackerNormalizationTest in particular)
  • Emulator run + screenshot + run-verification-loops.py — the one that matters. System.loadLibrary("face_detector_v2_jni") succeeding is now a precondition for any face lens, so this change must be proven not to disable lenses on a healthy device.

Manual QA checklist

  1. Install, open the camera, pick a face lens — it must track a face exactly as before.
  2. adb logcat -s OpenLoopFaceTracker must not show ML Kit face detection unavailable. If it does, the library name moved and the pin comment in gradle/libs.versions.toml is what to re-check.
  3. Flip front/back and leave/re-enter the camera a few times; lenses keep working, no faces leaked across binds.
  4. Record → trim → save; unchanged path, confirm no regression.
  5. Optional negative test: strip or rename libface_detector_v2_jni.so in an installed build and confirm the app opens, the camera previews, a face lens simply does nothing, and Crashlytics logs face_tracker_unavailable instead of crashing.

Checklist

  • 🔍 Self-review completed
  • ✍️ Code commented, particularly the native-library loading strategy
  • 🧪 Changes verified locally and work as expected — NOT DONE: cannot build or run in the authoring environment
  • 📖 No compile warnings or errors (allWarningsAsErrors is on) — stub compile is clean at -Werror; the real build is unverified
  • 🧹 Pre-PR sweep GREEN on the final commitNOT RUN: no Android SDK / pwsh / emulator, Google Maven blocked. Inspect Code export: SKIPPED because the IDE is unavailable in this environment. Onboarding loop: SKIPPED — not verified.
  • 🏪 Play-facing docs aligned: no permission, data-collection, storage or user-facing feature change; lenses degrade rather than crash, so no store-listing impact
  • 🧹 Git branch clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01FBjSmDxvBq7XtFE9PA28Ko


Note

Medium Risk
Changes camera lens initialization and per-frame analysis when the detector is missing; wrong JNI name after an ML Kit bump could silently disable face lenses on all devices.

Overview
Fixes Issue #195 by preventing a fatal crash when ML Kit’s bundled face-detection native library (face_detector_v2_jni) fails to load. ML Kit loads that .so on a GMS Task worker where UnsatisfiedLinkError is not delivered to failure listeners, so wrapping FaceDetection.getClient() alone cannot catch it.

FaceTracker now calls System.loadLibrary on the app thread inside createDetector(), then builds the detector only if load succeeds; on LinkageError the detector stays null, face lenses are inert, and analyze() still closes each ImageProxy so the shared analysis stream (including hand tracking) does not stall. ReverseCrashlytics.reportFaceTrackerUnavailable records a non-fatal instead of a process kill.

Docs and tooling note the coupling: Lesson 040 adds guidance to pre-load JNI when failures are uncatchable, and the mlkit-face-detection version pin warns to re-verify the JNI library name on bumps (a rename would disable face lenses everywhere). Dictionary entries add libface for spelling.

Reviewed by Cursor Bugbot for commit 4c2b20a. Bugbot is set up for automated code reviews on this repo. Configure here.

A device that cannot load `libface_detector_v2_jni.so` took the whole app
down. `FaceTracker` built ML Kit's bundled detector in a field initializer
with no guard, and ML Kit runs that model load off the caller's thread — on
its own worker, inside a GMS `Task`. `Task` funnels only `Exception` into
`addOnFailureListener`, so the `UnsatisfiedLinkError` escaped the worker's
`Runnable` and killed the process, which is why Crashlytics reported it as
fatal rather than as a failed task. Wrapping `FaceDetection.getClient` would
have caught nothing.

So load the library first, on our own thread, where the failure is catchable.
A load that succeeds makes ML Kit's own `System.loadLibrary` a no-op; one that
throws means ML Kit would have crashed, so the detector is never built:
`detector` is null, `analyze` closes the proxy and publishes nothing, `close`
is a no-op, and a Crashlytics non-fatal records the population. The lenses go
inert and capture, trim, save and gallery are untouched — the same trade
`CameraManager.bindWithLensEffect` already makes when the analysis use case
cannot bind, and the one `HandTracker` makes for MediaPipe.

The proxy is still closed on every frame in that state: under
KEEP_ONLY_LATEST an unclosed proxy stalls the stream the hand tracker shares,
so "lenses are inert" would otherwise become "the analyzer is dead".

Loading by name couples one string to the dependency's `jni/` folder, so the
version-catalog pin and Lesson 040 now say to re-check it on an ML Kit bump —
a rename would read as "absent" and take face lenses off every device.

Fixes #195

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBjSmDxvBq7XtFE9PA28Ko
@stozo04
stozo04 merged commit 2aa5405 into main Sep 17, 2026
3 checks passed
@stozo04
stozo04 deleted the claude/festive-franklin-4pixad branch September 17, 2026 16:06
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.

[Crashlytics] com.google.mlkit:face-detection@@16.1.7 - com.google.mlkit.vision.face.bundled.internal.ThickFaceDetectorCreator.<clinit>

2 participants