@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.
- 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 +24 -14
- 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/GlassIconButton.tsx +2 -5
- package/src/components/Icon.tsx +17 -13
- package/src/components/KycFlow.tsx +10 -2
- package/src/components/KycSheet.tsx +0 -1
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +3 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icons/glyphs.ts +35 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/map.ts +165 -0
- package/src/components/icons/names.ts +86 -0
- package/src/components/stepHeaderMeta.tsx +21 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/stepOrder.ts +20 -5
- package/src/config/supportingDocuments.ts +131 -0
- package/src/config/theme.ts +29 -3
- package/src/config/workflowMerge.ts +15 -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/lib/supportingDocumentsIntro.ts +35 -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/BusinessDocumentSlot.tsx +37 -8
- 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/SupportingDocumentCard.tsx +134 -0
- package/src/screens/SupportingDocumentsStep.tsx +203 -0
- package/src/screens/consent/model.ts +22 -5
- 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/CaptureRing.tsx +31 -17
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/screens/supportingDocumentParts.tsx +126 -0
- package/src/screens/useBusinessDocumentAttach.ts +20 -9
- package/src/services/api-types.ts +31 -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/derive.ts +11 -0
- package/src/store/kycStore.ts +23 -2
- package/src/store/session.ts +18 -0
- package/src/store/state.ts +26 -0
- package/src/store/submit.ts +11 -0
- package/src/types/config.ts +25 -0
- package/src/types/workflow.ts +39 -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/components/icon-map.ts +0 -176
- 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,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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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({
|
|
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
|
);
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
|
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';
|
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.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
|
-
|
|
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/derive.ts
CHANGED
|
@@ -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
|