@myazahq/kyc-sdk-react-native 2.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +128 -4
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +57 -2
  9. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  10. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  13. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  18. package/package.json +17 -8
  19. package/src/MyazaKYC.tsx +4 -11
  20. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  21. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  22. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  23. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  24. package/src/assets/fonts/OFL-Karla.txt +93 -0
  25. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  26. package/src/assets/fonts/README.md +12 -0
  27. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  28. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  30. package/src/capture/useAutoCapture.ts +8 -1
  31. package/src/components/DocumentCropper.tsx +30 -24
  32. package/src/components/DocumentReview.tsx +11 -1
  33. package/src/components/DocumentReviewSide.tsx +6 -2
  34. package/src/components/DocumentReviewZoom.tsx +5 -1
  35. package/src/components/Icon.tsx +8 -7
  36. package/src/components/KycFlow.tsx +9 -2
  37. package/src/components/MyazaButton.tsx +9 -0
  38. package/src/components/RequiredDocumentPill.tsx +114 -0
  39. package/src/components/StepHeader.tsx +23 -3
  40. package/src/components/documentReviewCopy.ts +39 -0
  41. package/src/components/fonts.ts +14 -17
  42. package/src/components/stepHeaderMeta.tsx +6 -1
  43. package/src/config/documentCaptureMethods.ts +31 -0
  44. package/src/config/workflowMerge.ts +2 -0
  45. package/src/index.ts +5 -10
  46. package/src/lib/documentCaptureCheck.ts +136 -0
  47. package/src/lib/model-ready.ts +82 -0
  48. package/src/lib/prime-models.ts +50 -0
  49. package/src/lib/resubmit.ts +36 -7
  50. package/src/lib/selfie-sharpness.ts +211 -0
  51. package/src/liveness/avatarSource.ts +59 -0
  52. package/src/liveness/useModelReady.ts +9 -55
  53. package/src/mrz/extract.ts +71 -4
  54. package/src/mrz/textRecognizer.ts +36 -0
  55. package/src/mrz/useTextModelReady.ts +21 -0
  56. package/src/screens/DocumentCaptureStep.tsx +177 -93
  57. package/src/screens/LivenessAvatar.tsx +18 -13
  58. package/src/screens/LivenessStep.tsx +37 -0
  59. package/src/screens/MrzScanView.tsx +33 -0
  60. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  61. package/src/screens/document/UploadPhase.tsx +177 -0
  62. package/src/screens/document/useDocumentCamera.ts +109 -0
  63. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  64. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  65. package/src/services/api-types.ts +28 -2
  66. package/src/services/api.ts +19 -3
  67. package/src/services/deviceMetadata.ts +1 -1
  68. package/src/services/mediaCompress.ts +23 -5
  69. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  70. package/src/store/kycStore.ts +8 -2
  71. package/src/types/config.ts +15 -0
  72. package/src/MyazaBiometricAuth.tsx +0 -119
  73. package/src/assets/liveness/Blink.gif +0 -0
  74. package/src/assets/liveness/Nod.gif +0 -0
  75. package/src/assets/liveness/Smile.gif +0 -0
  76. package/src/assets/liveness/Turn.gif +0 -0
  77. package/src/lib/biometric-auth.ts +0 -52
  78. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  79. package/src/services/api-biometric.ts +0 -37
  80. package/src/services/api-types-biometric.ts +0 -41
package/src/index.ts CHANGED
@@ -11,16 +11,6 @@
11
11
  export { MyazaKYC, useMyazaKYC } from './MyazaKYC';
12
12
  export type { MyazaKYCProps, UseMyazaKYCReturn } from './MyazaKYC';
13
13
 
14
- // Returning-user face re-authentication (the web SDK's MyazaBiometricAuth).
15
- export { MyazaBiometricAuth } from './MyazaBiometricAuth';
16
- export type { MyazaBiometricAuthProps } from './MyazaBiometricAuth';
17
- export type {
18
- BiometricAuthRequest,
19
- BiometricAuthResponse,
20
- BiometricStatusResponse,
21
- BiometricLivenessClaim,
22
- } from './services/api-types-biometric';
23
-
24
14
  // Public config + callback types
