@myazahq/kyc-sdk-react-native 2.6.0 → 3.1.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 (104) 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 +24 -14
  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/GlassIconButton.tsx +2 -5
  36. package/src/components/Icon.tsx +17 -13
  37. package/src/components/KycFlow.tsx +10 -2
  38. package/src/components/KycSheet.tsx +0 -1
  39. package/src/components/MyazaButton.tsx +9 -0
  40. package/src/components/RequiredDocumentPill.tsx +114 -0
  41. package/src/components/StepHeader.tsx +23 -3
  42. package/src/components/StepView.tsx +3 -0
  43. package/src/components/documentReviewCopy.ts +39 -0
  44. package/src/components/fonts.ts +14 -17
  45. package/src/components/icons/glyphs.ts +35 -0
  46. package/src/components/icons/index.ts +3 -0
  47. package/src/components/icons/map.ts +165 -0
  48. package/src/components/icons/names.ts +86 -0
  49. package/src/components/stepHeaderMeta.tsx +21 -1
  50. package/src/config/documentCaptureMethods.ts +31 -0
  51. package/src/config/stepOrder.ts +20 -5
  52. package/src/config/supportingDocuments.ts +131 -0
  53. package/src/config/theme.ts +29 -3
  54. package/src/config/workflowMerge.ts +15 -0
  55. package/src/index.ts +5 -10
  56. package/src/lib/documentCaptureCheck.ts +136 -0
  57. package/src/lib/model-ready.ts +82 -0
  58. package/src/lib/prime-models.ts +50 -0
  59. package/src/lib/resubmit.ts +36 -7
  60. package/src/lib/selfie-sharpness.ts +211 -0
  61. package/src/lib/supportingDocumentsIntro.ts +35 -0
  62. package/src/liveness/avatarSource.ts +59 -0
  63. package/src/liveness/useModelReady.ts +9 -55
  64. package/src/mrz/extract.ts +71 -4
  65. package/src/mrz/textRecognizer.ts +36 -0
  66. package/src/mrz/useTextModelReady.ts +21 -0
  67. package/src/screens/BusinessDocumentSlot.tsx +37 -8
  68. package/src/screens/DocumentCaptureStep.tsx +177 -93
  69. package/src/screens/LivenessAvatar.tsx +18 -13
  70. package/src/screens/LivenessStep.tsx +37 -0
  71. package/src/screens/MrzScanView.tsx +33 -0
  72. package/src/screens/SupportingDocumentCard.tsx +134 -0
  73. package/src/screens/SupportingDocumentsStep.tsx +203 -0
  74. package/src/screens/consent/model.ts +22 -5
  75. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  76. package/src/screens/document/UploadPhase.tsx +177 -0
  77. package/src/screens/document/useDocumentCamera.ts +109 -0
  78. package/src/screens/liveness/CaptureRing.tsx +31 -17
  79. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  80. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  81. package/src/screens/supportingDocumentParts.tsx +126 -0
  82. package/src/screens/useBusinessDocumentAttach.ts +20 -9
  83. package/src/services/api-types.ts +31 -2
  84. package/src/services/api.ts +19 -3
  85. package/src/services/deviceMetadata.ts +1 -1
  86. package/src/services/mediaCompress.ts +23 -5
  87. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  88. package/src/store/derive.ts +11 -0
  89. package/src/store/kycStore.ts +23 -2
  90. package/src/store/session.ts +18 -0
  91. package/src/store/state.ts +26 -0
  92. package/src/store/submit.ts +11 -0
  93. package/src/types/config.ts +25 -0
  94. package/src/types/workflow.ts +39 -0
  95. package/src/MyazaBiometricAuth.tsx +0 -119
  96. package/src/assets/liveness/Blink.gif +0 -0
  97. package/src/assets/liveness/Nod.gif +0 -0
  98. package/src/assets/liveness/Smile.gif +0 -0
  99. package/src/assets/liveness/Turn.gif +0 -0
  100. package/src/components/icon-map.ts +0 -176
  101. package/src/lib/biometric-auth.ts +0 -52
  102. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  103. package/src/services/api-biometric.ts +0 -37
  104. package/src/services/api-types-biometric.ts +0 -41
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
- import Svg, { Circle } from 'react-native-svg';
2
+ import Svg, { Circle, G } from 'react-native-svg';
3
3
  import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
