@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.
- package/README.md +128 -4
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +57 -2
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +17 -8
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/Icon.tsx +8 -7
- package/src/components/KycFlow.tsx +9 -2
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/stepHeaderMeta.tsx +6 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/workflowMerge.ts +2 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/services/api-types.ts +28 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/kycStore.ts +8 -2
- package/src/types/config.ts +15 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- 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
|
+
}
|
package/src/lib/resubmit.ts
CHANGED
|
@@ -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
|
-
*
|
|
52
|
-
* forward from the
|
|
53
|
-
* this is and supply it. `POST /verify` requires an `idType`, and a
|
|
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
|
-
|
|
97
|
-
|
|
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
|
+
}
|