The Compose-based face-capture module for the ZeroAuth Pramaan
Android client. Produces a deterministic 112×112 pixel cropped face
bitmap that the downstream :biometric module hashes into the SHA-256
biometric descriptor consumed by the fuzzy extractor + Poseidon
commitment (Scene 1 step 4–7 in
docs/plan/bfsi-v1/02-bank-demo.md):
"Face capture (CameraX + on-device ML Kit face detection). App shows a viewfinder, waits for a centred, well-lit face, takes the capture entirely on-device. The face image never leaves the device. SHA-256 of the face descriptor is computed."
| File | Role |
|---|---|
FaceCaptureScreen.kt |
The Compose composable. CameraX preview + ML Kit analysis + the 1.5 s liveness gate + the bitmap callback. |
FaceDetectorWrapper.kt |
ML Kit FaceDetector wrapped behind a clean coroutine API. Configured FAST + tracking, NO landmarks / classifications. |
LivenessTimer.kt |
The "face present continuously for N ms" timer. Pure, JVM-testable via injected clock. |
CaptureState.kt |
Sealed-class state machine + pure reducer. Drives the Compose screen. |
BitmapCrop.kt |
Deterministic crop-to-square + resize-to-112×112 helpers. Pure math in computeSquareBounds; JVM-testable. |
res/drawable/face_viewfinder.xml |
Vector ring overlay for the viewfinder. |
The :app module consumes this surface as:
FaceCaptureScreen(
onCaptured = { bitmap: Bitmap ->
// In-process callback only. See "Bitmap-flow contract" below.
// The :biometric module (lands with C-143) consumes this
// bitmap, hashes it with SHA-256, then passes the hash to the
// fuzzy extractor. The bitmap is GC-ed immediately after.
biometricEmbedder.consumeFace(bitmap)
},
onCancelled = { navController.popBackStack() },
)The :biometric module is wired in alongside C-143; until then the
bitmap is the output and it's the integrating code's job to keep the
bitmap in-process.
The Bitmap passed to onCaptured MUST be consumed by an
in-process callback. It MUST NOT be:
- Sent over the network (HTTP, gRPC, WebSocket, any wire protocol).
- Written to external storage.
- Logged via logcat or any logging framework.
- Passed across a Binder boundary to another process.
This is enforced in two complementary ways:
-
Source review. This module imports zero network libraries. The
AndroidManifest.xmldoes not declareINTERNET. Adding either trips thesecurity-reviewersubagent automatically (see the "Cross-line review" section of themobile/README.md). -
Runtime assertion.
FaceCaptureScreen.kt'sassertCallbackIsInProcesswalks the callback's declaring class name and crashes if the class name contains substrings that indicate a network stack (okhttp,retrofit,http,rpc,websocket,java.net.,android.net.http). Best-effort but catches the obvious shape (onCaptured = ::uploadFace).
The Scene 1 demo guarantee in docs/plan/bfsi-v1/02-bank-demo.md —
"the face image never leaves the device" — is the structural reason
both guards exist. ADR 0017 (blockchain-agnostic posture) preserves
this guarantee independent of which provider slots are wired in: the
biometric commitment lives off-chain by default and the on-chain
identity provider is opt-in per tenant.
⚠ TODO: ADR 0020 — full liveness
The v1 liveness gate is only a 1.5 s continuous-face-present
stability check (LivenessTimer.kt). A still photograph held in front
of the front camera satisfies this check.
The full liveness module (target: Phase 1 Sprint 3, commit C-148) lands the following on top of this scaffold:
- Randomized head-turn challenge ("look left", "look up") driven by
ML Kit's
headEulerAngle{X,Y,Z}outputs (re-enableLANDMARK_MODE_ALL). - Blink detection via
leftEyeOpenProbability+rightEyeOpenProbability(re-enableCLASSIFICATION_MODE_ALL). - Depth probing where the device's front sensor exposes one (Pixel 7/8 Tensor depth API, S22+ ToF sensor).
- ADR 0020 — formalises the liveness threat model + the per-tier device matrix (tier-1 must satisfy full liveness, tier-2 may fall back to a longer stability window, tier-3 is denied).
Until ADR 0020 lands, the Compose UI strings refer to "stability check" rather than "liveness" so the operator demoing Scene 1 is explicit about what the v1 module does.
- No network code.
INTERNETpermission is intentionally absent from this module's manifest. - No external storage. The bitmap lives in process heap only,
released to the GC as soon as the
onCapturedcallback returns. - Bundled ML Kit model.
face-detectionis the bundled artefact; the model is shipped inside the AAR and never fetched at runtime. (The unbundledface-detection-basevariant would lazily fetch the model — explicitly rejected; see the comment onmlkit-face-detectioningradle/libs.versions.toml.) - Camera is unbound on dispose. The CameraX provider is unbound
in the composable's
onDispose; theFaceDetectorWrapperis closed in the same hook. The system camera HAL is freed as soon as the screen leaves composition.
Three JVM-only unit tests run on Gradle's :test task. No emulator,
no instrumented test runner, no Android stubs required:
./gradlew :face:testThe tests cover:
BitmapCropTest— every code path incomputeSquareBoundsincluding the right-edge slide and the "face larger than bitmap" clamp. Verifies the determinism property: identical inputs produce byte-for-byte identical outputs (the v1 commitment scheme depends on this).LivenessTimerTest— every transition in the timer, driven by a closure-controlled clock so we can advance time deterministically.CaptureStateMachineTest— every row in the [CaptureStateMachine.next] transition table, plus a full happy-path round-trip and a face-lost-mid-stability recovery.
Instrumented tests (CameraX preview render, ML Kit detection end-to-end against a fixed input image) land alongside C-143 — they require a connected emulator and are deferred to the C-143 PR rather than blocking this scaffold.
Per docs/plan/bfsi-v1/06-ways-of-working.md §"Sub-agent rules",
every PR that touches mobile/face/** invokes the
security-reviewer subagent. The review focus is:
- No new network libraries.
- No new permissions that could leak the bitmap (e.g.,
READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,READ_MEDIA_IMAGES). assertCallbackIsInProcessis invoked on every code path that firesonCaptured.- The bundled ML Kit model is pinned (not the unbundled variant).
LAST_UPDATED: 2026-05-28 OWNER: Agent #19 (Mid Android Engineer, UX + flows)