@myazahq/kyc-sdk-react-native 2.6.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -4
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +57 -2
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +17 -8
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/Icon.tsx +8 -7
- package/src/components/KycFlow.tsx +9 -2
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/stepHeaderMeta.tsx +6 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/workflowMerge.ts +2 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/services/api-types.ts +28 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/kycStore.ts +8 -2
- package/src/types/config.ts +15 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- package/src/services/api-types-biometric.ts +0 -41
|
@@ -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({
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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';
|
package/src/services/api.ts
CHANGED
|
@@ -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 = '
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/store/kycStore.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: [],
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
|