4
4
 
5
5
  // A single line traced around the camera circle's edge for the length of the
@@ -69,23 +69,37 @@ export function CaptureRing({
69
69
  style={{ position: 'absolute', top: 0, left: 0 }}
70
70
  width={size}
71
71
  height={size}
72
- // Static origin, not motion: the arc starts at twelve o'clock.
73
- rotation={-90}
74
- originX={size / 2}
75
- originY={size / 2}
76
72
  >
77
- <Circle
78
- ref={ref}
79
- cx={size / 2}
80
- cy={size / 2}
81
- r={r}
82
- fill="none"
83
- stroke={color}
84
- strokeWidth={STROKE}
85
- strokeLinecap="butt"
86
- strokeDasharray={[circumference]}
87
- strokeDashoffset={circumference}
88
- />
73
+ {/* Static origin, not motion: the arc starts AND closes at twelve
74
+ o'clock, matching Flutter's `drawArc(rect, -pi / 2, ...)`.
75
+
76
+ The rotation sits on a <G>, not on the root <Svg>. react-native-svg
77
+ silently drops transform props on the root: its render applies a
78
+ transform only `if (transform)` — a `rotation`/`originX`/`originY`
79
+ triple leaves that undefined, so the branch never runs — and the
80
+ inner group it wraps children in is built from style/fill/stroke
81
+ props alone, so nothing forwards them there either. `SvgProps
82
+ extends GProps`, so the compiler accepts it and the arc quietly
83
+ starts at three o'clock, which is where the web SDK's CSS
84
+ `-rotate-90` would have put it had CSS applied here.
85
+
86
+ It is also deliberately not on the <Circle>: that node takes a
87
+ setNativeProps write every frame, and the transform has no business
88
+ sharing a node with the animation. */}
89
+ <G rotation={-90} originX={size / 2} originY={size / 2}>
90
+ <Circle
91
+ ref={ref}
92
+ cx={size / 2}
93
+ cy={size / 2}
94
+ r={r}
95
+ fill="none"
96
+ stroke={color}
97
+ strokeWidth={STROKE}
98
+ strokeLinecap="butt"
99
+ strokeDasharray={[circumference]}
100
+ strokeDashoffset={circumference}
101
+ />
102
+ </G>
89
103
  </Svg>
90
104
  );
91
105
  }
@@ -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
  );
