@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,177 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { mapToKycError, safeReportError } from '../services/errors';
|
|
6
|
+
import type { KYCError, KYCSubmission } from '../types/verification';
|
|
7
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
8
|
+
import { MyazaText } from '../components/Typography';
|
|
9
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
10
|
+
import { MyazaAlert } from '../components/MyazaAlert';
|
|
11
|
+
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
12
|
+
import { Icon } from '../components/Icon';
|
|
13
|
+
import { fillTokens } from '../utils/tokens';
|
|
14
|
+
|
|
15
|
+
// Terminal screen — 1:1 with the Flutter SDK's SubmittedScreen. Calls
|
|
16
|
+
// submitAsync on mount; renders submitting / success / error views with the same
|
|
17
|
+
// badges + copy. The header title is empty for this step.
|
|
18
|
+
|
|
19
|
+
type Phase = 'submitting' | 'success' | 'error';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
|
|
22
|
+
const DEFAULT_SUCCESS_DESCRIPTION =
|
|
23
|
+
"Your identity verification has been submitted for review. You'll be notified of the result.";
|
|
24
|
+
|
|
25
|
+
const ERROR_TITLES: Record<string, string> = {
|
|
26
|
+
insufficient_credits: 'Credits Exhausted',
|
|
27
|
+
invalid_api_key: 'Authentication Failed',
|
|
28
|
+
feature_disabled: 'Verification Unavailable',
|
|
29
|
+
upload_failed: 'Upload Failed',
|
|
30
|
+
network_error: 'Connection Failed',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function SubmittedStep({ onClose }: { onClose: () => void }): React.ReactElement {
|
|
34
|
+
const { colors } = useTheme();
|
|
35
|
+
const config = useKycConfig();
|
|
36
|
+
const store = useKycStore();
|
|
37
|
+
const existingResult = useKyc((s) => s.submissionResult);
|
|
38
|
+
|
|
39
|
+
const [phase, setPhase] = useState<Phase>('submitting');
|
|
40
|
+
const [error, setError] = useState<KYCError | null>(null);
|
|
41
|
+
const [retry, setRetry] = useState<{ attempt: number; total: number } | null>(null);
|
|
42
|
+
const reportedRef = useRef(false);
|
|
43
|
+
const kickedRef = useRef(false);
|
|
44
|
+
|
|
45
|
+
const submit = React.useCallback(async () => {
|
|
46
|
+
setPhase('submitting');
|
|
47
|
+
setError(null);
|
|
48
|
+
setRetry(null);
|
|
49
|
+
try {
|
|
50
|
+
const result = await store.getState().submitAsync((attempt, total) => setRetry({ attempt, total }));
|
|
51
|
+
const submission: KYCSubmission = {
|
|
52
|
+
verificationId: result.verificationId,
|
|
53
|
+
status: 'pending',
|
|
54
|
+
metadata: config.metadata ?? {},
|
|
55
|
+
submittedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
config.onSubmit?.(submission);
|
|
58
|
+
setPhase('success');
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const kycError = mapToKycError(err, 'verify');
|
|
61
|
+
setError(kycError);
|
|
62
|
+
if (!reportedRef.current) {
|
|
63
|
+
reportedRef.current = true;
|
|
64
|
+
safeReportError(config.onError, kycError);
|
|
65
|
+
}
|
|
66
|
+
setPhase('error');
|
|
67
|
+
}
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [store, config]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (existingResult) {
|
|
73
|
+
setPhase('success');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!kickedRef.current) {
|
|
77
|
+
kickedRef.current = true;
|
|
78
|
+
void submit();
|
|
79
|
+
}
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
if (phase === 'submitting') {
|
|
84
|
+
const retrying = retry != null;
|
|
85
|
+
return (
|
|
86
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }}>
|
|
87
|
+
<MyazaPulseLoader />
|
|
88
|
+
<View style={{ height: spacing.lg }} />
|
|
89
|
+
<MyazaText variant="heading3" style={{ textAlign: 'center' }}>
|
|
90
|
+
{retrying ? 'Reconnecting…' : 'Submitting your verification…'}
|
|
91
|
+
</MyazaText>
|
|
92
|
+
<View style={{ height: spacing.sm }} />
|
|
93
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
94
|
+
{retrying ? `Connection issue — retrying (${retry!.attempt}/${retry!.total})…` : 'Please wait a moment.'}
|
|
95
|
+
</MyazaText>
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (phase === 'error' && error) {
|
|
101
|
+
const title = ERROR_TITLES[error.code] ?? 'Submission Failed';
|
|
102
|
+
const canRetry = error.code === 'network_error';
|
|
103
|
+
return (
|
|
104
|
+
<View style={{ flex: 1, justifyContent: 'space-between' }}>
|
|
105
|
+
<View style={{ alignItems: 'center' }}>
|
|
106
|
+
<View style={{ height: spacing.xl }} />
|
|
107
|
+
<Badge bg={colors.errorBg} border={`${colors.error}4D`}>
|
|
108
|
+
<Icon name="alert" size={44} color={colors.error} />
|
|
109
|
+
</Badge>
|
|
110
|
+
<View style={{ height: spacing.lg }} />
|
|
111
|
+
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
112
|
+
{title}
|
|
113
|
+
</MyazaText>
|
|
114
|
+
<View style={{ height: spacing.md }} />
|
|
115
|
+
<View style={{ width: '100%', paddingHorizontal: spacing.md }}>
|
|
116
|
+
<MyazaAlert variant="error" title="What happened" message={error.message} />
|
|
117
|
+
</View>
|
|
118
|
+
</View>
|
|
119
|
+
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
|
120
|
+
{canRetry ? (
|
|
121
|
+
<View style={{ flex: 1 }}>
|
|
122
|
+
<MyazaButton label="Try Again" variant="outline" leadingIcon="refresh" onPress={() => void submit()} />
|
|
123
|
+
</View>
|
|
124
|
+
) : null}
|
|
125
|
+
<View style={{ flex: 1 }}>
|
|
126
|
+
<MyazaButton label="Close" onPress={onClose} />
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// success
|
|
134
|
+
const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
|
|
135
|
+
const description = config.success?.description
|
|
136
|
+
? fillTokens(config.success.description, config.userData)
|
|
137
|
+
: DEFAULT_SUCCESS_DESCRIPTION;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<View style={{ flex: 1, justifyContent: 'space-between' }}>
|
|
141
|
+
<View style={{ alignItems: 'center' }}>
|
|
142
|
+
<View style={{ height: spacing.xl }} />
|
|
143
|
+
<Badge bg={colors.successBg} border={`${colors.success}4D`}>
|
|
144
|
+
<Icon name="check" size={44} color={colors.success} />
|
|
145
|
+
</Badge>
|
|
146
|
+
<View style={{ height: spacing.lg }} />
|
|
147
|
+
<MyazaText variant="heading1" style={{ textAlign: 'center' }}>
|
|
148
|
+
{title}
|
|
149
|
+
</MyazaText>
|
|
150
|
+
<View style={{ height: spacing.sm }} />
|
|
151
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
152
|
+
{description}
|
|
153
|
+
</MyazaText>
|
|
154
|
+
</View>
|
|
155
|
+
<MyazaButton label="Done" onPress={onClose} />
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function Badge({ bg, border, children }: { bg: string; border: string; children: React.ReactNode }): React.ReactElement {
|
|
161
|
+
return (
|
|
162
|
+
<View
|
|
163
|
+
style={{
|
|
164
|
+
width: 88,
|
|
165
|
+
height: 88,
|
|
166
|
+
borderRadius: radius.full,
|
|
167
|
+
backgroundColor: bg,
|
|
168
|
+
borderWidth: 2,
|
|
169
|
+
borderColor: border,
|
|
170
|
+
alignItems: 'center',
|
|
171
|
+
justifyContent: 'center',
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{children}
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { SDK_VERSION } from './deviceMetadata';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// HTTP client — talks to the only endpoints the SDK calls (upload / verify /
|
|
5
|
+
// status / config / health). Mirrors the web SDK's `services/api.ts`. Multipart
|
|
6
|
+
// uploads append a real `Blob` (read from the local file URI via `uriToBlob`),
|
|
7
|
+
// NOT the classic RN `{ uri, name, type }` part — Expo SDK 54+ installs a
|
|
8
|
+
// WinterCG `fetch` whose serializer rejects the `{ uri }` form.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export class KYCApiError extends Error {
|
|
12
|
+
statusCode: number;
|
|
13
|
+
code?: string;
|
|
14
|
+
body?: Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, statusCode: number, code?: string, body?: Record<string, unknown>) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'KYCApiError';
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.body = body;
|
|
22
|
+
Object.setPrototypeOf(this, KYCApiError.prototype);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function baseHeaders(apiKey: string): Record<string, string> {
|
|
27
|
+
return {
|
|
28
|
+
Authorization: `Bearer ${apiKey}`,
|
|
29
|
+
'X-SDK-Version': SDK_VERSION,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a local file URI (`file://…`, `content://…`, `ph://…`) into a Blob.
|
|
35
|
+
*
|
|
36
|
+
* Uses `XMLHttpRequest` (not the global `fetch`) on purpose: React Native's XHR
|
|
37
|
+
* has built-in blob support for local URIs and is NOT shadowed by the WinterCG
|
|
38
|
+
* `fetch` Expo installs — so this works regardless of which `fetch` is global.
|
|
39
|
+
* The resulting Blob is what the multipart upload appends (Expo-fetch's
|
|
40
|
+
* serializer accepts a Blob; the classic RN `{ uri }` part it rejects).
|
|
41
|
+
*/
|
|
42
|
+
function uriToBlob(uri: string, mimeType: string): Promise<Blob> {
|
|
43
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
44
|
+
const xhr = new XMLHttpRequest();
|
|
45
|
+
xhr.open('GET', uri, true);
|
|
46
|
+
xhr.responseType = 'blob';
|
|
47
|
+
xhr.onload = () => {
|
|
48
|
+
const blob = xhr.response as Blob | null;
|
|
49
|
+
if (blob) {
|
|
50
|
+
// Ensure the part carries the right content-type even if the platform
|
|
51
|
+
// didn't infer one from the URI.
|
|
52
|
+
resolve(blob.type ? blob : blob.slice(0, blob.size, mimeType));
|
|
53
|
+
} else {
|
|
54
|
+
reject(new Error(`Could not read file at ${uri}`));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
xhr.onerror = () => reject(new Error(`Failed to read file at ${uri}`));
|
|
58
|
+
xhr.send();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleResponse<T>(res: Response): Promise<T> {
|
|
63
|
+
// Some endpoints return an empty body — read as text first so JSON.parse
|
|
64
|
+
// isn't called on "" (which throws).
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
let data: unknown = null;
|
|
67
|
+
if (text) {
|
|
68
|
+
try {
|
|
69
|
+
data = JSON.parse(text);
|
|
70
|
+
} catch {
|
|
71
|
+
data = { message: text };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const errObj = typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : {};
|
|
76
|
+
const message = String(errObj.message ?? errObj.error ?? `Request failed with status ${res.status}`);
|
|
77
|
+
const code = typeof errObj.error === 'string' ? errObj.error : undefined;
|
|
78
|
+
throw new KYCApiError(message, res.status, code, errObj);
|
|
79
|
+
}
|
|
80
|
+
return data as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Types
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Media kinds the SDK can upload — document/selfie photos plus best-effort videos. */
|
|
88
|
+
export type MediaUploadType =
|
|
89
|
+
| 'document_front'
|
|
90
|
+
| 'document_back'
|
|
91
|
+
| 'selfie'
|
|
92
|
+
| 'document_front_video'
|
|
93
|
+
| 'document_back_video'
|
|
94
|
+
| 'liveness_video';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A file to upload, in React Native's multipart shape. `uri` points at a
|
|
98
|
+
* local file (camera capture / gallery pick); `name`/`type` populate the
|
|
99
|
+
* multipart part's filename + Content-Type.
|
|
100
|
+
*/
|
|
101
|
+
export interface UploadFile {
|
|
102
|
+
uri: string;
|
|
103
|
+
name?: string;
|
|
104
|
+
/** Raw mime type (codec params are stripped before sending). */
|
|
105
|
+
type?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UploadResponse {
|
|
109
|
+
mediaId: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface VerifyRequest {
|
|
113
|
+
country: string;
|
|
114
|
+
idType: string;
|
|
115
|
+
idNumber?: string;
|
|
116
|
+
userData?: {
|
|
117
|
+
firstName?: string;
|
|
118
|
+
lastName?: string;
|
|
119
|
+
dateOfBirth?: string;
|
|
120
|
+
};
|
|
121
|
+
mediaIds: {
|
|
122
|
+
documentFront?: string;
|
|
123
|
+
documentBack?: string;
|
|
124
|
+
selfie?: string;
|
|
125
|
+
documentFrontVideo?: string;
|
|
126
|
+
documentBackVideo?: string;
|
|
127
|
+
livenessVideo?: string;
|
|
128
|
+
};
|
|
129
|
+
metadata: {
|
|
130
|
+
requestId: string;
|
|
131
|
+
device?: Record<string, unknown>;
|
|
132
|
+
[key: string]: unknown;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface VerifyResponse {
|
|
137
|
+
verificationId: string;
|
|
138
|
+
status: 'pending';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Minimal, publishable-safe status from `GET /api/kyc/status/:id` — no PII,
|
|
143
|
+
* scores, or result data; only the lifecycle state and an org-safe reason.
|
|
144
|
+
*/
|
|
145
|
+
export interface VerificationStatusResponse {
|
|
146
|
+
verificationId: string;
|
|
147
|
+
status: 'pending' | 'verified' | 'failed' | 'not_found' | 'error';
|
|
148
|
+
reason?: string | null;
|
|
149
|
+
reasonCode?: string | null;
|
|
150
|
+
createdAt: string;
|
|
151
|
+
completedAt?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface SdkConfigIdType {
|
|
155
|
+
country: string;
|
|
156
|
+
idType: string;
|
|
157
|
+
features: {
|
|
158
|
+
documentVerification: boolean;
|
|
159
|
+
livenessCheck: boolean;
|
|
160
|
+
govDbCheck: boolean;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Org branding configured server-side, returned with the SDK config. */
|
|
165
|
+
export interface SdkConfigBranding {
|
|
166
|
+
logo?: string;
|
|
167
|
+
companyName?: string;
|
|
168
|
+
primaryColor?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface SdkConfigResponse {
|
|
172
|
+
environment: 'DEVELOPMENT' | 'SANDBOX' | 'PRODUCTION';
|
|
173
|
+
idTypes: SdkConfigIdType[];
|
|
174
|
+
branding?: SdkConfigBranding;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface HealthResponse {
|
|
178
|
+
status: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The mime types the server accepts (must mirror the server's upload allowlist).
|
|
182
|
+
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
|
|
183
|
+
const VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'] as const;
|
|
184
|
+
|
|
185
|
+
const MIME_EXTENSIONS: Record<string, string> = {
|
|
186
|
+
'image/jpeg': 'jpg',
|
|
187
|
+
'image/png': 'png',
|
|
188
|
+
'image/webp': 'webp',
|
|
189
|
+
'video/webm': 'webm',
|
|
190
|
+
'video/mp4': 'mp4',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drop codec params from a file's type (e.g. `video/mp4;codecs=...` →
|
|
195
|
+
* `video/mp4`) and fall back to a sane default per media kind when the value
|
|
196
|
+
* isn't one the server recognizes. RN cameras record mp4, so videos default
|
|
197
|
+
* to `video/mp4` (the web SDK defaults to `video/webm`).
|
|
198
|
+
*/
|
|
199
|
+
function normalizeMimeType(rawType: string | undefined, type: MediaUploadType): string {
|
|
200
|
+
const base = (rawType?.split(';')[0] ?? '').trim().toLowerCase();
|
|
201
|
+
const isVideo = type.endsWith('_video');
|
|
202
|
+
const allowed: readonly string[] = isVideo ? VIDEO_MIME_TYPES : IMAGE_MIME_TYPES;
|
|
203
|
+
if (allowed.includes(base)) return base;
|
|
204
|
+
return isVideo ? 'video/mp4' : 'image/jpeg';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Factory
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export function createKYCApi(baseUrl: string, apiKey: string) {
|
|
212
|
+
const base = `${baseUrl}/api/kyc`;
|
|
213
|
+
const headers = baseHeaders(apiKey);
|
|
214
|
+
|
|
215
|
+
// JSON request to our own server (verify, status, config, health).
|
|
216
|
+
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
217
|
+
const res = await fetch(`${base}${path}`, {
|
|
218
|
+
...init,
|
|
219
|
+
headers: {
|
|
220
|
+
...headers,
|
|
221
|
+
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
|
222
|
+
...init.headers,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
return handleResponse<T>(res);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
/**
|
|
230
|
+
* Single multipart upload: the local file is POSTed to our server, which
|
|
231
|
+
* stores it and returns the `mediaId` referenced later by /verify.
|
|
232
|
+
*/
|
|
233
|
+
async upload(file: UploadFile, type: MediaUploadType, maxBytes?: number): Promise<string> {
|
|
234
|
+
const mimeType = normalizeMimeType(file.type, type);
|
|
235
|
+
const name = file.name ?? `${type}.${MIME_EXTENSIONS[mimeType]}`;
|
|
236
|
+
const form = new FormData();
|
|
237
|
+
|
|
238
|
+
// Read the local file (`file://…`) into a Blob and append THAT, not a
|
|
239
|
+
// `{ uri, name, type }` object. Expo SDK 54+ installs a WinterCG `fetch`
|
|
240
|
+
// whose multipart serializer (expo/src/winter/fetch/convertFormData) only
|
|
241
|
+
// accepts string | Blob | { bytes() } and throws "Unsupported FormDataPart
|
|
242
|
+
// implementation" on the classic RN `{ uri }` part. A Blob satisfies both
|
|
243
|
+
// Expo-fetch and React Native's own networking. The Blob carries its own
|
|
244
|
+
// content-type; pass the filename as the 3rd append arg.
|
|
245
|
+
const blob = await uriToBlob(file.uri, mimeType);
|
|
246
|
+
// Hard size ceiling (used for best-effort videos): reject locally rather than
|
|
247
|
+
// wasting an upload the server will refuse. A 4xx KYCApiError is terminal, so
|
|
248
|
+
// withRetry won't retry it and the caller drops the media.
|
|
249
|
+
if (maxBytes != null && blob.size > maxBytes) {
|
|
250
|
+
throw new KYCApiError(
|
|
251
|
+
`File too large (${(blob.size / 1024 / 1024).toFixed(1)}MB > ${(maxBytes / 1024 / 1024).toFixed(0)}MB)`,
|
|
252
|
+
413,
|
|
253
|
+
'file_too_large',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
form.append('file', blob, name);
|
|
257
|
+
form.append('type', type);
|
|
258
|
+
|
|
259
|
+
// Don't set Content-Type — the fetch impl adds the multipart boundary.
|
|
260
|
+
const res = await fetch(`${base}/upload`, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers,
|
|
263
|
+
body: form,
|
|
264
|
+
});
|
|
265
|
+
const { mediaId } = await handleResponse<UploadResponse>(res);
|
|
266
|
+
return mediaId;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async verify(body: VerifyRequest): Promise<VerifyResponse> {
|
|
270
|
+
return request<VerifyResponse>('/verify', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
body: JSON.stringify(body),
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async status(verificationId: string): Promise<VerificationStatusResponse> {
|
|
277
|
+
return request<VerificationStatusResponse>(`/status/${verificationId}`);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async config(): Promise<SdkConfigResponse> {
|
|
281
|
+
return request<SdkConfigResponse>('/config');
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async health(): Promise<HealthResponse> {
|
|
285
|
+
// Public endpoint — no auth needed, but the shared headers are harmless.
|
|
286
|
+
return request<HealthResponse>('/health');
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type KYCApi = ReturnType<typeof createKYCApi>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pure card-crop geometry — no native imports, so it's unit-testable in the
|
|
2
|
+
// plain-node jest env. The RN mirror of the math in the Flutter SDK's
|
|
3
|
+
// `_cropCardWorker`: the live-camera preview is shown BoxFit.cover in a fixed 3:4
|
|
4
|
+
// box with the ID-card guide centred at 88% width and `aspect` height; this maps
|
|
5
|
+
// that guide rectangle to pixel coordinates of the captured photo.
|
|
6
|
+
|
|
7
|
+
/** A crop rectangle in source-image pixels. */
|
|
8
|
+
export interface CropRect {
|
|
9
|
+
originX: number;
|
|
10
|
+
originY: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VIEW_AR = 3 / 4; // viewfinder box width ÷ height
|
|
16
|
+
const GUIDE_WIDTH_FRACTION = 0.88; // card width as a fraction of the box (overlay GW/VB_W)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Given the photo's pixel size and the guide `aspect` (width ÷ height), return
|
|
20
|
+
* the card-guide crop rect in photo pixels:
|
|
21
|
+
* 1. the cover-fit 3:4 box shows a centred 3:4 slice of the photo,
|
|
22
|
+
* 2. the guide is centred in that slice (88% wide, `aspect` ratio).
|
|
23
|
+
*/
|
|
24
|
+
export function cardCropRect(imageW: number, imageH: number, aspect: number): CropRect {
|
|
25
|
+
const photoAR = imageW / imageH;
|
|
26
|
+
let visW: number;
|
|
27
|
+
let visH: number;
|
|
28
|
+
let offX: number;
|
|
29
|
+
let offY: number;
|
|
30
|
+
if (photoAR > VIEW_AR) {
|
|
31
|
+
visH = imageH;
|
|
32
|
+
visW = imageH * VIEW_AR;
|
|
33
|
+
offX = (imageW - visW) / 2;
|
|
34
|
+
offY = 0;
|
|
35
|
+
} else {
|
|
36
|
+
visW = imageW;
|
|
37
|
+
visH = imageW / VIEW_AR;
|
|
38
|
+
offX = 0;
|
|
39
|
+
offY = (imageH - visH) / 2;
|
|
40
|
+
}
|
|
41
|
+
const leftFraction = (1 - GUIDE_WIDTH_FRACTION) / 2;
|
|
42
|
+
// card height ÷ box height = (cardWidthFrac · boxWidth / aspect) / boxHeight,
|
|
43
|
+
// and boxWidth ÷ boxHeight = VIEW_AR.
|
|
44
|
+
const heightFraction = (GUIDE_WIDTH_FRACTION * VIEW_AR) / aspect;
|
|
45
|
+
const topFraction = (1 - heightFraction) / 2;
|
|
46
|
+
return {
|
|
47
|
+
originX: offX + leftFraction * visW,
|
|
48
|
+
originY: offY + topFraction * visH,
|
|
49
|
+
width: GUIDE_WIDTH_FRACTION * visW,
|
|
50
|
+
height: heightFraction * visH,
|
|
51
|
+
};
|
|
52
|
+
}
|