25
15
  export type {
26
16
  MyazaKYCConfig,
@@ -192,6 +182,11 @@ export { mrzCheckDigit, parseMrz, type MrzScan } from './mrz/parse';
192
182
  // "this document has no MRZ" — the two look identical from the outside, and
193
183
  // only the first is worth reporting as a build problem.
194
184
  export { hasTextRecognizer } from './mrz/textRecognizer';
185
+ // Whether that recogniser can run RIGHT NOW. On Android the model is fetched
186
+ // through Play Services rather than bundled, so a build that HAS the recogniser
187
+ // can still be unable to read for a while after install — a different fact from
188
+ // the probe above, and the one a host app would show a "getting ready" note for.
189
+ export { isTextModelReady, primeTextModel } from './mrz/textRecognizer';
195
190
  export { hasRectDetector } from './capture/rectDetector';
196
191
  export { extractMrz, sanitizeMrzLine } from './mrz/extract';
197
192
 
@@ -0,0 +1,136 @@
1
+ // ─── Will the server be able to read these document photos? ──────────────────
2
+ //
3
+ // The React Native mirror of the web SDK's document capture check and the
4
+ // Flutter SDK's. Keep the three in lockstep: the problem order and every
5
+ // string below are word for word the same on each platform.
6
+ //
7
+ // Right after a document side uploads, the server runs the SAME detectors it
8
+ // later decides the verification with: a face on the photo printed on the
9
+ // front (only when the workflow will compare the selfie with that photo), and
10
+ // a readable barcode (only for documents whose details are read from one, such
11
+ // as a Nigerian voter's card or driver's licence). Asking here turns a decline
12
+ // that arrives by webhook into a retake that costs a few seconds.
13
+ //
14
+ // A NOTICE, NEVER A GATE. A detector can miss, so the applicant can always
15
+ // continue with the photos they have. And the check itself must never block
16
+ // the flow: a timeout, a network error or any answer we cannot read counts as
17
+ // "no problem", exactly like a check that found nothing wrong.
18
+ //
19
+ // Pure apart from the runner's timer, so the rules are pinned by a test
20
+ // without mounting React Native.
21
+
22
+ import type {
23
+ DocumentCaptureCheckRequest,
24
+ DocumentCaptureCheckResponse,
25
+ DocumentCaptureSide,
26
+ } from '../services/api-types';
27
+
28
+ export type DocumentCaptureCheckResult = DocumentCaptureCheckResponse;
29
+
30
+ export type CaptureProblemKind = 'no_face' | 'no_barcode';
31
+
32
+ export interface CaptureProblem {
33
+ side: DocumentCaptureSide;
34
+ kind: CaptureProblemKind;
35
+ }
36
+
37
+ /** How long Continue waits for each side's check before treating it as fine. */
38
+ export const CAPTURE_CHECK_TIMEOUT_MS = 8000;
39
+
40
+ export const CAPTURE_CHECK_TITLE = 'Check your photos';
41
+
42
+ export const CAPTURE_CHECK_CONTINUE_ANYWAY = 'Continue anyway';
43
+
44
+ const SIDE_ORDER: readonly DocumentCaptureSide[] = ['front', 'back'];
45
+
46
+ /**
47
+ * The problems worth a retake, front before back and, within a side, the face
48
+ * before the barcode. Only an explicit `false` is a problem: `true` is fine and
49
+ * `null` means the check did not apply or could not look.
50
+ */
51
+ export function captureCheckProblems(results: readonly DocumentCaptureCheckResult[]): CaptureProblem[] {
52
+ const problems: CaptureProblem[] = [];
53
+ for (const side of SIDE_ORDER) {
54
+ const forSide = results.filter((r) => r.side === side);
55
+ if (forSide.some((r) => r.face === false)) problems.push({ side, kind: 'no_face' });
56
+ if (forSide.some((r) => r.barcode === false)) problems.push({ side, kind: 'no_barcode' });
57
+ }
58
+ return problems;
59
+ }
60
+
61
+ /** The sides a retake button is offered for, once each and in problem order. */
62
+ export function captureRetakeSides(problems: readonly CaptureProblem[]): DocumentCaptureSide[] {
63
+ const sides: DocumentCaptureSide[] = [];
64
+ for (const p of problems) if (!sides.includes(p.side)) sides.push(p.side);
65
+ return sides;
66
+ }
67
+
68
+ export function captureProblemMessage(kind: CaptureProblemKind): string {
69
+ switch (kind) {
70
+ case 'no_face':
71
+ return "We couldn't see the face in the photo on the front of your ID. Retake it in good light, with the ID out of any plastic cover and no glare over the photo.";
72
+ case 'no_barcode':
73
+ return "We couldn't read the barcode on the back of your ID. Retake it with the ID out of any plastic cover, flat, filling the frame and with no glare over the barcode.";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The retake button's words. A side picked from the device (upload-only mode)
79
+ * was never taken with the camera, so it is replaced rather than retaken,
80
+ * matching the review screen's own wording.
81
+ */
82
+ export function captureRetakeLabel(side: DocumentCaptureSide, uploadOnly: boolean): string {
83
+ const verb = uploadOnly ? 'Replace' : 'Retake';
84
+ return `${verb} ${side}`;
85
+ }
86
+
87
+ /**
88
+ * Check every uploaded side at once and return what came back in time.
89
+ *
90
+ * Never throws and never waits longer than `timeoutMs` per side: a side whose
91
+ * call rejects or runs out of time is simply left out, which reads as "no
92
+ * problem". The side each result describes is the side we ASKED about, and a
93
+ * field that is not a boolean is read as `null`, so a malformed answer cannot
94
+ * invent a problem.
95
+ */
96
+ export async function runCaptureChecks(
97
+ requests: readonly DocumentCaptureCheckRequest[],
98
+ check: (body: DocumentCaptureCheckRequest, signal: AbortSignal) => Promise<DocumentCaptureCheckResponse>,
99
+ timeoutMs: number = CAPTURE_CHECK_TIMEOUT_MS,
100
+ ): Promise<DocumentCaptureCheckResult[]> {
101
+ const settled = await Promise.all(requests.map((body) => checkOneSide(body, check, timeoutMs)));
102
+ return settled.filter((r): r is DocumentCaptureCheckResult => r !== null);
103
+ }
104
+
105
+ async function checkOneSide(
106
+ body: DocumentCaptureCheckRequest,
107
+ check: (body: DocumentCaptureCheckRequest, signal: AbortSignal) => Promise<DocumentCaptureCheckResponse>,
108
+ timeoutMs: number,
109
+ ): Promise<DocumentCaptureCheckResult | null> {
110
+ const controller = new AbortController();
111
+ let timer: ReturnType<typeof setTimeout> | undefined;
112
+ const timedOut = new Promise<null>((resolve) => {
113
+ timer = setTimeout(() => {
114
+ controller.abort();
115
+ resolve(null);
116
+ }, timeoutMs);
117
+ });
118
+ try {
119
+ const answered = check(body, controller.signal).then(
120
+ (res): DocumentCaptureCheckResult => ({
121
+ side: body.side,
122
+ face: readVerdict(res?.face),
123
+ barcode: readVerdict(res?.barcode),
124
+ }),
125
+ );
126
+ return await Promise.race([answered, timedOut]);
127
+ } catch {
128
+ return null;
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+
134
+ function readVerdict(value: unknown): boolean | null {
135
+ return typeof value === 'boolean' ? value : null;
136
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ // ─── The on-device model readiness gate ───────────────────────────────────────
4
+ //
5
+ // Android fetches ML Kit's models through Play Services rather than bundling
6
+ // them, which keeps ~18.5 MB per device out of the APK but leaves a window
7
+ // where a detector cannot run: first launch before the download lands, or a
8
+ // device with no Google Play Services at all.
9
+ //
10
+ // This has to be answered BEFORE the camera opens, and neither detector can
11
+ // answer it itself. The face detector reports through `FaceResult`, where a
12
+ // missing model and an empty frame are both `faceCount: 0`; the text recogniser
13
+ // reports through `TextResult`, where both are an empty `lines` array. Gating on
14
+ // the per-frame result would strand the user on "position your face", or aiming
15
+ // at a passport that never reads, with the SDK unable to say why. That single
16
+ // failure mode is the whole reason this gate exists.
17
+ //
18
+ // Shared by both because the rule and the reasoning are identical — a copy per
19
+ // detector would be two places for one decision to drift.
20
+ //
21
+ // iOS is always ready (Apple Vision is a system framework), so this resolves on
22
+ // the first tick there and costs nothing.
23
+
24
+ export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
25
+
26
+ /**
27
+ * How long to wait for a model before calling it unavailable. Generous on
28
+ * purpose: the text model is about 10 MB, the native side requests it as an
29
+ * urgent install, and a slow connection is the common case. The cost is that a
30
+ * phone with no Play Services at all waits the full minute before being told;
31
+ * the bundled build (`myazaKycBundledMlKit`) is the answer for those fleets.
32
+ */
33
+ const MODEL_WAIT_MS = 60_000;
34
+ /** Gap between readiness polls while the download is in flight. */
35
+ const POLL_INTERVAL_MS = 500;
36
+
37
+ /**
38
+ * Tracks whether an on-device model can run.
39
+ *
40
+ * Returns `'preparing'` while Play Services fetches it, `'ready'` once it can
41
+ * run, and `'unavailable'` when it could not be obtained within
42
+ * {@link MODEL_WAIT_MS} — no Play Services, no network, or a declined install.
43
+ *
44
+ * The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
45
+ * user reaches the step this is normally already `'ready'` and no waiting screen
46
+ * is ever shown.
47
+ *
48
+ * @param isReady whether the model can run right now
49
+ * @param prime starts the download; called again here because the step can be
50
+ * reached directly in a resumed flow, and priming is a no-op once ready
51
+ */
52
+ export function useNativeModelReady(
53
+ isReady: () => boolean,
54
+ prime: () => void,
55
+ ): ModelReadyState {
56
+ const [state, setState] = useState<ModelReadyState>(() =>
57
+ isReady() ? 'ready' : 'preparing',
58
+ );
59
+ const startedAt = useRef(Date.now());
60
+ // Refs, not deps: a caller passing inline arrows would otherwise restart the
61
+ // poll, and the wait clock with it, on every render.
62
+ const fns = useRef({ isReady, prime });
63
+ fns.current = { isReady, prime };
64
+
65
+ useEffect(() => {
66
+ if (state !== 'preparing') return;
67
+
68
+ fns.current.prime();
69
+
70
+ const id = setInterval(() => {
71
+ if (fns.current.isReady()) {
72
+ setState('ready');
73
+ } else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
74
+ setState('unavailable');
75
+ }
76
+ }, POLL_INTERVAL_MS);
77
+
78
+ return () => clearInterval(id);
79
+ }, [state]);
80
+
81
+ return state;
82
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import { primeFaceModel } from '../liveness/visionCameraFaceDetector';
4
+ import { primeLivenessAvatars } from '../liveness/avatarSource';
5
+ import { primeTextModel } from '../mrz/textRecognizer';
6
+
7
+ // ─── Warming the on-device work the flow is about to need ────────────────────
8
+ //
9
+ // Android fetches ML Kit's models through Play Services rather than bundling
10
+ // them, and the gesture animations are served rather than shipped. All three
11
+ // are wanted in front of a camera, which is the worst possible moment to start
12
+ // a download — so they start the moment the flow opens instead, overlapping
13
+ // the screens the user is already reading (consent, ID type, the document
14
+ // step). Mirrors the web SDK's primeFaceMesh().
15
+ //
16
+ // Best-effort throughout: every step still gates on its own readiness, so a
17
+ // failure here costs a head start and nothing else.
18
+ //
19
+ // SHARED BY BOTH ENTRY POINTS, and that is the whole point of the file. This
20
+ // effect used to live inside the <MyazaKYC/> trigger component, so a consumer
21
+ // using useMyazaKYC() — the hook the SDK's own example uses, and the one
22
+ // documented for programmatic control — primed NOTHING. The face model was
23
+ // first requested when the liveness step mounted, in front of a camera the
24
+ // user was already looking at, and the gesture GIFs were fetched at the same
25
+ // moment. On a device that had never downloaded the model (a fresh install,
26
+ // the common case for a real applicant) that is a ~8 MB wait with the camera
27
+ // already up. A warm device hides it completely, which is why it survived:
28
+ // isModelReady() answers true on the first call and every path looks correct.
29
+ //
30
+ // The text model is the exception that proves it — useAutoCapture primes it at
31
+ // the document step, so it kept a head start on both paths regardless.
32
+ export function usePrimeModels(
33
+ wantOpen: boolean,
34
+ apiKey: string,
35
+ devUrl?: string,
36
+ ): void {
37
+ // Once per open sequence: priming is idempotent, but re-running it on every
38
+ // render would ask Play Services the same question a few times a second.
39
+ const primedRef = useRef(false);
40
+ useEffect(() => {
41
+ if (!wantOpen || primedRef.current) return;
42
+ primedRef.current = true;
43
+ primeFaceModel();
44
+ // The larger of the two, and wanted EARLIER in the flow than the face one
45
+ // (the document step comes before liveness), so it has the least time to
46
+ // arrive and the most to gain from the head start.
47
+ primeTextModel();
48
+ primeLivenessAvatars(apiKey, devUrl);
49
+ }, [wantOpen, apiKey, devUrl]);
50
+ }
@@ -17,6 +17,30 @@ export interface ResubmitConfig {
17
17
  steps: string[];
18
18
  /** Reviewer's note to the applicant. */
19
19
  message?: string | null;
20
+ /**
21
+ * The ID the verification being redone used, when the server CARRIES it.
22
+ *
23
+ * A redo keeps the verification id, so a reviewer who did not tick the ID
24
+ * has asked for nothing about it: the server keeps the original number,
25
+ * document photos, typed name and chip read, and says so by sending the
26
+ * idType here. Present only on an individual, single-ID send-back whose
27
+ * reviewer did not tick 'id-type'. Absent (an older server, a business redo,
28
+ * a multi-ID run, or the ID was ticked) keeps the old behaviour.
29
+ */
30
+ idType?: string | null;
31
+ }
32
+
33
+ /**
34
+ * The ID a redo keeps, or null when the applicant must name one again.
35
+ *
36
+ * Read defensively rather than trusted: an idType beside a plan that ticks the
37
+ * ID picker, or beside no plan at all, is not an instruction to skip it.
38
+ */
39
+ export function keptIdType(resubmit: ResubmitConfig | undefined | null): string | null {
40
+ const asked = resubmit?.steps;
41
+ if (!asked?.length || asked.includes('id-type')) return null;
42
+ const idType = resubmit?.idType;
43
+ return typeof idType === 'string' && idType.trim().length > 0 ? idType : null;
20
44
  }
21
45
 
22
46
  /**
@@ -48,10 +72,12 @@ const EVIDENCE: KYCStep[] = ['id-input', 'document-capture', 'nfc'];
48
72
  * Steps a narrowed flow keeps regardless, because without them it cannot
49
73
  * produce a submission at all.
50
74
  *
51
- * A resubmission is a NEW verification on a FRESH session: nothing is carried
52
- * forward from the one being redone, so the applicant must still say which ID
53
- * this is and supply it. `POST /verify` requires an `idType`, and a number-only
54
- * ID requires the number with it.
75
+ * Unless the server carries the original ID (`keptIdType`), nothing is carried
76
+ * forward from the attempt being redone, so the applicant must still say which
77
+ * ID this is and supply it. `POST /verify` requires an `idType`, and a
78
+ * number-only ID requires the number with it. When the ID IS carried, the
79
+ * identity steps drop out: the SDK preselects the kept ID and the server fills
80
+ * in the number, the document photos and the chip read.
55
81
  *
56
82
  * So narrowing removes the things arranged AROUND the identity — liveness,
57
83
  * proof of address, the questionnaire, contact checks — and never the identity
@@ -93,9 +119,12 @@ export function applyResubmitSteps(
93
119
 
94
120
  const wanted = new Set<string>(asked);
95
121
  if (wantsEvidence) for (const step of EVIDENCE) wanted.add(step);
96
- for (const step of order.includes('business-details') ? BUSINESS_REQUIRED : INDIVIDUAL_REQUIRED) {
97
- wanted.add(step);
98
- }
122
+ // A kept ID needs no picker and, unless the reviewer asked for the evidence,
123
+ // no evidence step either: the redo is only what was ticked. KYB never keeps
124
+ // an ID (the server never sends one for a business redo), so it stays as is.
125
+ const isBusiness = order.includes('business-details');
126
+ const required = isBusiness ? BUSINESS_REQUIRED : keptIdType(resubmit) ? [] : INDIVIDUAL_REQUIRED;
127
+ for (const step of required) wanted.add(step);
99
128
 
100
129
  const narrowed = order.filter((step) => wanted.has(step) || ALWAYS.includes(step));
101
130
 
@@ -0,0 +1,211 @@
1
+ // ─── Was the selfie sharp enough? Measured the moment it is taken ────────────
2
+ //
3
+ // The React Native mirror of the web SDK's lib/selfie-sharpness.ts and the
4
+ // Flutter SDK's utils/selfie_sharpness.dart. Keep the three in lockstep: the
5
+ // floor, the crop fraction and the measuring size are what make a score mean
6
+ // the same thing on every platform.
7
+ //
8
+ // The server can already say a failed face check was caused by a blurry
9
+ // selfie, but that reaches the applicant by webhook long after the phone is
10
+ // back in a pocket. The review screen is the one place a retake costs two
11
+ // seconds, so the same question is asked here.
12
+ //
13
+ // A NOTICE, NEVER A GATE. The floor was not calibrated on real phone captures,
14
+ // and a wrong floor on a gate would trap a genuine applicant in a retake loop.
15
+ // As a notice the cost of a wrong floor is one sentence the applicant can
16
+ // ignore: Continue stays available whatever this says. The server follows the
17
+ // same rule, where capture quality explains a failure and never causes one.
18
+ //
19
+ // MEASURED ON THE FACE, NOT THE FRAME. Auto-capture only fires once the face is
20
+ // centred and fills a good part of the frame, so the centre of the still IS the
21
+ // face. A centred square is also unchanged by a 90 degree rotation or a mirror,
22
+ // so EXIF orientation cannot move the measurement onto the room.
23
+ //
24
+ // The crop and the resize run natively in Nitro Image, so JS only ever reads
25
+ // 160 by 160 pixels, never the full still.
26
+
27
+ /** Below this, the notice shows. A starting value, biased toward NOT showing. */
28
+ export const SELFIE_SHARPNESS_FLOOR = 18;
29
+
30
+ /** Share of the shorter side the centre crop takes. */
31
+ export const SELFIE_CROP_FRACTION = 0.5;
32
+
33
+ /**
34
+ * The crop is resized to a FIXED size before measuring, because Laplacian
35
+ * variance scales with resolution: without it the score would measure the
36
+ * phone's camera rather than the photograph.
37
+ */
38
+ export const SELFIE_MEASURE_SIZE = 160;
39
+
40
+ /**
41
+ * Variance of the 4-neighbour Laplacian over a single-channel plane.
42
+ *
43
+ * Blur is a low-pass filter, so it flattens second derivatives, so the spread
44
+ * of the Laplacian response collapses. Variance rather than mean, because the
45
+ * mean of a Laplacian is near zero on any image, sharp or not. The same measure
46
+ * the server runs, so the two readings can be compared.
47
+ */
48
+ export function laplacianVariance(gray: ArrayLike<number>, width: number, height: number): number {
49
+ // Interior pixels only: the kernel needs all four neighbours.
50
+ if (width < 3 || height < 3 || gray.length < width * height) return 0;
51
+
52
+ let sum = 0;
53
+ let sumSq = 0;
54
+ let n = 0;
55
+ for (let y = 1; y < height - 1; y += 1) {
56
+ const row = y * width;
57
+ for (let x = 1; x < width - 1; x += 1) {
58
+ const i = row + x;
59
+ const v = gray[i - width]! + gray[i + width]! + gray[i - 1]! + gray[i + 1]! - 4 * gray[i]!;
60
+ sum += v;
61
+ sumSq += v * v;
62
+ n += 1;
63
+ }
64
+ }
65
+ if (n === 0) return 0;
66
+ const mean = sum / n;
67
+ return Math.round((sumSq / n - mean * mean) * 100) / 100;
68
+ }
69
+
70
+ /** A centred square covering `fraction` of the shorter side. */
71
+ export function centreCrop(
72
+ width: number,
73
+ height: number,
74
+ fraction: number = SELFIE_CROP_FRACTION,
75
+ ): { sx: number; sy: number; sw: number; sh: number } {
76
+ const side = Math.max(1, Math.round(Math.min(width, height) * fraction));
77
+ return {
78
+ sx: Math.max(0, Math.round((width - side) / 2)),
79
+ sy: Math.max(0, Math.round((height - side) / 2)),
80
+ sw: side,
81
+ sh: side,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Whether to show the notice. An unmeasurable selfie is NOT blurry: "we could
87
+ * not look" is not evidence the photograph was soft, and saying so would ask a
88
+ * person to retake a photo that may be perfectly good.
89
+ */
90
+ export function isSelfieBlurry(score: number | null): boolean {
91
+ return score != null && score < SELFIE_SHARPNESS_FLOOR;
92
+ }
93
+
94
+ // Where each colour channel sits, per Nitro Image's `pixelFormat`, which names
95
+ // the BYTE order of the buffer it hands back.
96
+ const CHANNELS: Record<string, { r: number; g: number; b: number; bpp: number }> = {
97
+ RGBA: { r: 0, g: 1, b: 2, bpp: 4 },
98
+ RGBX: { r: 0, g: 1, b: 2, bpp: 4 },
99
+ BGRA: { r: 2, g: 1, b: 0, bpp: 4 },
100
+ BGRX: { r: 2, g: 1, b: 0, bpp: 4 },
101
+ ARGB: { r: 1, g: 2, b: 3, bpp: 4 },
102
+ XRGB: { r: 1, g: 2, b: 3, bpp: 4 },
103
+ ABGR: { r: 3, g: 2, b: 1, bpp: 4 },
104
+ XBGR: { r: 3, g: 2, b: 1, bpp: 4 },
105
+ RGB: { r: 0, g: 1, b: 2, bpp: 3 },
106
+ BGR: { r: 2, g: 1, b: 0, bpp: 3 },
107
+ };
108
+
109
+ /**
110
+ * Rec. 601 luminance from a raw pixel buffer, or null for a layout we cannot
111
+ * read. The stride is taken from the buffer itself, because a platform may pad
112
+ * rows and assuming a tight buffer would shear the image diagonally.
113
+ */
114
+ export function grayFromPixels(
115
+ buffer: ArrayBuffer,
116
+ width: number,
117
+ height: number,
118
+ pixelFormat: string,
119
+ ): Uint8Array | null {
120
+ const layout = Object.prototype.hasOwnProperty.call(CHANNELS, pixelFormat)
121
+ ? CHANNELS[pixelFormat]
122
+ : undefined;
123
+ if (!layout || width < 1 || height < 1) return null;
124
+ const bytes = new Uint8Array(buffer);
125
+ const stride = Math.floor(bytes.length / height);
126
+ if (stride < width * layout.bpp) return null;
127
+
128
+ const gray = new Uint8Array(width * height);
129
+ for (let y = 0; y < height; y += 1) {
130
+ const row = y * stride;
131
+ for (let x = 0; x < width; x += 1) {
132
+ const i = row + x * layout.bpp;
133
+ gray[y * width + x] = Math.round(
134
+ 0.299 * bytes[i + layout.r]! + 0.587 * bytes[i + layout.g]! + 0.114 * bytes[i + layout.b]!,
135
+ );
136
+ }
137
+ }
138
+ return gray;
139
+ }
140
+
141
+ /** Nitro Image wants a filesystem path, never a `file://` URL. */
142
+ export function fileUriToPath(uri: string): string {
143
+ if (!uri.startsWith('file://')) return uri;
144
+ const path = uri.slice('file://'.length);
145
+ try {
146
+ return decodeURIComponent(path);
147
+ } catch {
148
+ return path;
149
+ }
150
+ }
151
+
152
+ export interface RawPixels {
153
+ buffer: ArrayBuffer;
154
+ width: number;
155
+ height: number;
156
+ pixelFormat: string;
157
+ }
158
+
159
+ export interface NitroImageLike {
160
+ width: number;
161
+ height: number;
162
+ cropAsync(startX: number, startY: number, endX: number, endY: number): Promise<NitroImageLike>;
163
+ resizeAsync(width: number, height: number): Promise<NitroImageLike>;
164
+ toRawPixelDataAsync(allowGpu?: boolean): Promise<RawPixels>;
165
+ }
166
+
167
+ export interface ImageFactoryLike {
168
+ loadFromFileAsync(filePath: string): Promise<NitroImageLike>;
169
+ }
170
+
171
+ let factory: ImageFactoryLike | null | undefined;
172
+
173
+ // Resolved once. A require that failed (a test runner, an install whose native
174
+ // module did not link) will fail again, and the answer is the same: no notice.
175
+ function loadImages(): ImageFactoryLike | null {
176
+ if (factory === undefined) {
177
+ try {
178
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
179
+ const mod = require('react-native-nitro-image') as { Images?: ImageFactoryLike };
180
+ factory = mod?.Images ?? null;
181
+ } catch {
182
+ factory = null;
183
+ }
184
+ }
185
+ return factory;
186
+ }
187
+
188
+ /**
189
+ * Load the still, measure its centre. Null on any failure.
190
+ *
191
+ * `images` exists so a test can hand in a fake; callers never pass it.
192
+ */
193
+ export async function measureSelfieSharpness(
194
+ uri: string,
195
+ images: ImageFactoryLike | null = loadImages(),
196
+ ): Promise<number | null> {
197
+ if (!images) return null;
198
+ try {
199
+ const still = await images.loadFromFileAsync(fileUriToPath(uri));
200
+ if (!still.width || !still.height) return null;
201
+ // Nitro's crop takes END coordinates, not a width and height.
202
+ const c = centreCrop(still.width, still.height);
203
+ const face = await still.cropAsync(c.sx, c.sy, c.sx + c.sw, c.sy + c.sh);
204
+ const small = await face.resizeAsync(SELFIE_MEASURE_SIZE, SELFIE_MEASURE_SIZE);
205
+ const raw = await small.toRawPixelDataAsync();
206
+ const gray = grayFromPixels(raw.buffer, raw.width, raw.height, raw.pixelFormat);
207
+ return gray ? laplacianVariance(gray, raw.width, raw.height) : null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
@@ -0,0 +1,59 @@
1
+ import { Image } from 'react-native';
2
+
3
+ import { resolveBaseUrl } from '../services/resolveUrl';
4
+ import type { LivenessChallenge } from './types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Where the gesture animations come from.
8
+ //
9
+ // They used to be four GIFs bundled in this package — 5.5 MB, shipped to every
10
+ // device whatever the workflow asked for, for a badge that is on screen for a
11
+ // few seconds of the liveness step. They are served by the API instead
12
+ // (`/api/kyc/assets/liveness/<gesture>.gif`), prefetched when the flow opens,
13
+ // and cached by the platform image loader from then on.
14
+ //
15
+ // GIF on BOTH platforms deliberately, even though the server also has WebP at a
16
+ // tenth of the size: React Native decodes only GIF animation on iOS, and
17
+ // animated WebP on Android needs Fresco's `animated-webp` module, which the
18
+ // host app enables or does not — invisible from in here. One URL, no platform
19
+ // branch, no dependence on the integrator's Fresco configuration.
20
+ //
21
+ // Nothing here throws and nothing here is awaited by the flow. A device that
22
+ // never gets the file shows the gesture icon the avatar has always fallen back
23
+ // to, and the instruction text above it says what to do regardless.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function livenessAvatarUrl(
27
+ challenge: LivenessChallenge,
28
+ apiKey: string,
29
+ devUrl?: string,
30
+ ): string | null {
31
+ try {
32
+ // Throws on a malformed key, which is a real error everywhere else in the
33
+ // SDK and merely a missing cartoon here.
34
+ return `${resolveBaseUrl(apiKey, devUrl)}/api/kyc/assets/liveness/${challenge}.gif`;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ const GESTURES: readonly LivenessChallenge[] = ['nod', 'turn', 'blink', 'smile'];
41
+
42
+ /**
43
+ * Warm the image cache at flow open, so the avatar is already on the device by
44
+ * the time liveness renders.
45
+ *
46
+ * Called beside `primeFaceModel()` and for the same reason: the work overlaps
47
+ * consent and ID-type selection instead of stalling in front of the camera.
48
+ * All four are fetched because which gestures a session asks for is randomised
49
+ * per session, and together they are under 700 KB.
50
+ */
51
+ export function primeLivenessAvatars(apiKey: string, devUrl?: string): void {
52
+ for (const gesture of GESTURES) {
53
+ const url = livenessAvatarUrl(gesture, apiKey, devUrl);
54
+ if (url == null) return;
55
+ Image.prefetch(url).catch(() => {
56
+ /* best-effort: the avatar falls back to its icon */
57
+ });
58
+ }
59
+ }