@@ -0,0 +1,126 @@
1
+ import React from 'react';
2
+ import { 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 } from '../components/Icon';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // The pieces of a supporting-document card.
11
+ //
12
+ // Split out of the card itself only for the file-length rule; they are that
13
+ // card's own furniture and nothing else builds them. The Flutter SDK splits
14
+ // the same three into supporting_document_parts.dart, for the same reason.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const MARKER = 28;
18
+
19
+ /** A count while the document is outstanding, and a state anybody can read once
20
+ * it is not. One document needs no number, so it wears a document glyph. */
21
+ export function DocumentMarker({
22
+ done,
23
+ position,
24
+ total,
25
+ }: {
26
+ done: boolean;
27
+ position: number;
28
+ total: number;
29
+ }): React.ReactElement {
30
+ const { colors } = useTheme();
31
+ return (
32
+ <View
33
+ style={{
34
+ width: MARKER,
35
+ height: MARKER,
36
+ borderRadius: radius.full,
37
+ alignItems: 'center',
38
+ justifyContent: 'center',
39
+ backgroundColor: done ? colors.primary : colors.primary100,
40
+ }}
41
+ >
42
+ {done ? (
43
+ <Icon name="check" size={16} color={colors.onPrimary} />
44
+ ) : total > 1 ? (
45
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
46
+ {String(position)}
47
+ </MyazaText>
48
+ ) : (
49
+ <Icon name="file-text" size={14} color={colors.primary} />
50
+ )}
51
+ </View>
52
+ );
53
+ }
54
+
55
+ /** Required or optional, in a word as well as a colour. */
56
+ export function DocumentStatePill({ required }: { required: boolean }): React.ReactElement {
57
+ const { colors } = useTheme();
58
+ return (
59
+ <View
60
+ style={{
61
+ paddingHorizontal: spacing.sm,
62
+ paddingVertical: 2,
63
+ borderRadius: radius.full,
64
+ backgroundColor: required ? colors.errorBg : colors.background,
65
+ }}
66
+ >
67
+ <MyazaText
68
+ variant="bodySmall"
69
+ color={required ? colors.error : colors.textSecondary}
70
+ style={{ fontWeight: '600' }}
71
+ >
72
+ {required ? 'Required' : 'Optional'}
73
+ </MyazaText>
74
+ </View>
75
+ );
76
+ }
77
+
78
+ /** What the server will take off this document, named as the author named it. */
79
+ export function DocumentReads({ reads }: { reads: readonly string[] }): React.ReactElement {
80
+ const { colors } = useTheme();
81
+ return (
82
+ <View
83
+ style={{
84
+ padding: spacing.sm + 4,
85
+ borderRadius: radius.sm,
86
+ backgroundColor: colors.background,
87
+ }}
88
+ >
89
+ <MyazaText
90
+ variant="bodySmall"
91
+ color={colors.textSecondary}
92
+ style={{ fontWeight: '600', letterSpacing: 0.5 }}
93
+ >
94
+ WHAT WE READ FROM IT
95
+ </MyazaText>
96
+ <View
97
+ style={{
98
+ marginTop: spacing.sm,
99
+ flexDirection: 'row',
100
+ flexWrap: 'wrap',
101
+ gap: spacing.xs + 2,
102
+ }}
103
+ >
104
+ {reads.map((read) => (
105
+ <View
106
+ key={read}
107
+ style={{
108
+ flexDirection: 'row',
109
+ alignItems: 'center',
110
+ paddingHorizontal: spacing.sm,
111
+ paddingVertical: 4,
112
+ borderRadius: radius.full,
113
+ borderWidth: 1,
114
+ borderColor: colors.border,
115
+ backgroundColor: colors.backgroundSecondary,
116
+ }}
117
+ >
118
+ <Icon name="check" size={12} color={colors.primary} />
119
+ <View style={{ width: 4 }} />
120
+ <MyazaText variant="bodySmall">{read}</MyazaText>
121
+ </View>
122
+ ))}
123
+ </View>
124
+ </View>
125
+ );
126
+ }
@@ -14,19 +14,30 @@ import { loadDocumentPicker } from '../services/documentPicker';
14
14
  // the refusal names the file that was chosen (images 5 MB, PDFs 15 MB).
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
- export type AttachBusinessDocument = (
18
- slot: ResolvedBusinessDocumentType,
17
+ /**
18
+ * The minimum a slot must carry for this hook: it reads the label for error
19
+ * copy and hands the slot straight back to the caller's uploader. Generalised
20
+ * so the supporting-documents step shares this picker rather than owning a
21
+ * second copy of three file sources and their size/MIME rules.
22
+ */
23
+ export interface AttachableSlot {
24
+ key: string;
25
+ label: string;
26
+ }
27
+
28
+ export type AttachBusinessDocument<S extends AttachableSlot = ResolvedBusinessDocumentType> = (
29
+ slot: S,
19
30
  uri: string,
20
31
  mimeType: string | undefined,
21
32
  name: string,
22
33
  ) => Promise<void>;
23
34
 
24
- type Pick = (slot: ResolvedBusinessDocumentType) => Promise<void>;
35
+ type Pick<S extends AttachableSlot> = (slot: S) => Promise<void>;
25
36
 
26
- export function useBusinessDocumentAttach(
27
- attach: AttachBusinessDocument,
37
+ export function useBusinessDocumentAttach<S extends AttachableSlot = ResolvedBusinessDocumentType>(
38
+ attach: AttachBusinessDocument<S>,
28
39
  setError: (message: string) => void,
29
- ): { takePhoto: Pick; choosePhoto: Pick; chooseFile: Pick } {
40
+ ): { takePhoto: Pick<S>; choosePhoto: Pick<S>; chooseFile: Pick<S> } {
30
41
  const tooLarge = useCallback(
31
42
  (mime: string | undefined, size: number | undefined): boolean => {
32
43
  const message = uploadSizeError(mime, size);
@@ -36,7 +47,7 @@ export function useBusinessDocumentAttach(
36
47
  [setError],
37
48
  );
38
49
 
39
- const takePhoto = useCallback<Pick>(
50
+ const takePhoto = useCallback<Pick<S>>(
40
51
  async (slot) => {
41
52
  const permission = await ImagePicker.requestCameraPermissionsAsync();
42
53
  if (!permission.granted) {
@@ -53,7 +64,7 @@ export function useBusinessDocumentAttach(
53
64
  [attach, setError, tooLarge],
54
65
  );
55
66
 
56
- const choosePhoto = useCallback<Pick>(
67
+ const choosePhoto = useCallback<Pick<S>>(
57
68
  async (slot) => {
58
69
  const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
59
70
  const asset = result.canceled ? undefined : result.assets[0];
@@ -63,7 +74,7 @@ export function useBusinessDocumentAttach(
63
74
  [attach, tooLarge],
64
75
  );
65
76
 
66
- const chooseFile = useCallback<Pick>(
77
+ const chooseFile = useCallback<Pick<S>>(
67
78
  async (slot) => {
68
79
  const picker = loadDocumentPicker();
69
80
  if (!picker) {
@@ -24,6 +24,9 @@ export type MediaUploadType =
24
24
  // downloaded statement, and company paperwork is almost always a scan.
25
25
  | 'proof_of_address'
26
26
  | 'business_document'
27
+ // Artefacts held on file, named by the organisation — not the
28
+ // identity evidence the verification is decided on. Images + PDF.
29
+ | 'supporting_document'
27
30
  // Address Intelligence door / premises photo (image only).
28
31
  | 'address_photo';
29
32
 
@@ -43,6 +46,34 @@ export interface UploadResponse {
43
46
  mediaId: string;
44
47
  }
45
48
 
49
+ /** Which side of a document a capture check is about. */
50
+ export type DocumentCaptureSide = 'front' | 'back';
51
+
52
+ /**
53
+ * Ask whether an uploaded document photo will be readable, using the same
54
+ * detectors the server later decides the verification with.
55
+ */
56
+ export interface DocumentCaptureCheckRequest {
57
+ mediaId: string;
58
+ side: DocumentCaptureSide;
59
+ country: string;
60
+ idType: string;
61
+ /** The workflow the submission will carry, when it carries one. */
62
+ workflowId?: string;
63
+ /** The attempt session from /session/start, when the flow has one. */
64
+ sessionId?: string;
65
+ }
66
+
67
+ /**
68
+ * `false` = looked and found nothing (ask for a retake); `true` = fine;
69
+ * `null` = not applicable, or the server could not look (treat as fine).
70
+ */
71
+ export interface DocumentCaptureCheckResponse {
72
+ side: DocumentCaptureSide;
73
+ face: boolean | null;
74
+ barcode: boolean | null;
75
+ }
76
+
46
77
  export interface KeyPersonInvite {
47
78
  keyPersonId: string;
48
79
  name: string;
@@ -408,5 +439,3 @@ export interface BusinessSearchResponse {
408
439
  export interface BusinessRegionsResponse {
409
440
  regions: { code: string; name: string }[];
410
441
  }
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.1.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
  }
@@ -8,6 +8,7 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  import { configScope } from '../lib/scope';
11
+ import { hasSupportingDocumentsStep, verifiedIdsFor } from '../config/supportingDocuments';
11
12
  import { requiresDocumentCapture, supportsNfcChip } from '../config/idTypes';
12
13
  import {
13
14
  buildStepOrder,
@@ -83,6 +84,16 @@ export function stepOrderOptions(state: KycState): StepOrderOptions {
83
84
  hasEmailVerification: config.emailVerification?.enabled === true,
84
85
  hasPhoneVerification: config.phoneVerification?.enabled === true,
85
86
  hasPoa: hasProofOfAddressStep(config.proofOfAddress),
87
+ // Resolved against the ID actually picked — that is what decides whether
88
+ // the step has anything to ask for.
89
+ hasSupportingDocuments: hasSupportingDocumentsStep(
90
+ config.supportingDocuments,
91
+ verifiedIdsFor({
92
+ country: state.selectedCountry ?? config.country,
93
+ idType: state.selectedIdType,
94
+ multiIdSlots: state.multiIdSlots,
95
+ }),
96
+ ),
86
97
  hasAddressCollection: hasAddressCollectionStep(config.addressCollection),
87
98
  // An absent search flag means no search SCREEN, never an error: the
88
99
  // applicant places the pin by hand, the fallback every address failure