@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
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../../config/theme';
5
+ import { MyazaAlert } from '../../components/MyazaAlert';
6
+ import { MyazaButton } from '../../components/MyazaButton';
7
+ import { documentReviewCopy } from '../../components/documentReviewCopy';
8
+ import {
9
+ CAPTURE_CHECK_CONTINUE_ANYWAY,
10
+ CAPTURE_CHECK_TITLE,
11
+ captureProblemMessage,
12
+ captureRetakeLabel,
13
+ captureRetakeSides,
14
+ type CaptureProblem,
15
+ } from '../../lib/documentCaptureCheck';
16
+ import type { DocumentCaptureSide } from '../../services/api-types';
17
+
18
+ // ─── The review footer when a document photo will not read ───────────────────
19
+ //
20
+ // Shown in place of Continue after the server's capture check found no face on
21
+ // the printed photo or no readable barcode (lib/documentCaptureCheck). The
22
+ // words come from that module so they stay in lockstep with the web and
23
+ // Flutter SDKs.
24
+ //
25
+ // A notice, never a gate: the detectors can miss, so "Continue anyway" is
26
+ // always offered and moves on with the photos already uploaded. The first
27
+ // retake is the primary action because a retake is the likelier fix.
28
+
29
+ export function CaptureCheckNotice({
30
+ problems,
31
+ uploadOnly,
32
+ onRetake,
33
+ onContinueAnyway,
34
+ }: {
35
+ problems: readonly CaptureProblem[];
36
+ /** The sides were picked from the device, so they are replaced, not retaken. */
37
+ uploadOnly: boolean;
38
+ onRetake: (side: DocumentCaptureSide) => void;
39
+ onContinueAnyway: () => void;
40
+ }): React.ReactElement {
41
+ // Once per kind: two sides with the same problem need one sentence, not two.
42
+ const messages = [...new Set(problems.map((p) => captureProblemMessage(p.kind)))];
43
+ const sides = captureRetakeSides(problems);
44
+ const copy = documentReviewCopy(uploadOnly ? 'upload' : 'scan', true);
45
+
46
+ return (
47
+ <View style={{ gap: spacing.sm }}>
48
+ {/* Grouped and announced: the notice replaces the button the applicant
49
+ just pressed, so a screen reader has to say why nothing moved on. */}
50
+ <View
51
+ accessible
52
+ accessibilityRole="alert"
53
+ accessibilityLiveRegion="polite"
54
+ accessibilityLabel={`${CAPTURE_CHECK_TITLE}. ${messages.join(' ')}`}
55
+ >
56
+ <MyazaAlert variant="warning" title={CAPTURE_CHECK_TITLE} message={messages.join('\n\n')} />
57
+ </View>
58
+ {sides.map((side, i) => (
59
+ <MyazaButton
60
+ key={side}
61
+ label={captureRetakeLabel(side, uploadOnly)}
62
+ variant={i === 0 ? 'primary' : 'outline'}
63
+ leadingIcon="refresh"
64
+ accessibilityLabel={copy.redoAccessibility(side === 'front' ? 'Front' : 'Back')}
65
+ onPress={() => onRetake(side)}
66
+ />
67
+ ))}
68
+ <MyazaButton
69
+ label={CAPTURE_CHECK_CONTINUE_ANYWAY}
70
+ variant="outline"
71
+ accessibilityLabel={CAPTURE_CHECK_CONTINUE_ANYWAY}
72
+ accessibilityHint="Carries on with these photos as they are"
73
+ onPress={onContinueAnyway}
74
+ />
75
+ </View>
76
+ );
77
+ }
@@ -0,0 +1,177 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { MyazaText } from '../../components/Typography';
7
+ import { Icon, type IconName } from '../../components/Icon';
8
+ import { DashedBorder } from '../../components/DashedBorder';
9
+ import type { DocumentCapturePhase } from '../../store/state';
10
+ import { RequiredPill } from './RequiredPill';
11
+
12
+ // ─── Document capture without the camera ──────────────────────────────────────
13
+ //
14
+ // One side of the document when the workflow switches scanning off
15
+ // (`allowDocumentScan: false`): a photo chosen from the device, never a camera
16
+ // shot. It renders in place of the camera gates, and nothing here touches
17
+ // VisionCamera, so an upload-only flow never asks for camera access and never
18
+ // records a capture video.
19
+ //
20
+ // Only the ASK differs. What happens after the pick is the step's own and shared
21
+ // with the camera path: the interactive cropper, the compress, the MRZ read off
22
+ // the front, the front preview and the review.
23
+
24
+ /** The sheet header for an upload-only capture phase (see documentCaptureMeta). */
25
+ export function documentUploadMeta(
26
+ phase: DocumentCapturePhase,
27
+ documentLabel: string,
28
+ ): { title: string; description: string } {
29
+ switch (phase) {
30
+ case 'front':
31
+ return {
32
+ title: `Upload Your ${documentLabel}`,
33
+ description: `Choose a clear photo of your ${documentLabel} from your device.`,
34
+ };
35
+ case 'front-preview':
36
+ return {
37
+ title: 'Front Side Added',
38
+ description: 'Looks good? Tap Next to add a photo of the back.',
39
+ };
40
+ case 'back':
41
+ return {
42
+ title: 'Upload Back Side',
43
+ description: `Now choose a photo of the back of your ${documentLabel}.`,
44
+ };
45
+ case 'review':
46
+ default:
47
+ return {
48
+ title: `Review Your ${documentLabel}`,
49
+ description: 'Tap Continue to upload and submit your document.',
50
+ };
51
+ }
52
+ }
53
+
54
+ const TIPS: ReadonlyArray<{ icon: IconName; label: string }> = [
55
+ { icon: 'id-card', label: 'The whole document in view, all four corners' },
56
+ { icon: 'sun', label: 'Sharp and evenly lit, with no glare' },
57
+ ];
58
+
59
+ export interface UploadPhaseProps {
60
+ isBack: boolean;
61
+ documentLabel: string;
62
+ isTwoSided: boolean;
63
+ /** A picked photo is being cropped and compressed. */
64
+ busy: boolean;
65
+ onUpload: () => void;
66
+ }
67
+
68
+ export function UploadPhase(p: UploadPhaseProps): React.ReactElement {
69
+ const { colors } = useTheme();
70
+ const subject = !p.isTwoSided ? `your ${p.documentLabel}` : p.isBack ? 'the back' : 'the front';
71
+ const ask = `Add a photo of ${subject}`;
72
+ const choose = () => {
73
+ if (!p.busy) p.onUpload();
74
+ };
75
+
76
+ return (
77
+ <View>
78
+ <RequiredPill
79
+ documentLabel={p.documentLabel}
80
+ sideBadge={p.isTwoSided ? (p.isBack ? 'Back Side' : 'Front Side') : undefined}
81
+ stepLabel={p.isTwoSided ? (p.isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
82
+ />
83
+ {p.isBack ? (
84
+ <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
85
+ <Icon name="credit-card" size={14} color={colors.primary} />
86
+ <View style={{ width: 4 }} />
87
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
88
+ Turn the card over and add a photo of the other side
89
+ </MyazaText>
90
+ </View>
91
+ ) : null}
92
+ <View style={{ height: spacing.md }} />
93
+
94
+ {/* The whole card is the target and there is no button beside it: on a
95
+ screen whose only job is to open the photo picker, the card is where
96
+ the thumb goes. A press deepens the fill and solidifies the border;
97
+ the primary line at the bottom says what a tap does. */}
98
+ <Pressable
99
+ testID={`kyc.document.upload.${p.isBack ? 'back' : 'front'}`}
100
+ onPress={choose}
101
+ disabled={p.busy}
102
+ accessibilityRole="button"
103
+ accessibilityLabel={p.busy ? 'Preparing your photo' : ask}
104
+ accessibilityHint="Opens your photos"
105
+ accessibilityState={{ busy: p.busy, disabled: p.busy }}
106
+ style={({ pressed }) => ({
107
+ alignItems: 'center',
108
+ paddingVertical: spacing.xl,
109
+ paddingHorizontal: spacing.lg,
110
+ borderRadius: radius.md,
111
+ backgroundColor: pressed ? `${colors.primary}1A` : `${colors.primary}0D`,
112
+ transform: [{ scale: pressed ? 0.99 : 1 }],
113
+ })}
114
+ >
115
+ {({ pressed }) => (
116
+ <>
117
+ <DashedBorder
118
+ color={pressed ? colors.primary : `${colors.primary}66`}
119
+ radius={radius.md}
120
+ strokeWidth={1.5}
121
+ />
122
+ <View
123
+ style={{
124
+ width: 64,
125
+ height: 64,
126
+ borderRadius: radius.full,
127
+ backgroundColor: `${colors.primary}1A`,
128
+ alignItems: 'center',
129
+ justifyContent: 'center',
130
+ }}
131
+ >
132
+ {p.busy ? (
133
+ <ActivityIndicator size="small" color={colors.primary} />
134
+ ) : (
135
+ <Icon name="image" size={30} color={colors.primary} />
136
+ )}
137
+ </View>
138
+ <View style={{ height: spacing.md }} />
139
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
140
+ {p.busy ? 'Preparing your photo…' : ask}
141
+ </MyazaText>
142
+ <View style={{ height: spacing.xs }} />
143
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
144
+ Choose a clear photo from your device. You can crop it on the next screen.
145
+ </MyazaText>
146
+ <View style={{ height: spacing.md }} />
147
+ {/* Stays in the layout while busy so the card keeps its height. */}
148
+ <View
149
+ style={{ flexDirection: 'row', alignItems: 'center', opacity: p.busy ? 0 : 1 }}
150
+ accessibilityElementsHidden
151
+ importantForAccessibility="no-hide-descendants"
152
+ >
153
+ <Icon name="upload" size={16} color={colors.primary} />
154
+ <View style={{ width: spacing.xs }} />
155
+ <MyazaText variant="label" color={colors.primary} style={{ fontWeight: '600' }}>
156
+ Tap to choose a photo
157
+ </MyazaText>
158
+ <View style={{ width: 2 }} />
159
+ <Icon name="chevron-right" size={16} color={colors.primary} />
160
+ </View>
161
+ </>
162
+ )}
163
+ </Pressable>
164
+
165
+ <View style={{ height: spacing.md }} />
166
+ {TIPS.map((tip) => (
167
+ <View key={tip.label} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }}>
168
+ <Icon name={tip.icon} size={16} color={colors.textMuted} />
169
+ <View style={{ width: spacing.sm }} />
170
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ flexShrink: 1 }}>
171
+ {tip.label}
172
+ </MyazaText>
173
+ </View>
174
+ ))}
175
+ </View>
176
+ );
177
+ }
@@ -0,0 +1,109 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
3
+
4
+ import { safeReportError } from '../../services/errors';
5
+ import { KYCError } from '../../types/verification';
6
+
7
+ // The document camera's permission and hardware state, lifted out of
8
+ // DocumentCaptureStep so that only a workflow which SCANS ever calls
9
+ // VisionCamera's hooks. An upload-only workflow (`allowDocumentScan: false`)
10
+ // never mounts the component that uses this, so it never reads the camera
11
+ // permission, never enumerates devices and never prompts. The logic is the
12
+ // step's own, moved rather than changed.
13
+
14
+ export type DocumentCameraPermission = 'priming' | 'requesting' | 'granted' | 'denied';
15
+
16
+ export interface DocumentCamera {
17
+ perm: DocumentCameraPermission;
18
+ /** A back camera exists (false on a simulator, which has none). */
19
+ hasDevice: boolean;
20
+ /** No camera hardware at all, once the device list has had a moment. */
21
+ cameraUnavailable: boolean;
22
+ /** The "Allow camera access" primer, shown before the OS prompt. */
23
+ showPrimer: boolean;
24
+ /** A real camera exists but the OS blocked access. */
25
+ permissionDenied: boolean;
26
+ /** Fire the real OS prompt: the primer's "Grant access", or a retry. */
27
+ requestAccess: () => void;
28
+ }
29
+
30
+ export function useDocumentCamera(onError: ((error: KYCError) => void) | undefined): DocumentCamera {
31
+ // ── Camera permission ──────────────────────────────────────────────────────
32
+ // `perm` is derived from the ASYNC requestPermission result, not synchronously
33
+ // from `hasPermission` — otherwise the brief window while the OS prompt is open
34
+ // (hasPermission still false) would read as "denied" and fire onError early.
35
+ // 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
36
+ // (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
37
+ // "Grant access".
38
+ const { hasPermission, requestPermission } = useCameraPermission();
39
+ const [perm, setPerm] = useState<DocumentCameraPermission>(hasPermission ? 'granted' : 'priming');
40
+ const permReportedRef = useRef(false);
41
+
42
+ // ── Camera availability ─────────────────────────────────────────────────────
43
+ // Even with permission granted, there may be no usable back camera (the iOS/
44
+ // Android simulator has none; a real device may fail to init). Give the device
45
+ // list a moment to resolve, then surface a proper "Camera not available" error
46
+ // with an upload fallback — on every iOS version (glass or not) and Android.
47
+ // Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
48
+ // Android CameraManager list hardware regardless), so `!device` reliably means
49
+ // "no back-camera hardware" — true on every simulator. That's a different state
50
+ // from "permission denied": no hardware → nothing to grant.
51
+ const device = useCameraDevice('back');
52
+ const [cameraGrace, setCameraGrace] = useState(false);
53
+ useEffect(() => {
54
+ const t = setTimeout(() => setCameraGrace(true), 1500);
55
+ return () => clearTimeout(t);
56
+ }, []);
57
+ // No camera hardware at all → "Camera not available" (regardless of what the
58
+ // permission API says — on a camera-less sim it may even report denied).
59
+ const cameraUnavailable = cameraGrace && !device;
60
+
61
+ // Reflect an externally-granted permission.
62
+ useEffect(() => {
63
+ if (hasPermission) setPerm('granted');
64
+ }, [hasPermission]);
65
+
66
+ // Fire the real OS prompt only after the user taps "Grant access" (or retry).
67
+ useEffect(() => {
68
+ if (perm !== 'requesting') return;
69
+ let cancelled = false;
70
+ void (async () => {
71
+ const granted = await requestPermission();
72
+ if (!cancelled) setPerm(granted ? 'granted' : 'denied');
73
+ })();
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [perm, requestPermission]);
78
+
79
+ // A *genuine* permission denial requires a camera to exist but be blocked. On a
80
+ // camera-less sim the OS may report denied — that's "not available", not a
81
+ // permission problem, so don't treat it as denied or report onError there.
82
+ const permissionDenied = perm === 'denied' && !!device;
83
+ useEffect(() => {
84
+ if (permissionDenied && !permReportedRef.current) {
85
+ permReportedRef.current = true;
86
+ safeReportError(
87
+ onError,
88
+ new KYCError(
89
+ 'camera_permission_denied',
90
+ 'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
91
+ ),
92
+ );
93
+ }
94
+ if (!permissionDenied) permReportedRef.current = false;
95
+ }, [permissionDenied, onError]);
96
+
97
+ const requestAccess = useCallback(() => {
98
+ setPerm('requesting');
99
+ }, []);
100
+
101
+ return {
102
+ perm,
103
+ hasDevice: !!device,
104
+ cameraUnavailable,
105
+ showPrimer: perm === 'priming' && !!device,
106
+ permissionDenied,
107
+ requestAccess,
108
+ };
109
+ }
@@ -23,12 +23,15 @@ import type { LivenessFailureReason } from '../../liveness/types';
23
23
 
24
24
  export function LivenessComplete({
25
25
  selfieUri,
26
+ soft = false,
26
27
  upload,
27
28
  onRetake,
28
29
  onContinue,
29
30
  }: {
30
31
  /** Null on a restored session: the mediaId survived, the local file did not. */
31
32
  selfieUri: string | null;
33
+ /** The still reads out of focus. A notice on the preview, never a gate. */
34
+ soft?: boolean;
32
35
  upload: SelfieUpload;
33
36
  onRetake: () => void;
34
37
  onContinue: () => void;
@@ -38,7 +41,7 @@ export function LivenessComplete({
38
41
  upload;
39
42
  return (
40
43
  <View>
41
- <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
44
+ <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} soft={soft} />
42
45
  {retryInfo && uploading ? (
43
46
  <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
44
47
  {`Upload failed, retrying (${retryInfo.attempt}/${retryInfo.total})`}
@@ -11,7 +11,16 @@ import { StyleAbsFill } from './constants';
11
11
  // The captured selfie, shown back while it uploads.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- export function SelfiePreview({ uri, uploading }: { uri: string | null; uploading?: boolean }): React.ReactElement {
14
+ export function SelfiePreview({
15
+ uri,
16
+ uploading,
17
+ soft = false,
18
+ }: {
19
+ uri: string | null;
20
+ uploading?: boolean;
21
+ /** The still reads out of focus (lib/selfie-sharpness). */
22
+ soft?: boolean;
23
+ }): React.ReactElement {
15
24
  const { colors } = useTheme();
16
25
  const S = 200;
17
26
  return (
@@ -37,11 +46,17 @@ export function SelfiePreview({ uri, uploading }: { uri: string | null; uploadin
37
46
  ) : null}
38
47
  </View>
39
48
  <View style={{ height: spacing.md }} />
40
- <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
41
- Looking good!
49
+ {/* The heading is the review's verdict on the photo, so a soft still must
50
+ not read "Looking good!". Continue stays available either way: the
51
+ floor is uncalibrated, and a wrong one should cost a sentence, never
52
+ a retake loop. */}
53
+ <MyazaText variant="heading3" color={soft ? colors.warning : undefined} style={{ textAlign: 'center' }}>
54
+ {soft ? 'This photo looks blurry' : 'Looking good!'}
42
55
  </MyazaText>
43
56
  <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
44
- Tap Continue to submit, or Retake to try again.
57
+ {soft
58
+ ? 'For the best chance of a match, retake it holding the phone steady until your face is sharp.'
59
+ : 'Tap Continue to submit, or Retake to try again.'}
45
60
  </MyazaText>
46
61
  </View>
47
62
  );
@@ -43,6 +43,34 @@ export interface UploadResponse {
43
43
  mediaId: string;
44
44
  }
45
45
 
46
+ /** Which side of a document a capture check is about. */
47
+ export type DocumentCaptureSide = 'front' | 'back';
48
+
49
+ /**
50
+ * Ask whether an uploaded document photo will be readable, using the same
51
+ * detectors the server later decides the verification with.
52
+ */
53
+ export interface DocumentCaptureCheckRequest {
54
+ mediaId: string;
55
+ side: DocumentCaptureSide;
56
+ country: string;
57
+ idType: string;
58
+ /** The workflow the submission will carry, when it carries one. */
59
+ workflowId?: string;
60
+ /** The attempt session from /session/start, when the flow has one. */
61
+ sessionId?: string;
62
+ }
63
+
64
+ /**
65
+ * `false` = looked and found nothing (ask for a retake); `true` = fine;
66
+ * `null` = not applicable, or the server could not look (treat as fine).
67
+ */
68
+ export interface DocumentCaptureCheckResponse {
69
+ side: DocumentCaptureSide;
70
+ face: boolean | null;
71
+ barcode: boolean | null;
72
+ }
73
+
46
74
  export interface KeyPersonInvite {
47
75
  keyPersonId: string;
48
76
  name: string;
@@ -408,5 +436,3 @@ export interface BusinessSearchResponse {
408
436
  export interface BusinessRegionsResponse {
409
437
  regions: { code: string; name: string }[];
410
438
  }
411
-
412
- export * from './api-types-biometric';
@@ -6,6 +6,8 @@ import type {
6
6
  BusinessSearchResponse,
7
7
  BusinessSelectResponse,
8
8
  ContactCheckResponse,
9
+ DocumentCaptureCheckRequest,
10
+ DocumentCaptureCheckResponse,
9
11
  NfcChallengeResponse,
10
12
  ContactSendResponse,
11
13
  HealthResponse,
@@ -22,7 +24,6 @@ import type {
22
24
  VerifyResponse,
23
25
  WorkflowResolutionResponse,
24
26
  } from './api-types';
25
- import { biometricCalls } from './api-biometric';
26
27
 
27
28
  // The HTTP contract lives in ./api-types and is re-exported here, so importers
28
29
  // keep a single entry point for both the client and the shapes it exchanges.
@@ -167,8 +168,6 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
167
168
  }
168
169
 
169
170
  return {
170
- ...biometricCalls(request),
171
-
172
171
  /**
173
172
  * Single multipart upload: the local file is POSTed to our server, which
174
173
  * stores it and returns the `mediaId` referenced later by /verify.
@@ -209,6 +208,23 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
209
208
  return mediaId;
210
209
  },
211
210
 
211
+ /**
212
+ * Ask whether an uploaded document side will be readable: a face on the
213
+ * printed photo, a barcode that decodes. Best-effort by contract at the
214
+ * call site (lib/documentCaptureCheck runCaptureChecks): a failure or a
215
+ * timeout reads as "no problem" and never blocks the flow.
216
+ */
217
+ async checkDocumentCapture(
218
+ body: DocumentCaptureCheckRequest,
219
+ signal?: AbortSignal,
220
+ ): Promise<DocumentCaptureCheckResponse> {
221
+ return request<DocumentCaptureCheckResponse>('/document-capture/check', {
222
+ method: 'POST',
223
+ body: JSON.stringify(body),
224
+ ...(signal ? { signal } : {}),
225
+ });
226
+ },
227
+
212
228
  async verify(body: VerifyRequest): Promise<VerifyResponse> {
213
229
  return request<VerifyResponse>('/verify', {
214
230
  method: 'POST',
@@ -15,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
15
15
  * Single source of truth for the SDK version — also used by `services/api.ts`
16
16
  * for the `X-SDK-Version` header. Keep in sync with `package.json`.
17
17
  */
18
- export const SDK_VERSION = '2.6.0';
18
+ export const SDK_VERSION = '3.0.0';
19
19
 
20
20
  export interface ReactNativeDeviceMetadata {
21
21
  sdkType: 'react-native';
@@ -1,4 +1,3 @@
1
- import { Image } from 'react-native';
2
1
  import * as ImageManipulator from 'expo-image-manipulator';
3
2
  import { Video } from 'react-native-compressor';
4
3
 
@@ -41,10 +40,29 @@ export async function compressVideo(uri: string): Promise<string> {
41
40
  // • SELFIE — moderate: JPEG q0.8, capped to ~1280 px.
42
41
  // Runs natively via expo-image-manipulator (off the JS thread).
43
42
 
44
- export function imageSize(uri: string): Promise<{ width: number; height: number }> {
45
- return new Promise((resolve, reject) =>
46
- Image.getSize(uri, (width, height) => resolve({ width, height }), reject),
47
- );
43
+ /**
44
+ * The image's TRUE pixel dimensions.
45
+ *
46
+ * Measured through expo-image-manipulator — the same native module that does
47
+ * the cropping — because the two must agree, and they did not.
48
+ *
49
+ * This used `Image.getSize`, which on Android reports DP (pixels ÷ display
50
+ * density), while ImageManipulator crops in real pixels. On a density-2 phone
51
+ * a 3048x4064 photo measured 1524x2032, so a crop rect computed as "centred"
52
+ * was applied at half scale and landed in the UPPER-LEFT QUADRANT of the real
53
+ * image. Every Android document capture cropped the wrong region — the card
54
+ * the applicant framed was simply not in the stored photo. iOS was unaffected
55
+ * (getSize returns pixels there), which is how an iPhone-led test history
56
+ * never saw it. Found on a TECNO KM5 (density 320 → scale 2.0), 2026-09-20:
57
+ * reported 1524x2032 against a true 3048x4064, a factor of exactly 2.
58
+ *
59
+ * Correcting by `PixelRatio.get()` would also work on today's devices, but it
60
+ * re-states the cropper's units somewhere else and leaves the same class of
61
+ * bug one refactor away. Asking the cropper itself cannot drift from it.
62
+ */
63
+ export async function imageSize(uri: string): Promise<{ width: number; height: number }> {
64
+ const ref = await ImageManipulator.ImageManipulator.manipulate(uri).renderAsync();
65
+ return { width: ref.width, height: ref.height };
48
66
  }
49
67
 
50
68
  /**
@@ -45,4 +45,33 @@ export interface MyazaTextRecognizer extends HybridObject<{ ios: 'swift'; androi
45
45
  * crop as the frame version.
46
46
  */
47
47
  recognizeTextInImage(uri: string, bottomFraction: number): Promise<TextResult>;
48
+
49
+ /**
50
+ * Whether the recogniser can run RIGHT NOW.
51
+ *
52
+ * Android fetches ML Kit's text model through Google Play Services rather
53
+ * than bundling it (see android/build.gradle), so there is a window — first
54
+ * launch, or a device with no GMS at all — where recognition cannot work.
55
+ * This MUST be checked before the camera opens: `recognizeText` can only
56
+ * answer in `TextResult`, where "model missing" and "no text in frame" are
57
+ * both an empty `lines` array, so relying on it would leave auto-capture
58
+ * silently never firing and the MRZ never producing a chip key, with nothing
59
+ * shown to explain why.
60
+ *
61
+ * iOS is always true — Apple Vision is a system framework with nothing to
62
+ * fetch.
63
+ */
64
+ isModelReady(): boolean;
65
+
66
+ /**
67
+ * Ask Play Services to download the model, resolving `true` once it is usable
68
+ * and `false` if it cannot be obtained (no GMS, no network, user declined).
69
+ *
70
+ * Call this EARLY — the SDK primes it at flow start, so the download overlaps
71
+ * the consent and ID-type screens and the model is warm by the time the
72
+ * document step runs.
73
+ *
74
+ * Safe to call repeatedly; resolves immediately when already ready.
75
+ */
76
+ prepareModel(): Promise<boolean>;
48
77
  }
@@ -62,6 +62,7 @@ import { IDLE_SELFIE_UPLOAD } from '../lib/selfie-upload-wait';
62
62
  import { resetBusinessCheck, runBusinessCheck } from './businessCheck';
63
63
  import { startAttemptSession, watchSessionProgress } from './session';
64
64
  import { applicantMediaCaptured, buildApplicantVerifyRequest } from './submitApplicant';
65
+ import { keptIdType } from '../lib/resubmit';
65
66
 
66
67
  export * from './state';
67
68
  export {
@@ -102,7 +103,10 @@ export function createKycStore(
102
103
  businessCheck: { ...EMPTY_BUSINESS_CHECK },
103
104
  selectedCountry: null,
104
105
  countryAutoPicked: false,
105
- selectedIdType: null,
106
+ // A send-back that did not ask for the ID keeps the original one: the
107
+ // ID steps are narrowed out, so the kept ID is selected here instead.
108
+ // A restored session only overwrites it with a saved pick.
109
+ selectedIdType: keptIdType(config.resubmit) as KycState['selectedIdType'],
106
110
  idNumber: null,
107
111
  multiIdSlotIndex: 0,
108
112
  multiIdSlots: [],
@@ -661,7 +665,9 @@ export function createKycStore(
661
665
  currentStep: opening,
662
666
  selectedCountry: null,
663
667
  countryAutoPicked: false,
664
- selectedIdType: null,
668
+ // Same kept ID as at creation: the narrowed flow has no picker to
669
+ // choose it again.
670
+ selectedIdType: keptIdType(config.resubmit) as KycState['selectedIdType'],
665
671
  idNumber: null,
666
672
  multiIdSlotIndex: 0,
667
673
  multiIdSlots: [],
@@ -142,6 +142,12 @@ export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
142
142
  * mirroring the web SDK.
143
143
  */
144
144
  businessName?: string;
145
+ /**
146
+ * The applicant's email, when you already hold it. Not asked for in the flow:
147
+ * it is submitted so your organisation can have the applicant emailed about a
148
+ * decision, for example once their verification is approved.
149
+ */
150
+ email?: string;
145
151
  };
146
152
 
147
153
  /** Enable the live-selfie capture step. */
@@ -157,6 +163,15 @@ export interface MyazaKYCConfig<C extends SupportedCountry = SupportedCountry> {
157
163
  */
158
164
  allowDocumentUpload?: boolean;
159
165
 
166
+ /**
167
+ * Allow photographing the document with the live camera. Default `true`.
168
+ * When `false`, the document step never asks for camera access: the
169
+ * applicant chooses a photo of each side from the device instead. At least
170
+ * one of this and `allowDocumentUpload` stays on, so a config that switches
171
+ * both off keeps the camera.
172
+ */
173
+ allowDocumentScan?: boolean;
174
+
160
175
  /** Enable liveness detection during selfie capture. Default `true`. */
161
176
  enableLiveness?: boolean;
162
177