@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
@@ -1,7 +1,6 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { Image, Platform, Pressable, View } from 'react-native';
3
3
  import * as ImagePicker from 'expo-image-picker';
4
- import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
5
4
  import { initialWindowMetrics } from 'react-native-safe-area-context';
6
5
 
7
6
  import { radius, spacing } from '../config/theme';
@@ -10,8 +9,8 @@ import { withRetry } from '../services/retry';
10
9
  import { mapToKycError, safeReportError } from '../services/errors';
11
10
  import { compressDocumentImage, compressVideo, cropCardRegion } from '../services/mediaCompress';
12
11
  import { MAX_VIDEO_BYTES } from '../config/captureSettings';
13
- import { KYCError } from '../types/verification';
14
12
  import type { DocumentCapturePhase } from '../store/kycStore';
13
+ import { documentCaptureMethods } from '../config/documentCaptureMethods';
15
14
  import { useEffectiveCountry, useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
16
15
  import { MyazaText } from '../components/Typography';
17
16
  import { MyazaButton } from '../components/MyazaButton';
@@ -20,17 +19,34 @@ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
20
19
  import { CameraPhase } from './document/CameraPhase';
21
20
  import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
22
21
  import { ReadyPrimer } from '../components/ReadyPrimer';
22
+ import { RequiredDocumentPill } from '../components/RequiredDocumentPill';
23
23
  import { READY_DOCUMENT } from '../components/readyPrimerContent';
24
24
  import { DocumentCropper } from '../components/DocumentCropper';
25
25
  import { DocumentReview } from '../components/DocumentReview';
26
26
  import { scanMrzFromImage } from '../mrz/textRecognizer';
27
27
  import { Icon } from '../components/Icon';
28
+ import { useDocumentCamera, type DocumentCamera } from './document/useDocumentCamera';
29
+ import { UploadPhase, documentUploadMeta } from './document/UploadPhase';
30
+ import { CaptureCheckNotice } from './document/CaptureCheckNotice';
31
+ import { isBusinessFlow } from '../config/business';
32
+ import { captureCheckProblems, runCaptureChecks, type CaptureProblem } from '../lib/documentCaptureCheck';
33
+ import type { DocumentCaptureCheckRequest, DocumentCaptureSide } from '../services/api';
28
34
 
29
35
  // Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
30
36
  // Phases: front → front-preview → back → review (two-sided); front → review
31
37
  // (one-sided). Manual shutter (VisionCamera) or gallery upload; each side is
32
38
  // compressed (OCR-conservative) then eagerly uploaded on Continue with retry.
33
39
  //
40
+ // Which of the two ways in is offered comes from `documentCaptureMethods`
41
+ // (allowDocumentScan / allowDocumentUpload). With scanning off, each side is
42
+ // UploadPhase instead of the camera, and the camera's hooks are never mounted.
43
+ //
44
+ // Before moving on, each uploaded side goes past the server's capture check
45
+ // (lib/documentCaptureCheck): no face on the printed photo, or a barcode that
46
+ // will not read, turns the review footer into a retake notice. A notice, never
47
+ // a gate: "Continue anyway" always advances, and a failed or slow check reads
48
+ // as no problem.
49
+ //
34
50
  // The per-phase title/description live in the SHEET HEADER (KycFlow reads the
35
51
  // synced `documentCapturePhase` from the store and calls `documentCaptureMeta`),
36
52
  // not in the body — mirroring Flutter's header `docReviewPhase` sync.
@@ -39,7 +55,10 @@ import { Icon } from '../components/Icon';
39
55
  export function documentCaptureMeta(
40
56
  phase: DocumentCapturePhase,
41
57
  documentLabel: string,
58
+ mode: 'scan' | 'upload' = 'scan',
42
59
  ): { title: string; description: string } {
60
+ // An upload-only workflow asks for a photo, so it cannot say "scan".
61
+ if (mode === 'upload') return documentUploadMeta(phase, documentLabel);
43
62
  // Copy matches the Flutter SDK's document-capture header meta exactly.
44
63
  switch (phase) {
45
64
  case 'front':
@@ -77,6 +96,33 @@ function cameraBottomInset(): number {
77
96
  }
78
97
 
79
98
  export function DocumentCaptureStep(): React.ReactElement {
99
+ const config = useKycConfig();
100
+ const methods = documentCaptureMethods(config);
101
+ // The camera's hooks live in their own wrapper so an upload-only workflow
102
+ // (`allowDocumentScan: false`) never mounts them: no permission read, no
103
+ // device enumeration, no prompt. The element types stay fixed for a given
104
+ // config, so the capture state below keeps the step's whole lifetime.
105
+ return methods.scan ? (
106
+ <ScanningDocumentCapture allowUpload={methods.upload} />
107
+ ) : (
108
+ <DocumentCapture camera={null} allowUpload />
109
+ );
110
+ }
111
+
112
+ function ScanningDocumentCapture({ allowUpload }: { allowUpload: boolean }): React.ReactElement {
113
+ const config = useKycConfig();
114
+ const camera = useDocumentCamera(config.onError);
115
+ return <DocumentCapture camera={camera} allowUpload={allowUpload} />;
116
+ }
117
+
118
+ function DocumentCapture({
119
+ camera,
120
+ allowUpload,
121
+ }: {
122
+ /** Null in upload-only mode, where each side is a photo from the device. */
123
+ camera: DocumentCamera | null;
124
+ allowUpload: boolean;
125
+ }): React.ReactElement {
80
126
  const { colors } = useTheme();
81
127
  const toast = useToast();
82
128
  const config = useKycConfig();
@@ -90,12 +136,23 @@ export function DocumentCaptureStep(): React.ReactElement {
90
136
  const setImmersiveCapture = useKyc((s) => s.setImmersiveCapture);
91
137
  const store = useKycStore();
92
138
  const nextStep = useKyc((s) => s.nextStep);
139
+ const sessionId = useKyc((s) => s.sessionId);
93
140
 
94
- const allowUpload = config.allowDocumentUpload !== false;
95
141
  const documentLabel =
96
142
  (selectedIdType && Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType)?.label) || 'Document';
97
143
  const scanSides = selectedIdType ? getScanSides(selectedIdType) : 'front_only';
98
144
  const isTwoSided = scanSides === 'front_and_back';
145
+
146
+ // "Required: <document>" — shown on the screens either side of the camera,
147
+ // matching the web SDK's badge and Flutter's _RequiredPill. Not over a live
148
+ // viewfinder: that screen is full-bleed and owns the display.
149
+ const requiredPill = (side: 'front' | 'back' | null) => (
150
+ <RequiredDocumentPill
151
+ idTypeLabel={documentLabel}
152
+ sideBadge={isTwoSided && side ? (side === 'back' ? 'Back Side' : 'Front Side') : null}
153
+ stepLabel={isTwoSided && side ? (side === 'back' ? 'Step 2 of 2' : 'Step 1 of 2') : null}
154
+ />
155
+ );
99
156
  const guideAspect = documentGuideAspect(selectedIdType);
100
157
 
101
158
  const [phase, setPhase] = useState<DocumentCapturePhase>('front');
@@ -106,6 +163,10 @@ export function DocumentCaptureStep(): React.ReactElement {
106
163
  const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
107
164
  const [uploadError, setUploadError] = useState<string | null>(null);
108
165
  const [cropUri, setCropUri] = useState<string | null>(null); // gallery photo awaiting crop
166
+ // What the server's capture check found wrong with the uploaded sides. Set
167
+ // only when there is something to retake; any new capture clears it, so the
168
+ // next Continue uploads and checks again.
169
+ const [captureProblems, setCaptureProblems] = useState<CaptureProblem[] | null>(null);
109
170
  // Best-effort document videos recorded alongside each side's still (uploaded as
110
171
  // document_front_video / document_back_video). Refs — they don't drive the UI.
111
172
  const frontVideoRef = useRef<string | null>(null);
@@ -117,42 +178,14 @@ export function DocumentCaptureStep(): React.ReactElement {
117
178
  }, [phase, setDocumentCapturePhase]);
118
179
 
119
180
 
120
- // ── Camera permission ──────────────────────────────────────────────────────
121
- // `perm` is derived from the ASYNC requestPermission result, not synchronously
122
- // from `hasPermission` — otherwise the brief window while the OS prompt is open
123
- // (hasPermission still false) would read as "denied" and fire onError early.
124
- // 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
125
- // (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
126
- // "Grant access".
127
- const { hasPermission, requestPermission } = useCameraPermission();
128
- const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
129
- hasPermission ? 'granted' : 'priming',
130
- );
131
- const permReportedRef = useRef(false);
132
-
133
- // ── Camera availability ─────────────────────────────────────────────────────
134
- // Even with permission granted, there may be no usable back camera (the iOS/
135
- // Android simulator has none; a real device may fail to init). Give the device
136
- // list a moment to resolve, then surface a proper "Camera not available" error
137
- // with an upload fallback — on every iOS version (glass or not) and Android.
138
- // Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
139
- // Android CameraManager list hardware regardless), so `!device` reliably means
140
- // "no back-camera hardware" — true on every simulator. That's a different state
141
- // from "permission denied": no hardware → nothing to grant.
142
- const device = useCameraDevice('back');
143
- const [cameraGrace, setCameraGrace] = useState(false);
144
- useEffect(() => {
145
- const t = setTimeout(() => setCameraGrace(true), 1500);
146
- return () => clearTimeout(t);
147
- }, []);
148
- // No camera hardware at all → "Camera not available" (regardless of what the
149
- // permission API says — on a camera-less sim it may even report denied).
150
- const cameraUnavailable = cameraGrace && !device;
181
+ // ── Camera permission + availability ───────────────────────────────────────
182
+ // Owned by `useDocumentCamera` (called by ScanningDocumentCapture), so
183
+ // `camera` is null in upload-only mode and every camera gate below is skipped.
184
+ //
151
185
  // Gate the camera path on the user acknowledging the primer. Shown once for
152
186
  // the step, not per side — repeating it before the back of a card would be
153
187
  // noise, not a warning.
154
188
  const [ready, setReady] = useState(false);
155
- const showPrimer = perm === 'priming' && !!device;
156
189
  // Tell the shell when the full-bleed camera is on screen. Scoped tightly:
157
190
  // only the live preview earns the whole display — the ready primer, the
158
191
  // permission screens, the previews and the review all keep their chrome.
@@ -161,56 +194,21 @@ export function DocumentCaptureStep(): React.ReactElement {
161
194
  // cleanup here would fire during any remount and fight the effect that had
162
195
  // just raised it.
163
196
  const cameraLive =
164
- (phase === 'front' || phase === 'back') && ready && perm === 'granted' && !!device;
197
+ (phase === 'front' || phase === 'back') &&
198
+ ready &&
199
+ !!camera &&
200
+ camera.perm === 'granted' &&
201
+ camera.hasDevice;
165
202
  useEffect(() => {
166
203
  setImmersiveCapture(cameraLive);
167
204
  }, [cameraLive, setImmersiveCapture]);
168
205
 
169
- // Reflect an externally-granted permission.
170
- useEffect(() => {
171
- if (hasPermission) setPerm('granted');
172
- }, [hasPermission]);
173
-
174
- // Fire the real OS prompt only after the user taps "Grant access" (or retry).
175
- useEffect(() => {
176
- if (perm !== 'requesting') return;
177
- let cancelled = false;
178
- void (async () => {
179
- const granted = await requestPermission();
180
- if (!cancelled) setPerm(granted ? 'granted' : 'denied');
181
- })();
182
- return () => {
183
- cancelled = true;
184
- };
185
- }, [perm, requestPermission]);
186
-
187
- // A *genuine* permission denial requires a camera to exist but be blocked. On a
188
- // camera-less sim the OS may report denied — that's "not available", not a
189
- // permission problem, so don't treat it as denied or report onError there.
190
- const permissionDenied = perm === 'denied' && !!device;
191
- useEffect(() => {
192
- if (permissionDenied && !permReportedRef.current) {
193
- permReportedRef.current = true;
194
- safeReportError(
195
- config.onError,
196
- new KYCError(
197
- 'camera_permission_denied',
198
- 'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
199
- ),
200
- );
201
- }
202
- if (!permissionDenied) permReportedRef.current = false;
203
- }, [permissionDenied, config.onError]);
204
-
205
- const retryPermission = useCallback(() => {
206
- setPerm('requesting');
207
- }, []);
208
-
209
206
  // ── Capture → compress → store for the current side ────────────────────────
210
207
  const storeCapture = useCallback(
211
208
  async (rawUri: string) => {
212
209
  setBusy(true);
213
210
  setUploadError(null);
211
+ setCaptureProblems(null);
214
212
  try {
215
213
  const compressed = await compressDocumentImage(rawUri);
216
214
  // Read the MRZ off the FRONT while we have it. It is the key that
@@ -317,25 +315,34 @@ export function DocumentCaptureStep(): React.ReactElement {
317
315
  [api, setMediaId],
318
316
  );
319
317
 
320
- // ── Upload both sides, then advance (KycFlow routes to liveness/submitted) ──
318
+ // ── Advance (KycFlow routes to liveness/submitted, or the next multi-ID ID) ─
319
+ // Shared by a clean Continue and "Continue anyway". The media ids are already
320
+ // in the store, so moving on never uploads or checks a second time.
321
+ const advance = useCallback(() => {
322
+ setCaptureProblems(null);
323
+ nextStep();
324
+ }, [nextStep]);
325
+
326
+ // ── Upload both sides, check they will read, then advance ──────────────────
321
327
  const handleContinue = useCallback(async () => {
322
328
  if (!frontUri) return;
323
329
  setUploading(true);
324
330
  setUploadError(null);
331
+ setCaptureProblems(null);
325
332
  setRetryInfo(null);
326
333
  const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
334
+ const uploaded: { side: DocumentCaptureSide; mediaId: string }[] = [];
327
335
  try {
328
336
  const frontId = await withRetry(() => api.upload({ uri: frontUri, type: 'image/jpeg' }, 'document_front'), { onRetry });
329
337
  setMediaId('documentFront', frontId);
338
+ uploaded.push({ side: 'front', mediaId: frontId });
330
339
  await uploadDocVideo(frontVideoRef.current, 'document_front_video', 'documentFrontVideo');
331
340
  if (isTwoSided && backUri) {
332
341
  const backId = await withRetry(() => api.upload({ uri: backUri, type: 'image/jpeg' }, 'document_back'), { onRetry });
333
342
  setMediaId('documentBack', backId);
343
+ uploaded.push({ side: 'back', mediaId: backId });
334
344
  await uploadDocVideo(backVideoRef.current, 'document_back_video', 'documentBackVideo');
335
345
  }
336
- setRetryInfo(null);
337
- setUploading(false);
338
- nextStep();
339
346
  } catch (err) {
340
347
  setRetryInfo(null);
341
348
  setUploading(false);
@@ -343,11 +350,51 @@ export function DocumentCaptureStep(): React.ReactElement {
343
350
  setUploadError(kycError.message);
344
351
  toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
345
352
  safeReportError(config.onError, kycError);
353
+ return;
354
+ }
355
+ setRetryInfo(null);
356
+
357
+ // The same workflow the submission will carry: a KYB flow's document is
358
+ // the applicant's own leg, which submits under the mapped applicant
359
+ // workflow (store/submitApplicant). Best-effort throughout:
360
+ // runCaptureChecks never throws and gives up on a side after 8s.
361
+ const workflowId = isBusinessFlow(config) ? config.applicantWorkflowId : config.workflowId;
362
+ const requests: DocumentCaptureCheckRequest[] = selectedIdType
363
+ ? uploaded.map(({ side, mediaId }) => ({
364
+ mediaId,
365
+ side,
366
+ country,
367
+ idType: selectedIdType,
368
+ ...(workflowId ? { workflowId } : {}),
369
+ ...(sessionId ? { sessionId } : {}),
370
+ }))
371
+ : [];
372
+ const results = await runCaptureChecks(requests, (body, signal) => api.checkDocumentCapture(body, signal));
373
+ const problems = captureCheckProblems(results);
374
+ setUploading(false);
375
+ if (problems.length > 0) {
376
+ setCaptureProblems(problems);
377
+ return;
346
378
  }
347
- }, [frontUri, backUri, isTwoSided, api, setMediaId, nextStep, config.onError, toast, uploadDocVideo]);
379
+ advance();
380
+ }, [
381
+ frontUri,
382
+ backUri,
383
+ isTwoSided,
384
+ api,
385
+ setMediaId,
386
+ config,
387
+ toast,
388
+ uploadDocVideo,
389
+ selectedIdType,
390
+ country,
391
+ sessionId,
392
+ advance,
393
+ ]);
348
394
 
349
- const retake = (side: 'front' | 'back') => {
395
+ const retake = (side: DocumentCaptureSide) => {
350
396
  setUploadError(null);
397
+ setCaptureProblems(null);
351
398
  if (side === 'back') {
352
399
  setBackUri(null);
353
400
  backVideoRef.current = null;
@@ -377,7 +424,25 @@ export function DocumentCaptureStep(): React.ReactElement {
377
424
  // states keep the title/description in the header, and always offer the gallery
378
425
  // escape hatch). These never overlap: one needs a device, the other needs none.
379
426
  if (phase === 'front' || phase === 'back') {
380
- if (cameraUnavailable) {
427
+ if (!camera) {
428
+ // Upload-only: no ready primer (it describes a camera scan), no
429
+ // permission screens, no viewfinder. The pick runs the same cropper and
430
+ // store path a gallery photo takes from the camera screen.
431
+ return (
432
+ <>
433
+ {cropper}
434
+ <View style={{ marginBottom: spacing.md }}>{requiredPill(phase === 'back' ? 'back' : 'front')}</View>
435
+ <UploadPhase
436
+ isBack={phase === 'back'}
437
+ documentLabel={documentLabel}
438
+ isTwoSided={isTwoSided}
439
+ busy={busy}
440
+ onUpload={pickFromGallery}
441
+ />
442
+ </>
443
+ );
444
+ }
445
+ if (camera.cameraUnavailable) {
381
446
  // No back-camera hardware (e.g. a simulator) — permission is moot here.
382
447
  return (
383
448
  <>
@@ -393,28 +458,29 @@ export function DocumentCaptureStep(): React.ReactElement {
393
458
  return (
394
459
  <>
395
460
  {cropper}
461
+ <View style={{ marginBottom: spacing.md }}>{requiredPill(phase === 'back' ? 'back' : 'front')}</View>
396
462
  <ReadyPrimer content={READY_DOCUMENT} onReady={() => setReady(true)} />
397
463
  </>
398
464
  );
399
465
  }
400
- if (showPrimer) {
466
+ if (camera.showPrimer) {
401
467
  // Primer before the OS prompt — camera not started yet.
402
468
  return (
403
469
  <>
404
470
  {cropper}
405
471
  <CameraPermissionPrimingView
406
472
  message="When prompted, allow camera access to photograph your document."
407
- onGrant={() => setPerm('requesting')}
473
+ onGrant={camera.requestAccess}
408
474
  />
409
475
  </>
410
476
  );
411
477
  }
412
- if (permissionDenied) {
478
+ if (camera.permissionDenied) {
413
479
  // A real camera exists but the OS blocked access.
414
480
  return (
415
481
  <>
416
482
  {cropper}
417
- <CameraPermissionView onRetry={retryPermission} onUpload={pickFromGallery} />
483
+ <CameraPermissionView onRetry={camera.requestAccess} onUpload={pickFromGallery} />
418
484
  </>
419
485
  );
420
486
  }
@@ -426,7 +492,7 @@ export function DocumentCaptureStep(): React.ReactElement {
426
492
  <CameraPhase
427
493
  isBack={isBack}
428
494
  cameraLive={cameraLive}
429
- active={perm === 'granted'}
495
+ active={camera.perm === 'granted'}
430
496
  documentLabel={documentLabel}
431
497
  guideAspect={guideAspect}
432
498
  isTwoSided={isTwoSided}
@@ -453,13 +519,19 @@ export function DocumentCaptureStep(): React.ReactElement {
453
519
  if (phase === 'front-preview') {
454
520
  return (
455
521
  <View>
522
+ <View style={{ marginBottom: spacing.md }}>{requiredPill('front')}</View>
456
523
  <DocImage uri={frontUri!} />
457
524
  <View style={{ flexDirection: 'row', gap: spacing.md, marginTop: spacing.md }}>
458
525
  <View style={{ flex: 1 }}>
459
- <MyazaButton label="Retake" variant="outline" leadingIcon="refresh" onPress={() => retake('front')} />
526
+ <MyazaButton
527
+ label={camera ? 'Retake' : 'Replace'}
528
+ variant="outline"
529
+ leadingIcon="refresh"
530
+ onPress={() => retake('front')}
531
+ />
460
532
  </View>
461
533
  <View style={{ flex: 1 }}>
462
- <MyazaButton label="Next — Scan Back" onPress={() => setPhase('back')} />
534
+ <MyazaButton label={camera ? 'Next: Scan Back' : 'Next: Add Back'} onPress={() => setPhase('back')} />
463
535
  </View>
464
536
  </View>
465
537
  </View>
@@ -468,7 +540,10 @@ export function DocumentCaptureStep(): React.ReactElement {
468
540
 
469
541
  // ── Review ─────────────────────────────────────────────────────────────────
470
542
  return (
471
- <DocumentReview
543
+ <>
544
+ <View style={{ marginBottom: spacing.md }}>{requiredPill(null)}</View>
545
+ <DocumentReview
546
+ mode={camera ? 'scan' : 'upload'}
472
547
  frontUri={frontUri!}
473
548
  backUri={isTwoSided ? backUri : null}
474
549
  aspect={guideAspect}
@@ -494,18 +569,27 @@ export function DocumentCaptureStep(): React.ReactElement {
494
569
  <>
495
570
  {retryInfo && uploading ? (
496
571
  <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginBottom: spacing.sm }}>
497
- {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
572
+ {`Upload failed. Retrying (${retryInfo.attempt}/${retryInfo.total})…`}
498
573
  </MyazaText>
499
574
  ) : null}
500
575
  {uploadError ? (
501
576
  // The error message is shown as a top toast; keep a retry action here.
502
577
  <MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
578
+ ) : captureProblems ? (
579
+ // A side will not read: retake it, or carry on with what was sent.
580
+ <CaptureCheckNotice
581
+ problems={captureProblems}
582
+ uploadOnly={!camera}
583
+ onRetake={retake}
584
+ onContinueAnyway={advance}
585
+ />
503
586
  ) : (
504
587
  <MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
505
588
  )}
506
589
  </>
507
590
  }
508
- />
591
+ />
592
+ </>
509
593
  );
510
594
  }
511
595
 
@@ -1,21 +1,22 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Animated, Easing, Image } from 'react-native';
3
3
 
4
- import { useTheme } from '../components/runtime';
4
+ import { useTheme, useKycConfig } from '../components/runtime';
5
5
  import { Icon } from '../components/Icon';
6
+ import { livenessAvatarUrl } from '../liveness/avatarSource';
6
7
  import type { LivenessChallenge } from '../liveness/types';
7
8
 
8
9
  // Animated GIF avatar demonstrating the requested gesture — the RN mirror of the
9
- // web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile GIFs in a
10
- // circular badge (primary-tinted, like Flutter), sliding up + fading in when the
11
- // challenge changes. RN's <Image> animates GIFs natively.
12
-
13
- const GIFS: Record<LivenessChallenge, number> = {
14
- nod: require('../assets/liveness/Nod.gif'),
15
- turn: require('../assets/liveness/Turn.gif'),
16
- blink: require('../assets/liveness/Blink.gif'),
17
- smile: require('../assets/liveness/Smile.gif'),
18
- };
10
+ // web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile animations in
11
+ // a circular badge (primary-tinted, like Flutter), sliding up + fading in when
12
+ // the challenge changes. RN's <Image> animates GIFs natively.
13
+ //
14
+ // The animations are FETCHED (see liveness/avatarSource) rather than bundled.
15
+ // They were 5.5 MB of this package, in every integrator's app, for a badge the
16
+ // liveness step shows for a few seconds. The flow prefetches all four at open,
17
+ // so by the time this renders the file is normally already on the device; when
18
+ // it is not, `broken` renders the gesture icon exactly as it always did for a
19
+ // failed decode.
19
20
 
20
21
  /** The badge on a tall phone; the step hands a smaller size down on a short one
21
22
  * (lib/livenessLayout), so the gesture stays on screen beside the circle. */
@@ -33,9 +34,13 @@ export function LivenessAvatar({
33
34
  }): React.ReactElement {
34
35
  const SIZE = size;
35
36
  const { colors } = useTheme();
37
+ const { apiKey, devUrl } = useKycConfig();
36
38
  const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
37
39
  const [broken, setBroken] = useState(false);
38
40
  const anim = useRef(new Animated.Value(1)).current;
41
+ // `displayed`, not `challenge`: the badge cross-fades, so the image must keep
42
+ // showing the outgoing gesture until the transition swaps it.
43
+ const uri = livenessAvatarUrl(displayed, apiKey, devUrl);
39
44
 
40
45
  // Slide-up + fade transition when the challenge changes (mirrors Flutter's
41
46
  // AnimatedSwitcher: Offset(0, 0.4) → 0, easeOutCubic, ~350ms).
@@ -66,11 +71,11 @@ export function LivenessAvatar({
66
71
  transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [SIZE * 0.4, 0] }) }],
67
72
  }}
68
73
  >
69
- {broken ? (
74
+ {broken || uri == null ? (
70
75
  <Icon name="scan-face" size={iconSize} color={colors.primary} />
71
76
  ) : (
72
77
  <Image
73
- source={GIFS[displayed]}
78
+ source={{ uri }}
74
79
  style={{ width: '100%', height: '100%' }}
75
80
  resizeMode="cover"
76
81
  onError={() => setBroken(true)}
@@ -14,6 +14,7 @@ import { radius, spacing } from '../config/theme';
14
14
  import { withRetry } from '../services/retry';
15
15
  import { mapToKycError, safeReportError } from '../services/errors';
16
16
  import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
17
+ import { isSelfieBlurry, measureSelfieSharpness } from '../lib/selfie-sharpness';
17
18
  import { KYCError } from '../types/verification';
18
19
  import { useStore } from 'zustand';
19
20
  import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
@@ -184,6 +185,11 @@ export function LivenessStep(): React.ReactElement {
184
185
  uploadSelfieAndVideo,
185
186
  } = upload;
186
187
 
188
+ // The still the review should flag as soft, held BY URI, so a measurement
189
+ // that lands after a retake can never flag the next photo. A notice only,
190
+ // never a gate: see lib/selfie-sharpness.
191
+ const [softSelfieUri, setSoftSelfieUri] = useState<string | null>(null);
192
+
187
193
  // A stored selfie with no uploaded mediaId is an interrupted upload (failed,
188
194
  // or the user left mid-flight): resume it once on mount. Idempotent — with a
189
195
  // mediaId there is nothing to do.
@@ -255,6 +261,11 @@ export function LivenessStep(): React.ReactElement {
255
261
  const raw = `file://${file.filePath}`;
256
262
  const compressed = await compressSelfieImage(raw).catch(() => raw);
257
263
  setSelfieUri(compressed);
264
+ // Measured on the still the applicant is about to see. It never holds
265
+ // the review back: the notice appears when the answer does.
266
+ void measureSelfieSharpness(compressed).then((score) => {
267
+ if (isSelfieBlurry(score)) setSoftSelfieUri(compressed);
268
+ });
258
269
  // Recorded at capture, not at submit: by then the liveness hook is gone
259
270
  // and its flash result with it.
260
271
  setCaptureIntegrity({
@@ -448,6 +459,7 @@ export function LivenessStep(): React.ReactElement {
448
459
  return (
449
460
  <LivenessComplete
450
461
  selfieUri={selfieUri}
462
+ soft={!!selfieUri && softSelfieUri === selfieUri}
451
463
  upload={upload}
452
464
  onRetake={() => {
453
465
  setSelfieUri(null);
@@ -492,6 +504,31 @@ export function LivenessStep(): React.ReactElement {
492
504
  if (permissionDenied) {
493
505
  return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
494
506
  }
507
+ // The model is still arriving. Measured at ~9s on a freshly installed device
508
+ // over fast wifi, and the connections this SDK actually serves are slower, so
509
+ // the head start from opening the flow does not always win the race.
510
+ //
511
+ // Without this the camera opened anyway and `detectFace` returned
512
+ // `faceCount: 0` for every frame, which is indistinguishable from "no face in
513
+ // shot" — so the user was told to position a face that was never going to
514
+ // register, for as long as the download took. That is the exact failure the
515
+ // readiness gate exists to prevent, and MrzScanView already answers it this
516
+ // way; only this screen was missing it.
517
+ //
518
+ // Deliberately AFTER the primer and the permission prompt: those need no
519
+ // model, so letting them run first keeps the download overlapping something
520
+ // the user is already reading. Only the camera itself waits.
521
+ if (modelState === 'preparing') {
522
+ return (
523
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }}>
524
+ <MyazaPulseLoader size={24} />
525
+ <View style={{ height: spacing.md }} />
526
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
527
+ Getting face verification ready. This only happens once.
528
+ </MyazaText>
529
+ </View>
530
+ );
531
+ }
495
532
 
496
533
  if (liveness.phase === 'failed') {
497
534
  return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
@@ -15,6 +15,7 @@ import { MyazaButton } from '../components/MyazaButton';
15
15
  import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
16
16
  import { extractMrz } from '../mrz/extract';
17
17
  import { recognizeMrzLines, MRZ_BAND_FRACTION } from '../mrz/textRecognizer';
18
+ import { useTextModelReady } from '../mrz/useTextModelReady';
18
19
  import type { MrzScan } from '../mrz/parse';
19
20
 
20
21
  // ---------------------------------------------------------------------------
@@ -45,6 +46,11 @@ export function MrzScanView({
45
46
  const device = useCameraDevice('back');
46
47
  const { hasPermission, requestPermission } = useCameraPermission();
47
48
  const [denied, setDenied] = useState(false);
49
+ // Android fetches the text model through Play Services rather than bundling
50
+ // it. A recogniser that cannot run reports exactly what a blank page reports
51
+ // — no lines — so without this the user would aim at their passport while a
52
+ // scanner that can never read sat there pulsing. iOS is always ready.
53
+ const modelState = useTextModelReady();
48
54
 
49
55
  // A ref, not state: the worklet callback lands on the JS thread after the
50
56
  // frame that found the MRZ, and a stale render would let a second frame
@@ -113,6 +119,33 @@ export function MrzScanView({
113
119
  );
114
120
  }
115
121
 
122
+ // Worded like the camera-denied case above, and for the same reason: the chip
123
+ // is optional, so the honest thing is to say the code cannot be read and let
124
+ // the user carry on rather than leave them waiting on something that will not
125
+ // arrive.
126
+ if (modelState === 'unavailable') {
127
+ return (
128
+ <Centered>
129
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
130
+ The text reader could not be set up on this device, so the printed code
131
+ cannot be read. You can still continue without the chip.
132
+ </MyazaText>
133
+ </Centered>
134
+ );
135
+ }
136
+
137
+ if (modelState === 'preparing') {
138
+ return (
139
+ <Centered>
140
+ <MyazaPulseLoader size={24} />
141
+ <View style={{ height: spacing.md }} />
142
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
143
+ Getting the text reader ready. This only happens once.
144
+ </MyazaText>
145
+ </Centered>
146
+ );
147
+ }
148
+
116
149
  return (
117
150
  <View
118
151
  style={{