@myazahq/kyc-sdk-react-native 2.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/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- 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/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- package/src/utils/uuid.ts +31 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
8
|
+
import { Icon, type IconName } from '../components/Icon';
|
|
9
|
+
import { fillTokens } from '../utils/tokens';
|
|
10
|
+
|
|
11
|
+
// Consent / welcome screen — 1:1 with the Flutter SDK's ConsentScreen:
|
|
12
|
+
// shield hero, token-filled greeting, a "DURING THIS PROCESS WE WILL" card,
|
|
13
|
+
// a consent checkbox (must agree), Continue (gated on agreement), lock footer.
|
|
14
|
+
// The header title is intentionally empty for this step; the screen owns its hero.
|
|
15
|
+
|
|
16
|
+
const DEFAULT_DESCRIPTION =
|
|
17
|
+
'We need to verify your identity to comply with regulatory requirements. This process is quick and secure.';
|
|
18
|
+
|
|
19
|
+
interface ProcessStep {
|
|
20
|
+
icon: IconName;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ConsentStep(): React.ReactElement {
|
|
25
|
+
const { colors } = useTheme();
|
|
26
|
+
const config = useKycConfig();
|
|
27
|
+
const nextStep = useKyc((s) => s.nextStep);
|
|
28
|
+
const [agreed, setAgreed] = useState(false);
|
|
29
|
+
|
|
30
|
+
const firstName = config.userData?.firstName ?? '';
|
|
31
|
+
const title = config.consent?.title
|
|
32
|
+
? fillTokens(config.consent.title, config.userData)
|
|
33
|
+
: firstName
|
|
34
|
+
? `Welcome, ${firstName}`
|
|
35
|
+
: 'Identity Verification';
|
|
36
|
+
const description = config.consent?.description
|
|
37
|
+
? fillTokens(config.consent.description, config.userData)
|
|
38
|
+
: DEFAULT_DESCRIPTION;
|
|
39
|
+
|
|
40
|
+
const steps: ProcessStep[] = [
|
|
41
|
+
{ icon: 'badge-check', label: 'Verify your government-issued ID' },
|
|
42
|
+
{ icon: 'user', label: 'Collect basic personal information' },
|
|
43
|
+
...(config.enableDocumentCapture
|
|
44
|
+
? [{ icon: 'scan-line' as IconName, label: 'Capture a photo of your ID document' }]
|
|
45
|
+
: []),
|
|
46
|
+
...(config.enableSelfie
|
|
47
|
+
? [{ icon: 'scan-face' as IconName, label: 'Take a selfie for facial verification' }]
|
|
48
|
+
: []),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View>
|
|
53
|
+
<View style={{ height: spacing.sm }} />
|
|
54
|
+
|
|
55
|
+
{/* Shield hero — concentric tinted rings + primary badge */}
|
|
56
|
+
<View style={{ alignItems: 'center' }}>
|
|
57
|
+
<View style={{ width: 80, height: 80, alignItems: 'center', justifyContent: 'center' }}>
|
|
58
|
+
<View style={{ position: 'absolute', width: 80, height: 80, borderRadius: 40, backgroundColor: `${colors.primary}1A` }} />
|
|
59
|
+
<View style={{ position: 'absolute', width: 64, height: 64, borderRadius: 32, backgroundColor: `${colors.primary}26` }} />
|
|
60
|
+
<View
|
|
61
|
+
style={{
|
|
62
|
+
width: 56,
|
|
63
|
+
height: 56,
|
|
64
|
+
borderRadius: 28,
|
|
65
|
+
backgroundColor: colors.primary,
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
justifyContent: 'center',
|
|
68
|
+
shadowColor: colors.primary,
|
|
69
|
+
shadowOpacity: 0.3,
|
|
70
|
+
shadowRadius: 16,
|
|
71
|
+
shadowOffset: { width: 0, height: 6 },
|
|
72
|
+
elevation: 6,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Icon name="shield" size={28} color={colors.onPrimary} />
|
|
76
|
+
</View>
|
|
77
|
+
</View>
|
|
78
|
+
</View>
|
|
79
|
+
|
|
80
|
+
<View style={{ height: spacing.lg }} />
|
|
81
|
+
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
82
|
+
{title}
|
|
83
|
+
</MyazaText>
|
|
84
|
+
<View style={{ height: spacing.sm }} />
|
|
85
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
86
|
+
{description}
|
|
87
|
+
</MyazaText>
|
|
88
|
+
<View style={{ height: spacing.lg }} />
|
|
89
|
+
|
|
90
|
+
{/* Process steps card */}
|
|
91
|
+
<View style={{ backgroundColor: colors.backgroundSecondary, borderRadius: radius.lg, padding: spacing.md + 4 }}>
|
|
92
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ fontWeight: '600', letterSpacing: 0.5 }}>
|
|
93
|
+
DURING THIS PROCESS WE WILL
|
|
94
|
+
</MyazaText>
|
|
95
|
+
<View style={{ height: spacing.md }} />
|
|
96
|
+
{steps.map((step, i) => (
|
|
97
|
+
<View key={step.label} style={{ flexDirection: 'row', alignItems: 'center', marginTop: i > 0 ? 14 : 0 }}>
|
|
98
|
+
<View
|
|
99
|
+
style={{
|
|
100
|
+
width: 36,
|
|
101
|
+
height: 36,
|
|
102
|
+
borderRadius: radius.xs,
|
|
103
|
+
backgroundColor: `${colors.primary}1A`,
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<Icon name={step.icon} size={18} color={colors.primary} />
|
|
109
|
+
</View>
|
|
110
|
+
<View style={{ width: spacing.sm + 4 }} />
|
|
111
|
+
<MyazaText variant="bodyMedium" color={colors.textDark} style={{ flex: 1, fontWeight: '600' }}>
|
|
112
|
+
{step.label}
|
|
113
|
+
</MyazaText>
|
|
114
|
+
</View>
|
|
115
|
+
))}
|
|
116
|
+
</View>
|
|
117
|
+
|
|
118
|
+
<View style={{ height: spacing.lg }} />
|
|
119
|
+
|
|
120
|
+
{/* Consent checkbox */}
|
|
121
|
+
<Pressable
|
|
122
|
+
onPress={() => setAgreed((v) => !v)}
|
|
123
|
+
style={{
|
|
124
|
+
flexDirection: 'row',
|
|
125
|
+
alignItems: 'flex-start',
|
|
126
|
+
padding: spacing.md,
|
|
127
|
+
borderRadius: radius.sm,
|
|
128
|
+
backgroundColor: agreed ? `${colors.primary}0F` : colors.backgroundSecondary,
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<View
|
|
132
|
+
style={{
|
|
133
|
+
width: 22,
|
|
134
|
+
height: 22,
|
|
135
|
+
borderRadius: radius.xs - 2,
|
|
136
|
+
backgroundColor: agreed ? colors.primary : 'transparent',
|
|
137
|
+
borderWidth: 1.5,
|
|
138
|
+
borderColor: agreed ? colors.primary : colors.gray400,
|
|
139
|
+
alignItems: 'center',
|
|
140
|
+
justifyContent: 'center',
|
|
141
|
+
marginTop: 1,
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{agreed ? <Icon name="check" size={14} color={colors.onPrimary} /> : null}
|
|
145
|
+
</View>
|
|
146
|
+
<View style={{ width: spacing.sm + 2 }} />
|
|
147
|
+
<MyazaText variant="bodyMedium" color={colors.textDark} style={{ flex: 1, fontWeight: '600' }}>
|
|
148
|
+
I consent to the collection and processing of my personal data for identity verification purposes.
|
|
149
|
+
</MyazaText>
|
|
150
|
+
</Pressable>
|
|
151
|
+
|
|
152
|
+
<View style={{ height: spacing.lg }} />
|
|
153
|
+
<MyazaButton label="Continue" onPress={agreed ? nextStep : undefined} disabled={!agreed} />
|
|
154
|
+
<View style={{ height: spacing.sm }} />
|
|
155
|
+
|
|
156
|
+
{/* Footer */}
|
|
157
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
|
158
|
+
<Icon name="lock" size={13} color={colors.textMuted} />
|
|
159
|
+
<View style={{ width: 6 }} />
|
|
160
|
+
<MyazaText variant="bodySmall">Your data is encrypted and securely processed</MyazaText>
|
|
161
|
+
</View>
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Image, Pressable, View } from 'react-native';
|
|
3
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
4
|
+
import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
5
|
+
|
|
6
|
+
import { radius, spacing } from '../config/theme';
|
|
7
|
+
import { ID_TYPES, documentGuideAspect, getScanSides } from '../config/idTypes';
|
|
8
|
+
import { withRetry } from '../services/retry';
|
|
9
|
+
import { mapToKycError, safeReportError } from '../services/errors';
|
|
10
|
+
import { compressDocumentImage, compressVideo, cropCardRegion } from '../services/mediaCompress';
|
|
11
|
+
import { MAX_VIDEO_BYTES } from '../config/captureSettings';
|
|
12
|
+
import { KYCError } from '../types/verification';
|
|
13
|
+
import type { DocumentCapturePhase } from '../store/kycStore';
|
|
14
|
+
import { useKyc, useKycConfig, useTheme } from '../components/runtime';
|
|
15
|
+
import { MyazaText } from '../components/Typography';
|
|
16
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
17
|
+
import { useToast } from '../components/toast';
|
|
18
|
+
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
19
|
+
import { CameraViewfinder } from '../components/CameraViewfinder';
|
|
20
|
+
import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
|
|
21
|
+
import { DocumentCropper } from '../components/DocumentCropper';
|
|
22
|
+
import { Icon } from '../components/Icon';
|
|
23
|
+
|
|
24
|
+
// Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
|
|
25
|
+
// Phases: front → front-preview → back → review (two-sided); front → review
|
|
26
|
+
// (one-sided). Manual shutter (VisionCamera) or gallery upload; each side is
|
|
27
|
+
// compressed (OCR-conservative) then eagerly uploaded on Continue with retry.
|
|
28
|
+
//
|
|
29
|
+
// The per-phase title/description live in the SHEET HEADER (KycFlow reads the
|
|
30
|
+
// synced `documentCapturePhase` from the store and calls `documentCaptureMeta`),
|
|
31
|
+
// not in the body — mirroring Flutter's header `docReviewPhase` sync.
|
|
32
|
+
|
|
33
|
+
/** The header title/description for a document-capture phase. Used by KycFlow. */
|
|
34
|
+
export function documentCaptureMeta(
|
|
35
|
+
phase: DocumentCapturePhase,
|
|
36
|
+
documentLabel: string,
|
|
37
|
+
): { title: string; description: string } {
|
|
38
|
+
// Copy matches the Flutter SDK's document-capture header meta exactly.
|
|
39
|
+
switch (phase) {
|
|
40
|
+
case 'front':
|
|
41
|
+
return {
|
|
42
|
+
title: `Capture Your ${documentLabel}`,
|
|
43
|
+
description: `Photograph your ${documentLabel} — position it within the frame and hold steady.`,
|
|
44
|
+
};
|
|
45
|
+
case 'front-preview':
|
|
46
|
+
return {
|
|
47
|
+
title: 'Front Side Captured',
|
|
48
|
+
description: 'Looks good? Tap Next to flip the card and scan the back side.',
|
|
49
|
+
};
|
|
50
|
+
case 'back':
|
|
51
|
+
return {
|
|
52
|
+
title: 'Scan Back Side',
|
|
53
|
+
description: `Now place the BACK of your ${documentLabel} within the frame.`,
|
|
54
|
+
};
|
|
55
|
+
case 'review':
|
|
56
|
+
default:
|
|
57
|
+
return {
|
|
58
|
+
title: `Review Your ${documentLabel}`,
|
|
59
|
+
description: 'Tap Continue to upload and submit your document.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DocumentCaptureStep(): React.ReactElement {
|
|
65
|
+
const { colors } = useTheme();
|
|
66
|
+
const toast = useToast();
|
|
67
|
+
const config = useKycConfig();
|
|
68
|
+
const selectedIdType = useKyc((s) => s.selectedIdType);
|
|
69
|
+
const api = useKyc((s) => s.api);
|
|
70
|
+
const setMediaId = useKyc((s) => s.setMediaId);
|
|
71
|
+
const setDocumentCapturePhase = useKyc((s) => s.setDocumentCapturePhase);
|
|
72
|
+
const nextStep = useKyc((s) => s.nextStep);
|
|
73
|
+
|
|
74
|
+
const allowUpload = config.allowDocumentUpload !== false;
|
|
75
|
+
const documentLabel =
|
|
76
|
+
(selectedIdType && Object.values(ID_TYPES).flat().find((t) => t.key === selectedIdType)?.label) || 'Document';
|
|
77
|
+
const scanSides = selectedIdType ? getScanSides(selectedIdType) : 'front_only';
|
|
78
|
+
const isTwoSided = scanSides === 'front_and_back';
|
|
79
|
+
const guideAspect = documentGuideAspect(selectedIdType);
|
|
80
|
+
|
|
81
|
+
const [phase, setPhase] = useState<DocumentCapturePhase>('front');
|
|
82
|
+
const [frontUri, setFrontUri] = useState<string | null>(null);
|
|
83
|
+
const [backUri, setBackUri] = useState<string | null>(null);
|
|
84
|
+
const [busy, setBusy] = useState(false); // compressing a fresh capture
|
|
85
|
+
const [uploading, setUploading] = useState(false);
|
|
86
|
+
const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
|
|
87
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
88
|
+
const [cropUri, setCropUri] = useState<string | null>(null); // gallery photo awaiting crop
|
|
89
|
+
// Best-effort document videos recorded alongside each side's still (uploaded as
|
|
90
|
+
// document_front_video / document_back_video). Refs — they don't drive the UI.
|
|
91
|
+
const frontVideoRef = useRef<string | null>(null);
|
|
92
|
+
const backVideoRef = useRef<string | null>(null);
|
|
93
|
+
|
|
94
|
+
// Publish the sub-phase so the sheet header (KycFlow) shows the right title.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setDocumentCapturePhase(phase);
|
|
97
|
+
}, [phase, setDocumentCapturePhase]);
|
|
98
|
+
|
|
99
|
+
// ── Camera permission ──────────────────────────────────────────────────────
|
|
100
|
+
// `perm` is derived from the ASYNC requestPermission result, not synchronously
|
|
101
|
+
// from `hasPermission` — otherwise the brief window while the OS prompt is open
|
|
102
|
+
// (hasPermission still false) would read as "denied" and fire onError early.
|
|
103
|
+
const { hasPermission, requestPermission } = useCameraPermission();
|
|
104
|
+
const [perm, setPerm] = useState<'checking' | 'granted' | 'denied'>(hasPermission ? 'granted' : 'checking');
|
|
105
|
+
const askedRef = useRef(false);
|
|
106
|
+
const permReportedRef = useRef(false);
|
|
107
|
+
|
|
108
|
+
// ── Camera availability ─────────────────────────────────────────────────────
|
|
109
|
+
// Even with permission granted, there may be no usable back camera (the iOS/
|
|
110
|
+
// Android simulator has none; a real device may fail to init). Give the device
|
|
111
|
+
// list a moment to resolve, then surface a proper "Camera not available" error
|
|
112
|
+
// with an upload fallback — on every iOS version (glass or not) and Android.
|
|
113
|
+
// Device enumeration does NOT need camera permission (iOS AVCaptureDevice /
|
|
114
|
+
// Android CameraManager list hardware regardless), so `!device` reliably means
|
|
115
|
+
// "no back-camera hardware" — true on every simulator. That's a different state
|
|
116
|
+
// from "permission denied": no hardware → nothing to grant.
|
|
117
|
+
const device = useCameraDevice('back');
|
|
118
|
+
const [cameraGrace, setCameraGrace] = useState(false);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const t = setTimeout(() => setCameraGrace(true), 1500);
|
|
121
|
+
return () => clearTimeout(t);
|
|
122
|
+
}, []);
|
|
123
|
+
// No camera hardware at all → "Camera not available" (regardless of what the
|
|
124
|
+
// permission API says — on a camera-less sim it may even report denied).
|
|
125
|
+
const cameraUnavailable = cameraGrace && !device;
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (hasPermission) {
|
|
129
|
+
setPerm('granted');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (askedRef.current) return;
|
|
133
|
+
askedRef.current = true;
|
|
134
|
+
void (async () => {
|
|
135
|
+
const granted = await requestPermission();
|
|
136
|
+
setPerm(granted ? 'granted' : 'denied');
|
|
137
|
+
})();
|
|
138
|
+
}, [hasPermission, requestPermission]);
|
|
139
|
+
|
|
140
|
+
// A *genuine* permission denial requires a camera to exist but be blocked. On a
|
|
141
|
+
// camera-less sim the OS may report denied — that's "not available", not a
|
|
142
|
+
// permission problem, so don't treat it as denied or report onError there.
|
|
143
|
+
const permissionDenied = perm === 'denied' && !!device;
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (permissionDenied && !permReportedRef.current) {
|
|
146
|
+
permReportedRef.current = true;
|
|
147
|
+
safeReportError(
|
|
148
|
+
config.onError,
|
|
149
|
+
new KYCError(
|
|
150
|
+
'camera_permission_denied',
|
|
151
|
+
'Camera access is required to photograph your document. Allow camera access or upload a photo instead.',
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!permissionDenied) permReportedRef.current = false;
|
|
156
|
+
}, [permissionDenied, config.onError]);
|
|
157
|
+
|
|
158
|
+
const retryPermission = useCallback(() => {
|
|
159
|
+
askedRef.current = false;
|
|
160
|
+
setPerm('checking');
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// ── Capture → compress → store for the current side ────────────────────────
|
|
164
|
+
const storeCapture = useCallback(
|
|
165
|
+
async (rawUri: string) => {
|
|
166
|
+
setBusy(true);
|
|
167
|
+
setUploadError(null);
|
|
168
|
+
try {
|
|
169
|
+
const compressed = await compressDocumentImage(rawUri);
|
|
170
|
+
if (phase === 'back') {
|
|
171
|
+
setBackUri(compressed);
|
|
172
|
+
setPhase('review');
|
|
173
|
+
} else {
|
|
174
|
+
setFrontUri(compressed);
|
|
175
|
+
setPhase(isTwoSided ? 'front-preview' : 'review');
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
setBusy(false);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[phase, isTwoSided],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Gallery upload: pick the raw photo (no native editor), then open the SDK's
|
|
185
|
+
// interactive ID-card cropper — mirrors the Flutter SDK's _DocumentCropperScreen.
|
|
186
|
+
// Live-camera capture: crop the full frame to the card-guide rectangle first
|
|
187
|
+
// (mirrors Flutter's cropCardRegion), then store. Gallery photos skip this —
|
|
188
|
+
// they're already cropped by the interactive cropper.
|
|
189
|
+
const captureFromCamera = useCallback(
|
|
190
|
+
async (rawUri: string, videoPath: string | null) => {
|
|
191
|
+
setBusy(true);
|
|
192
|
+
try {
|
|
193
|
+
// Stash the side's video (best-effort) before cropping/storing the still.
|
|
194
|
+
if (phase === 'back') backVideoRef.current = videoPath;
|
|
195
|
+
else frontVideoRef.current = videoPath;
|
|
196
|
+
const carded = await cropCardRegion(rawUri, guideAspect).catch(() => rawUri);
|
|
197
|
+
await storeCapture(carded);
|
|
198
|
+
} finally {
|
|
199
|
+
setBusy(false);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
[guideAspect, storeCapture, phase],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const pickFromGallery = useCallback(async () => {
|
|
206
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
207
|
+
mediaTypes: ['images'],
|
|
208
|
+
allowsEditing: false,
|
|
209
|
+
quality: 1,
|
|
210
|
+
});
|
|
211
|
+
if (!result.canceled && result.assets[0]) {
|
|
212
|
+
setCropUri(result.assets[0].uri);
|
|
213
|
+
}
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
// Best-effort document video upload — never blocks or fails the flow.
|
|
217
|
+
const uploadDocVideo = useCallback(
|
|
218
|
+
async (
|
|
219
|
+
videoPath: string | null,
|
|
220
|
+
type: 'document_front_video' | 'document_back_video',
|
|
221
|
+
mediaKey: 'documentFrontVideo' | 'documentBackVideo',
|
|
222
|
+
) => {
|
|
223
|
+
if (!videoPath) return;
|
|
224
|
+
try {
|
|
225
|
+
// Transcode the raw 4K recording down to a small evidence clip first.
|
|
226
|
+
const small = await compressVideo(videoPath);
|
|
227
|
+
const id = await withRetry(() => api.upload({ uri: small, type: 'video/mp4' }, type, MAX_VIDEO_BYTES));
|
|
228
|
+
setMediaId(mediaKey, id);
|
|
229
|
+
} catch {
|
|
230
|
+
/* supplementary — verification proceeds without the document video
|
|
231
|
+
(dropped if it failed to upload or exceeded the 5MB ceiling) */
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
[api, setMediaId],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// ── Upload both sides, then advance (KycFlow routes to liveness/submitted) ──
|
|
238
|
+
const handleContinue = useCallback(async () => {
|
|
239
|
+
if (!frontUri) return;
|
|
240
|
+
setUploading(true);
|
|
241
|
+
setUploadError(null);
|
|
242
|
+
setRetryInfo(null);
|
|
243
|
+
const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
|
|
244
|
+
try {
|
|
245
|
+
const frontId = await withRetry(() => api.upload({ uri: frontUri, type: 'image/jpeg' }, 'document_front'), { onRetry });
|
|
246
|
+
setMediaId('documentFront', frontId);
|
|
247
|
+
await uploadDocVideo(frontVideoRef.current, 'document_front_video', 'documentFrontVideo');
|
|
248
|
+
if (isTwoSided && backUri) {
|
|
249
|
+
const backId = await withRetry(() => api.upload({ uri: backUri, type: 'image/jpeg' }, 'document_back'), { onRetry });
|
|
250
|
+
setMediaId('documentBack', backId);
|
|
251
|
+
await uploadDocVideo(backVideoRef.current, 'document_back_video', 'documentBackVideo');
|
|
252
|
+
}
|
|
253
|
+
setRetryInfo(null);
|
|
254
|
+
setUploading(false);
|
|
255
|
+
nextStep();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
setRetryInfo(null);
|
|
258
|
+
setUploading(false);
|
|
259
|
+
const kycError = mapToKycError(err, 'upload');
|
|
260
|
+
setUploadError(kycError.message);
|
|
261
|
+
toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
|
|
262
|
+
safeReportError(config.onError, kycError);
|
|
263
|
+
}
|
|
264
|
+
}, [frontUri, backUri, isTwoSided, api, setMediaId, nextStep, config.onError, toast, uploadDocVideo]);
|
|
265
|
+
|
|
266
|
+
const retake = (side: 'front' | 'back') => {
|
|
267
|
+
setUploadError(null);
|
|
268
|
+
if (side === 'back') {
|
|
269
|
+
setBackUri(null);
|
|
270
|
+
backVideoRef.current = null;
|
|
271
|
+
setPhase('back');
|
|
272
|
+
} else {
|
|
273
|
+
setFrontUri(null);
|
|
274
|
+
frontVideoRef.current = null;
|
|
275
|
+
setPhase('front');
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Interactive ID-card cropper for a gallery photo (Modal — overlays whatever
|
|
280
|
+
// capture sub-screen triggered the upload, including the sim's "camera unavailable").
|
|
281
|
+
const cropper = cropUri ? (
|
|
282
|
+
<DocumentCropper
|
|
283
|
+
uri={cropUri}
|
|
284
|
+
onCancel={() => setCropUri(null)}
|
|
285
|
+
onConfirm={(out) => {
|
|
286
|
+
setCropUri(null);
|
|
287
|
+
void storeCapture(out);
|
|
288
|
+
}}
|
|
289
|
+
/>
|
|
290
|
+
) : null;
|
|
291
|
+
|
|
292
|
+
// ── Capture (front/back) ───────────────────────────────────────────────────
|
|
293
|
+
// Two distinct error states, distinguished by hardware vs permission (both
|
|
294
|
+
// states keep the title/description in the header, and always offer the gallery
|
|
295
|
+
// escape hatch). These never overlap: one needs a device, the other needs none.
|
|
296
|
+
if (phase === 'front' || phase === 'back') {
|
|
297
|
+
if (cameraUnavailable) {
|
|
298
|
+
// No back-camera hardware (e.g. a simulator) — permission is moot here.
|
|
299
|
+
return (
|
|
300
|
+
<>
|
|
301
|
+
{cropper}
|
|
302
|
+
<CameraUnavailableView onUpload={pickFromGallery} />
|
|
303
|
+
</>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (permissionDenied) {
|
|
307
|
+
// A real camera exists but the OS blocked access.
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
{cropper}
|
|
311
|
+
<CameraPermissionView onRetry={retryPermission} onUpload={pickFromGallery} />
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const isBack = phase === 'back';
|
|
316
|
+
return (
|
|
317
|
+
<View>
|
|
318
|
+
{cropper}
|
|
319
|
+
{/* Required pill: ID label + side badge + step label (two-sided) */}
|
|
320
|
+
<RequiredPill
|
|
321
|
+
documentLabel={documentLabel}
|
|
322
|
+
sideBadge={isTwoSided ? (isBack ? 'Back Side' : 'Front Side') : undefined}
|
|
323
|
+
stepLabel={isTwoSided ? (isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
|
|
324
|
+
/>
|
|
325
|
+
{isBack ? (
|
|
326
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
|
|
327
|
+
<Icon name="credit-card" size={14} color={colors.primary} />
|
|
328
|
+
<View style={{ width: 4 }} />
|
|
329
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
|
|
330
|
+
Flip the card over and scan the other side
|
|
331
|
+
</MyazaText>
|
|
332
|
+
</View>
|
|
333
|
+
) : null}
|
|
334
|
+
<View style={{ height: spacing.md }} />
|
|
335
|
+
<CameraViewfinder
|
|
336
|
+
active={perm === 'granted'}
|
|
337
|
+
side={isBack ? 'back' : 'front'}
|
|
338
|
+
documentLabel={documentLabel}
|
|
339
|
+
guideAspect={guideAspect}
|
|
340
|
+
onCapture={(uri, videoPath) => void captureFromCamera(uri, videoPath)}
|
|
341
|
+
busy={busy}
|
|
342
|
+
/>
|
|
343
|
+
{!busy ? (
|
|
344
|
+
<>
|
|
345
|
+
<View style={{ height: spacing.md }} />
|
|
346
|
+
<MyazaText variant="bodySmall" style={{ textAlign: 'center' }}>
|
|
347
|
+
Tap the button to capture manually
|
|
348
|
+
</MyazaText>
|
|
349
|
+
{allowUpload ? (
|
|
350
|
+
<Pressable onPress={pickFromGallery} style={{ marginTop: spacing.sm }}>
|
|
351
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
|
352
|
+
<MyazaText variant="bodySmall">Having trouble? </MyazaText>
|
|
353
|
+
<Icon name="upload" size={14} color={colors.primary} />
|
|
354
|
+
<View style={{ width: 4 }} />
|
|
355
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
356
|
+
Upload a photo instead
|
|
357
|
+
</MyazaText>
|
|
358
|
+
</View>
|
|
359
|
+
</Pressable>
|
|
360
|
+
) : null}
|
|
361
|
+
</>
|
|
362
|
+
) : null}
|
|
363
|
+
</View>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Front preview (two-sided) ──────────────────────────────────────────────
|
|
368
|
+
if (phase === 'front-preview') {
|
|
369
|
+
return (
|
|
370
|
+
<View>
|
|
371
|
+
<DocImage uri={frontUri!} />
|
|
372
|
+
<View style={{ flexDirection: 'row', gap: spacing.md, marginTop: spacing.md }}>
|
|
373
|
+
<View style={{ flex: 1 }}>
|
|
374
|
+
<MyazaButton label="Retake" variant="outline" leadingIcon="refresh" onPress={() => retake('front')} />
|
|
375
|
+
</View>
|
|
376
|
+
<View style={{ flex: 1 }}>
|
|
377
|
+
<MyazaButton label="Next — Scan Back" onPress={() => setPhase('back')} />
|
|
378
|
+
</View>
|
|
379
|
+
</View>
|
|
380
|
+
</View>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Review ─────────────────────────────────────────────────────────────────
|
|
385
|
+
return (
|
|
386
|
+
<View>
|
|
387
|
+
<DocImage uri={frontUri!} label={isTwoSided ? 'Front' : undefined} uploading={uploading} />
|
|
388
|
+
<MyazaButton label="Retake" variant="ghost" leadingIcon="refresh" onPress={() => retake('front')} disabled={uploading} />
|
|
389
|
+
{isTwoSided && backUri ? (
|
|
390
|
+
<>
|
|
391
|
+
<View style={{ height: spacing.md }} />
|
|
392
|
+
<DocImage uri={backUri} label="Back" uploading={uploading} />
|
|
393
|
+
<MyazaButton label="Retake Back" variant="ghost" leadingIcon="refresh" onPress={() => retake('back')} disabled={uploading} />
|
|
394
|
+
</>
|
|
395
|
+
) : null}
|
|
396
|
+
|
|
397
|
+
{retryInfo && uploading ? (
|
|
398
|
+
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
|
|
399
|
+
{`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
|
|
400
|
+
</MyazaText>
|
|
401
|
+
) : null}
|
|
402
|
+
|
|
403
|
+
<View style={{ height: spacing.md }} />
|
|
404
|
+
{uploadError ? (
|
|
405
|
+
// The error message is shown as a top toast; keep a retry action here.
|
|
406
|
+
<MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
|
|
407
|
+
) : (
|
|
408
|
+
<MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
|
|
409
|
+
)}
|
|
410
|
+
</View>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// "Required: {label}" pill with optional side badge + step label — mirrors the
|
|
415
|
+
// Flutter SDK's _RequiredPill on the capture screen.
|
|
416
|
+
function RequiredPill({
|
|
417
|
+
documentLabel,
|
|
418
|
+
sideBadge,
|
|
419
|
+
stepLabel,
|
|
420
|
+
}: {
|
|
421
|
+
documentLabel: string;
|
|
422
|
+
sideBadge?: string;
|
|
423
|
+
stepLabel?: string;
|
|
424
|
+
}): React.ReactElement {
|
|
425
|
+
const { colors } = useTheme();
|
|
426
|
+
return (
|
|
427
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
428
|
+
<View
|
|
429
|
+
style={{
|
|
430
|
+
flex: 1,
|
|
431
|
+
flexDirection: 'row',
|
|
432
|
+
alignItems: 'center',
|
|
433
|
+
flexWrap: 'wrap',
|
|
434
|
+
borderWidth: 1,
|
|
435
|
+
borderColor: `${colors.primary}33`,
|
|
436
|
+
backgroundColor: `${colors.primary}0D`,
|
|
437
|
+
borderRadius: radius.md,
|
|
438
|
+
paddingHorizontal: spacing.sm + 4,
|
|
439
|
+
paddingVertical: spacing.sm,
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
<Icon name="credit-card" size={16} color={colors.primary} />
|
|
443
|
+
<View style={{ width: 8 }} />
|
|
444
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
445
|
+
Required:{' '}
|
|
446
|
+
</MyazaText>
|
|
447
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
|
|
448
|
+
{documentLabel}
|
|
449
|
+
</MyazaText>
|
|
450
|
+
{sideBadge ? (
|
|
451
|
+
<View style={{ backgroundColor: `${colors.primary}26`, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
|
|
452
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600', fontSize: 11 }}>
|
|
453
|
+
{sideBadge}
|
|
454
|
+
</MyazaText>
|
|
455
|
+
</View>
|
|
456
|
+
) : null}
|
|
457
|
+
</View>
|
|
458
|
+
{stepLabel ? (
|
|
459
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ marginLeft: spacing.sm }}>
|
|
460
|
+
{stepLabel}
|
|
461
|
+
</MyazaText>
|
|
462
|
+
) : null}
|
|
463
|
+
</View>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function DocImage({ uri, label, uploading }: { uri: string; label?: string; uploading?: boolean }): React.ReactElement {
|
|
468
|
+
const { colors } = useTheme();
|
|
469
|
+
return (
|
|
470
|
+
<View>
|
|
471
|
+
{label ? (
|
|
472
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center', marginBottom: spacing.xs }}>
|
|
473
|
+
{label}
|
|
474
|
+
</MyazaText>
|
|
475
|
+
) : null}
|
|
476
|
+
<View style={{ borderRadius: radius.md, overflow: 'hidden', borderWidth: 1, borderColor: colors.border }}>
|
|
477
|
+
<Image source={{ uri }} style={{ width: '100%', aspectRatio: 1.586 }} resizeMode="cover" />
|
|
478
|
+
{/* Standard upload loader — a dark scrim + the pulse-ring/spinner loader
|
|
479
|
+
rendered INSIDE the preview frame, mirroring the web/Flutter SDKs (and
|
|
480
|
+
the liveness selfie review). */}
|
|
481
|
+
{uploading ? (
|
|
482
|
+
<View
|
|
483
|
+
style={{
|
|
484
|
+
position: 'absolute',
|
|
485
|
+
top: 0,
|
|
486
|
+
left: 0,
|
|
487
|
+
right: 0,
|
|
488
|
+
bottom: 0,
|
|
489
|
+
alignItems: 'center',
|
|
490
|
+
justifyContent: 'center',
|
|
491
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
<MyazaPulseLoader size={64} />
|
|
495
|
+
</View>
|
|
496
|
+
) : null}
|
|
497
|
+
</View>
|
|
498
|
+
</View>
|
|
499
|
+
);
|
|
500
|
+
}
|