@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,484 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// useLiveness — the liveness state machine, as a React hook.
|
|
3
|
+
//
|
|
4
|
+
// RN mirror of the Flutter SDK's LivenessNotifier (liveness_provider.dart). It
|
|
5
|
+
// consumes per-frame `LivenessFaceData` (pushed in from the VisionCamera frame
|
|
6
|
+
// processor via runOnJS) and drives the phase machine:
|
|
7
|
+
//
|
|
8
|
+
// loading → positioning → challenge → challenge_passed → capturing → complete
|
|
9
|
+
// └────────────────────────▶ failed (timeout|face_lost)
|
|
10
|
+
//
|
|
11
|
+
// All gesture thresholds + guards come from the pure helpers
|
|
12
|
+
// (gestureDetector.ts / challengeManager.ts) and match Flutter's native-signal
|
|
13
|
+
// values — NOT the web SDK's landmark math. Guards mirrored 1:1 from Flutter:
|
|
14
|
+
// • single-face enforcement (pause on faceCount > 1)
|
|
15
|
+
// • face positioning (too far <0.2 / too close >0.7 of frame)
|
|
16
|
+
// • lighting gate (challenges won't start while lighting is poor)
|
|
17
|
+
// • per-challenge timeout, inter-challenge cooldown, wrong-gesture flash
|
|
18
|
+
//
|
|
19
|
+
// The hook is detector-agnostic: it just receives face data. The camera/frame-
|
|
20
|
+
// processor lives in LivenessStep; auto-capture + upload are owned there too.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
CHALLENGE_POOL,
|
|
27
|
+
DEFAULT_LIVENESS_CONFIG,
|
|
28
|
+
FACE_TOO_CLOSE_RATIO,
|
|
29
|
+
FACE_TOO_FAR_RATIO,
|
|
30
|
+
type LivenessChallenge,
|
|
31
|
+
type LivenessConfig,
|
|
32
|
+
type LivenessFaceData,
|
|
33
|
+
type LivenessFailureReason,
|
|
34
|
+
type LivenessPhase,
|
|
35
|
+
} from './types';
|
|
36
|
+
import {
|
|
37
|
+
detectBlink,
|
|
38
|
+
detectNod,
|
|
39
|
+
detectSmile,
|
|
40
|
+
detectTurn,
|
|
41
|
+
eyeAverageOpenProbability,
|
|
42
|
+
pushHistory,
|
|
43
|
+
} from './gestureDetector';
|
|
44
|
+
import { ChallengeTracker, pickChallenges } from './challengeManager';
|
|
45
|
+
import { LivenessSpeaker } from './speech';
|
|
46
|
+
import type { VoiceGuidanceOption } from '../types/config';
|
|
47
|
+
|
|
48
|
+
const CHALLENGE_PASSED_MS = 700; // brief "Great!" before the next challenge
|
|
49
|
+
const POSITION_TOO_FAR = 'too_far';
|
|
50
|
+
const POSITION_TOO_CLOSE = 'too_close';
|
|
51
|
+
const MULTIPLE_FACES_GUIDANCE = 'Make sure only your face is visible';
|
|
52
|
+
|
|
53
|
+
export type PositionGuidance = typeof POSITION_TOO_FAR | typeof POSITION_TOO_CLOSE;
|
|
54
|
+
export type LightingGuidance = 'dark' | 'bright';
|
|
55
|
+
|
|
56
|
+
export interface LivenessUiState {
|
|
57
|
+
phase: LivenessPhase;
|
|
58
|
+
instruction: string;
|
|
59
|
+
activeChallenge: LivenessChallenge | null;
|
|
60
|
+
timeoutRemaining: number;
|
|
61
|
+
completedCount: number;
|
|
62
|
+
totalCount: number;
|
|
63
|
+
positionGuidance: PositionGuidance | null;
|
|
64
|
+
lightingGuidance: LightingGuidance | null;
|
|
65
|
+
multipleFaces: boolean;
|
|
66
|
+
wrongGesture: boolean;
|
|
67
|
+
faceDetected: boolean;
|
|
68
|
+
failureReason: LivenessFailureReason | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface UseLivenessOptions {
|
|
72
|
+
config?: Partial<LivenessConfig>;
|
|
73
|
+
voiceGuidance?: VoiceGuidanceOption;
|
|
74
|
+
/** Fires once when all challenges pass and the phase enters `capturing`. */
|
|
75
|
+
onReadyToCapture?: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UseLivenessReturn extends LivenessUiState {
|
|
79
|
+
/** Push a face frame into the machine (call from the frame processor via runOnJS). */
|
|
80
|
+
onFace: (data: LivenessFaceData) => void;
|
|
81
|
+
/** Push a "no face this frame" signal. */
|
|
82
|
+
onNoFace: () => void;
|
|
83
|
+
/** Feed live lighting quality (null = ok). */
|
|
84
|
+
setLighting: (guidance: LightingGuidance | null) => void;
|
|
85
|
+
/** Mark the selfie captured → phase `complete`. */
|
|
86
|
+
markComplete: () => void;
|
|
87
|
+
/** Restart the session with a fresh random challenge set. */
|
|
88
|
+
reset: () => void;
|
|
89
|
+
/** True while the machine wants a still captured (phase === 'capturing'). */
|
|
90
|
+
shouldCapture: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Position guidance text (spoken + shown), matching the Flutter SDK exactly. */
|
|
94
|
+
export function positionGuidanceText(g: PositionGuidance): string {
|
|
95
|
+
return g === POSITION_TOO_FAR ? 'Kindly move closer' : 'Kindly move further away';
|
|
96
|
+
}
|
|
97
|
+
/** Lighting warning banner text — the full Flutter strings (shown, not spoken). */
|
|
98
|
+
export function lightingGuidanceText(g: LightingGuidance): string {
|
|
99
|
+
return g === 'dark'
|
|
100
|
+
? 'It looks dark here. Move to a brighter area or near a light source for better detection.'
|
|
101
|
+
: 'Too bright — reduce glare or move away from direct light for better detection.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTerminal(phase: LivenessPhase): boolean {
|
|
105
|
+
return phase === 'complete' || phase === 'failed';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
109
|
+
const { config, voiceGuidance, onReadyToCapture } = opts;
|
|
110
|
+
|
|
111
|
+
// Resolve the challenge set once per session (and on reset).
|
|
112
|
+
const trackerRef = useRef<ChallengeTracker | null>(null);
|
|
113
|
+
if (trackerRef.current === null) {
|
|
114
|
+
trackerRef.current = new ChallengeTracker(pickChallenges(config));
|
|
115
|
+
}
|
|
116
|
+
const initialTotalCount = trackerRef.current.totalCount;
|
|
117
|
+
|
|
118
|
+
const speakerRef = useRef<LivenessSpeaker | null>(null);
|
|
119
|
+
if (speakerRef.current === null) speakerRef.current = new LivenessSpeaker(voiceGuidance);
|
|
120
|
+
|
|
121
|
+
// Gesture history windows (nod pitch, avg eye-open) + per-challenge guards.
|
|
122
|
+
const xHistoryRef = useRef<number[]>([]);
|
|
123
|
+
const earHistoryRef = useRef<number[]>([]);
|
|
124
|
+
const processingRef = useRef(false); // true between pass and next-challenge start
|
|
125
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
126
|
+
const passTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
127
|
+
const readyFiredRef = useRef(false);
|
|
128
|
+
|
|
129
|
+
const [state, setState] = useState<LivenessUiState>(() => ({
|
|
130
|
+
phase: 'positioning',
|
|
131
|
+
instruction: 'Position your face in the circle',
|
|
132
|
+
activeChallenge: null,
|
|
133
|
+
timeoutRemaining: 0,
|
|
134
|
+
completedCount: 0,
|
|
135
|
+
totalCount: initialTotalCount,
|
|
136
|
+
positionGuidance: null,
|
|
137
|
+
lightingGuidance: null,
|
|
138
|
+
multipleFaces: false,
|
|
139
|
+
wrongGesture: false,
|
|
140
|
+
faceDetected: false,
|
|
141
|
+
failureReason: null,
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
// Keep a ref mirror so frame callbacks read fresh state without re-subscribing.
|
|
145
|
+
const stateRef = useRef(state);
|
|
146
|
+
stateRef.current = state;
|
|
147
|
+
|
|
148
|
+
const speak = useCallback((text: string) => speakerRef.current?.speak(text), []);
|
|
149
|
+
|
|
150
|
+
const cancelTimer = useCallback(() => {
|
|
151
|
+
if (timerRef.current) {
|
|
152
|
+
clearInterval(timerRef.current);
|
|
153
|
+
timerRef.current = null;
|
|
154
|
+
}
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const startTimer = useCallback(
|
|
158
|
+
(seconds: number) => {
|
|
159
|
+
cancelTimer();
|
|
160
|
+
let remaining = seconds;
|
|
161
|
+
timerRef.current = setInterval(() => {
|
|
162
|
+
remaining -= 1;
|
|
163
|
+
if (remaining <= 0) {
|
|
164
|
+
cancelTimer();
|
|
165
|
+
setState((s) =>
|
|
166
|
+
isTerminal(s.phase)
|
|
167
|
+
? s
|
|
168
|
+
: { ...s, phase: 'failed', failureReason: 'timeout', instruction: 'Time ran out' },
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
setState((s) => (s.phase === 'challenge' ? { ...s, timeoutRemaining: remaining } : s));
|
|
172
|
+
}
|
|
173
|
+
}, 1000);
|
|
174
|
+
},
|
|
175
|
+
[cancelTimer],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const startNextChallenge = useCallback(() => {
|
|
179
|
+
const tracker = trackerRef.current!;
|
|
180
|
+
const current = tracker.current;
|
|
181
|
+
if (!current) {
|
|
182
|
+
// No challenges left → ask for a still.
|
|
183
|
+
cancelTimer();
|
|
184
|
+
setState((s) => ({
|
|
185
|
+
...s,
|
|
186
|
+
phase: 'capturing',
|
|
187
|
+
instruction: 'Hold still…',
|
|
188
|
+
activeChallenge: null,
|
|
189
|
+
positionGuidance: null,
|
|
190
|
+
}));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
xHistoryRef.current = [];
|
|
194
|
+
earHistoryRef.current = [];
|
|
195
|
+
processingRef.current = false;
|
|
196
|
+
const timeout = config?.timeoutPerChallenge ?? current.config.timeoutSeconds;
|
|
197
|
+
setState((s) => ({
|
|
198
|
+
...s,
|
|
199
|
+
phase: 'challenge',
|
|
200
|
+
instruction: current.config.instruction,
|
|
201
|
+
activeChallenge: current.config.type,
|
|
202
|
+
timeoutRemaining: timeout,
|
|
203
|
+
positionGuidance: null,
|
|
204
|
+
wrongGesture: false,
|
|
205
|
+
}));
|
|
206
|
+
speak(current.config.instruction);
|
|
207
|
+
startTimer(timeout);
|
|
208
|
+
}, [cancelTimer, config, speak, startTimer]);
|
|
209
|
+
|
|
210
|
+
const onChallengePassed = useCallback(() => {
|
|
211
|
+
processingRef.current = true;
|
|
212
|
+
cancelTimer();
|
|
213
|
+
trackerRef.current!.markCurrentPassed();
|
|
214
|
+
trackerRef.current!.advance();
|
|
215
|
+
setState((s) => ({
|
|
216
|
+
...s,
|
|
217
|
+
phase: 'challenge_passed',
|
|
218
|
+
instruction: 'Great!',
|
|
219
|
+
completedCount: s.completedCount + 1,
|
|
220
|
+
timeoutRemaining: 0,
|
|
221
|
+
positionGuidance: null,
|
|
222
|
+
wrongGesture: false,
|
|
223
|
+
}));
|
|
224
|
+
speak('Great');
|
|
225
|
+
passTimeoutRef.current = setTimeout(() => {
|
|
226
|
+
if (stateRef.current.phase !== 'challenge_passed') return;
|
|
227
|
+
startNextChallenge();
|
|
228
|
+
}, CHALLENGE_PASSED_MS);
|
|
229
|
+
}, [cancelTimer, speak, startNextChallenge]);
|
|
230
|
+
|
|
231
|
+
// ── Position check (every frame; mirrors Flutter _checkFacePosition) ────────
|
|
232
|
+
const checkPosition = useCallback((ratio: number) => {
|
|
233
|
+
setState((s) => {
|
|
234
|
+
if (
|
|
235
|
+
s.phase === 'challenge_passed' ||
|
|
236
|
+
s.phase === 'capturing' ||
|
|
237
|
+
isTerminal(s.phase)
|
|
238
|
+
) {
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
let next: PositionGuidance | null;
|
|
242
|
+
if (ratio < FACE_TOO_FAR_RATIO) next = POSITION_TOO_FAR;
|
|
243
|
+
else if (ratio > FACE_TOO_CLOSE_RATIO) next = POSITION_TOO_CLOSE;
|
|
244
|
+
else next = null;
|
|
245
|
+
return next === s.positionGuidance ? s : { ...s, positionGuidance: next };
|
|
246
|
+
});
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
// ── Per-frame entry point ──────────────────────────────────────────────────
|
|
250
|
+
const onFace = useCallback(
|
|
251
|
+
(data: LivenessFaceData) => {
|
|
252
|
+
const phase = stateRef.current.phase;
|
|
253
|
+
if (isTerminal(phase)) return;
|
|
254
|
+
|
|
255
|
+
if (!stateRef.current.faceDetected) {
|
|
256
|
+
setState((s) => ({ ...s, faceDetected: true }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Multiple-faces guard — pause everything until a single face returns.
|
|
260
|
+
if (data.faceCount > 1) {
|
|
261
|
+
if (!stateRef.current.multipleFaces && phase !== 'capturing') {
|
|
262
|
+
cancelTimer();
|
|
263
|
+
setState((s) => ({
|
|
264
|
+
...s,
|
|
265
|
+
multipleFaces: true,
|
|
266
|
+
instruction: MULTIPLE_FACES_GUIDANCE,
|
|
267
|
+
wrongGesture: false,
|
|
268
|
+
}));
|
|
269
|
+
speak(MULTIPLE_FACES_GUIDANCE);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (stateRef.current.multipleFaces) {
|
|
274
|
+
// Single face restored — resume the current challenge timer.
|
|
275
|
+
const resume = stateRef.current.phase === 'challenge';
|
|
276
|
+
setState((s) => ({ ...s, multipleFaces: false }));
|
|
277
|
+
if (resume) {
|
|
278
|
+
const current = trackerRef.current!.current;
|
|
279
|
+
if (current) {
|
|
280
|
+
xHistoryRef.current = [];
|
|
281
|
+
earHistoryRef.current = [];
|
|
282
|
+
processingRef.current = false;
|
|
283
|
+
const timeout = config?.timeoutPerChallenge ?? current.config.timeoutSeconds;
|
|
284
|
+
setState((s) => ({ ...s, instruction: current.config.instruction }));
|
|
285
|
+
startTimer(timeout);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
checkPosition(data.faceSizeRatio);
|
|
291
|
+
|
|
292
|
+
const s = stateRef.current;
|
|
293
|
+
if (s.phase === 'positioning') {
|
|
294
|
+
// Advance only at the right distance AND with acceptable lighting.
|
|
295
|
+
if (s.positionGuidance == null && s.lightingGuidance == null) {
|
|
296
|
+
startNextChallenge();
|
|
297
|
+
} else if (s.positionGuidance) {
|
|
298
|
+
speak(positionGuidanceText(s.positionGuidance));
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (s.phase === 'challenge') {
|
|
304
|
+
if (processingRef.current) return;
|
|
305
|
+
if (s.positionGuidance != null) {
|
|
306
|
+
// Gesture detection is unreliable at the wrong distance — also tell the
|
|
307
|
+
// user to move closer/further (deduped by the speaker), matching Flutter
|
|
308
|
+
// which speaks position guidance whenever it changes, in any phase.
|
|
309
|
+
speak(positionGuidanceText(s.positionGuidance));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Block gestures from passing in poor light — the native signals are noisy
|
|
313
|
+
// when it's too dark/bright, so a gesture must not register (and the selfie
|
|
314
|
+
// must not auto-capture) until the user is in good lighting. The banner
|
|
315
|
+
// already tells them to fix it.
|
|
316
|
+
if (s.lightingGuidance != null) {
|
|
317
|
+
// Reset the gesture windows so a half-formed gesture from the dark frames
|
|
318
|
+
// can't complete the instant lighting recovers.
|
|
319
|
+
xHistoryRef.current = [];
|
|
320
|
+
earHistoryRef.current = [];
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Update history windows.
|
|
324
|
+
xHistoryRef.current = pushHistory(xHistoryRef.current, data.headEulerAngleX);
|
|
325
|
+
earHistoryRef.current = pushHistory(earHistoryRef.current, eyeAverageOpenProbability(data));
|
|
326
|
+
checkGesture(data);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
// checkGesture is defined below and stable via refs
|
|
330
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
331
|
+
[cancelTimer, checkPosition, config, speak, startNextChallenge, startTimer],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// checkGesture reads the current challenge + histories from refs.
|
|
335
|
+
const checkGesture = useCallback(
|
|
336
|
+
(data: LivenessFaceData) => {
|
|
337
|
+
const current = trackerRef.current!.current;
|
|
338
|
+
if (!current) return;
|
|
339
|
+
const type = current.config.type;
|
|
340
|
+
// The iOS Vision pitch is a landmark proxy that gets contaminated when the
|
|
341
|
+
// head turns (turning shifts the nose-vs-eyeline geometry and reads as a
|
|
342
|
+
// nod). Reject nod while the head is meaningfully turned so a turn can't
|
|
343
|
+
// false-trigger a nod. (Android ML Kit pitch is robust; this guard is
|
|
344
|
+
// harmless there.)
|
|
345
|
+
const detected =
|
|
346
|
+
type === 'nod'
|
|
347
|
+
? detectNod(xHistoryRef.current) && Math.abs(data.headEulerAngleY) < 18
|
|
348
|
+
: type === 'turn'
|
|
349
|
+
? detectTurn(data.headEulerAngleY)
|
|
350
|
+
: type === 'blink'
|
|
351
|
+
? detectBlink(earHistoryRef.current)
|
|
352
|
+
: detectSmile(data.smilingProbability);
|
|
353
|
+
|
|
354
|
+
if (detected) {
|
|
355
|
+
onChallengePassed();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Wrong-gesture feedback — user is clearly doing a DIFFERENT gesture.
|
|
360
|
+
const isTurning = detectTurn(data.headEulerAngleY);
|
|
361
|
+
const isSmiling = detectSmile(data.smilingProbability);
|
|
362
|
+
const wrong =
|
|
363
|
+
type === 'nod'
|
|
364
|
+
? isTurning || isSmiling
|
|
365
|
+
: type === 'turn'
|
|
366
|
+
? isSmiling
|
|
367
|
+
: type === 'blink'
|
|
368
|
+
? isTurning || isSmiling
|
|
369
|
+
: isTurning;
|
|
370
|
+
if (wrong !== stateRef.current.wrongGesture) {
|
|
371
|
+
setState((s) => ({ ...s, wrongGesture: wrong }));
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[onChallengePassed],
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const onNoFace = useCallback(() => {
|
|
378
|
+
if (!stateRef.current.faceDetected) return;
|
|
379
|
+
if (isTerminal(stateRef.current.phase)) return;
|
|
380
|
+
setState((s) => ({ ...s, faceDetected: false, positionGuidance: null, multipleFaces: false }));
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
const setLighting = useCallback(
|
|
384
|
+
(guidance: LightingGuidance | null) => {
|
|
385
|
+
const prev = stateRef.current.lightingGuidance;
|
|
386
|
+
const phase = stateRef.current.phase;
|
|
387
|
+
setState((s) => {
|
|
388
|
+
if (guidance === s.lightingGuidance) return s;
|
|
389
|
+
// Lighting only matters before/while reaching capture.
|
|
390
|
+
if (s.phase === 'capturing' || isTerminal(s.phase)) {
|
|
391
|
+
return s.lightingGuidance != null ? { ...s, lightingGuidance: null } : s;
|
|
392
|
+
}
|
|
393
|
+
return { ...s, lightingGuidance: guidance };
|
|
394
|
+
});
|
|
395
|
+
// During a challenge, pause the per-challenge timer while lighting is poor so
|
|
396
|
+
// the user isn't timed out while moving to better light, and resume when it
|
|
397
|
+
// recovers (mirrors the multi-face pause). Gestures themselves are blocked in
|
|
398
|
+
// the onFace handler until lighting is good. Banner only — not spoken.
|
|
399
|
+
if (phase === 'challenge') {
|
|
400
|
+
if (guidance != null && prev == null) {
|
|
401
|
+
cancelTimer();
|
|
402
|
+
} else if (guidance == null && prev != null) {
|
|
403
|
+
const current = trackerRef.current?.current;
|
|
404
|
+
if (current) {
|
|
405
|
+
xHistoryRef.current = [];
|
|
406
|
+
earHistoryRef.current = [];
|
|
407
|
+
startTimer(config?.timeoutPerChallenge ?? current.config.timeoutSeconds);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
[cancelTimer, startTimer, config],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const markComplete = useCallback(() => {
|
|
416
|
+
if (stateRef.current.phase !== 'capturing') return;
|
|
417
|
+
cancelTimer();
|
|
418
|
+
setState((s) => ({
|
|
419
|
+
...s,
|
|
420
|
+
phase: 'complete',
|
|
421
|
+
instruction: 'Verification complete',
|
|
422
|
+
activeChallenge: null,
|
|
423
|
+
positionGuidance: null,
|
|
424
|
+
}));
|
|
425
|
+
}, [cancelTimer]);
|
|
426
|
+
|
|
427
|
+
const reset = useCallback(() => {
|
|
428
|
+
cancelTimer();
|
|
429
|
+
if (passTimeoutRef.current) clearTimeout(passTimeoutRef.current);
|
|
430
|
+
trackerRef.current = new ChallengeTracker(pickChallenges(config));
|
|
431
|
+
xHistoryRef.current = [];
|
|
432
|
+
earHistoryRef.current = [];
|
|
433
|
+
processingRef.current = false;
|
|
434
|
+
readyFiredRef.current = false;
|
|
435
|
+
speakerRef.current?.reset();
|
|
436
|
+
setState({
|
|
437
|
+
phase: 'positioning',
|
|
438
|
+
instruction: 'Position your face in the circle',
|
|
439
|
+
activeChallenge: null,
|
|
440
|
+
timeoutRemaining: 0,
|
|
441
|
+
completedCount: 0,
|
|
442
|
+
totalCount: trackerRef.current.totalCount,
|
|
443
|
+
positionGuidance: null,
|
|
444
|
+
lightingGuidance: null,
|
|
445
|
+
multipleFaces: false,
|
|
446
|
+
wrongGesture: false,
|
|
447
|
+
faceDetected: false,
|
|
448
|
+
failureReason: null,
|
|
449
|
+
});
|
|
450
|
+
}, [cancelTimer, config]);
|
|
451
|
+
|
|
452
|
+
// Fire onReadyToCapture once when we enter `capturing`.
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (state.phase === 'capturing' && !readyFiredRef.current) {
|
|
455
|
+
readyFiredRef.current = true;
|
|
456
|
+
onReadyToCapture?.();
|
|
457
|
+
}
|
|
458
|
+
}, [state.phase, onReadyToCapture]);
|
|
459
|
+
|
|
460
|
+
// Cleanup timers + speech on unmount.
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
return () => {
|
|
463
|
+
cancelTimer();
|
|
464
|
+
if (passTimeoutRef.current) clearTimeout(passTimeoutRef.current);
|
|
465
|
+
speakerRef.current?.stop();
|
|
466
|
+
};
|
|
467
|
+
}, [cancelTimer]);
|
|
468
|
+
|
|
469
|
+
return useMemo(
|
|
470
|
+
() => ({
|
|
471
|
+
...state,
|
|
472
|
+
onFace,
|
|
473
|
+
onNoFace,
|
|
474
|
+
setLighting,
|
|
475
|
+
markComplete,
|
|
476
|
+
reset,
|
|
477
|
+
shouldCapture: state.phase === 'capturing',
|
|
478
|
+
}),
|
|
479
|
+
[state, onFace, onNoFace, setLighting, markComplete, reset],
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Re-export the challenge pool so the avatar/UI can label upcoming gestures.
|
|
484
|
+
export { CHALLENGE_POOL, DEFAULT_LIVENESS_CONFIG };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// VisionCameraFaceDetector — the default FaceDetectorService, backed by the
|
|
3
|
+
// native VisionCamera v5 **Nitro** face detector (Apple Vision on iOS, Google
|
|
4
|
+
// ML Kit on Android; see ios/ + android/, generated from
|
|
5
|
+
// `../specs/MyazaFaceDetector.nitro.ts`).
|
|
6
|
+
//
|
|
7
|
+
// v5 frame processors run in a WORKLET on the camera thread. To call a Nitro
|
|
8
|
+
// HybridObject from that worklet, the object is **boxed** on the JS thread and
|
|
9
|
+
// **unboxed** inside the worklet (Nitro's worklet-sharing mechanism). The camera
|
|
10
|
+
// frame never crosses the JS bridge — only the small `FaceResult` comes back.
|
|
11
|
+
//
|
|
12
|
+
// This module exposes:
|
|
13
|
+
// 1. `detectFaceOnFrame(frame)` — a WORKLET that unboxes the detector, runs it
|
|
14
|
+
// on the frame, and returns `LivenessFaceData | null`. LivenessStep calls
|
|
15
|
+
// this inside its `useFrameOutput({ onFrame })`.
|
|
16
|
+
// 2. A `FaceDetectorService` registration (at module load) so the rest of the
|
|
17
|
+
// SDK — and `createFaceDetectorService()` — resolves to this detector by
|
|
18
|
+
// default, mirroring the Flutter SDK's `NativeFaceDetectorService`.
|
|
19
|
+
//
|
|
20
|
+
// If the native Nitro detector isn't registered (e.g. Expo Go, or a build
|
|
21
|
+
// without the SDK's native code), `hasHybridObject` is false and the worklet
|
|
22
|
+
// falls back to "no face" — the JS flow degrades gracefully instead of crashing.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
26
|
+
import type { BoxedHybridObject } from 'react-native-nitro-modules';
|
|
27
|
+
import type { Frame } from 'react-native-vision-camera';
|
|
28
|
+
|
|
29
|
+
import type { FaceResult, MyazaFaceDetector } from '../specs/MyazaFaceDetector.nitro';
|
|
30
|
+
import {
|
|
31
|
+
registerFaceDetectorFactory,
|
|
32
|
+
type FaceDetectorService,
|
|
33
|
+
type FaceDetectorFrame,
|
|
34
|
+
} from './faceDetector';
|
|
35
|
+
import type { LivenessFaceData } from './types';
|
|
36
|
+
|
|
37
|
+
// Create the native Nitro detector once. Guarded by `hasHybridObject` so a build
|
|
38
|
+
// without the native code (Expo Go) degrades to "no face" rather than throwing.
|
|
39
|
+
const detector: MyazaFaceDetector | null = NitroModules.hasHybridObject('MyazaFaceDetector')
|
|
40
|
+
? NitroModules.createHybridObject<MyazaFaceDetector>('MyazaFaceDetector')
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
// Box the detector so it can be unboxed inside the camera-thread worklet.
|
|
44
|
+
const boxedDetector: BoxedHybridObject<MyazaFaceDetector> | null = detector
|
|
45
|
+
? NitroModules.box(detector)
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps the native {@link FaceResult} to a {@link LivenessFaceData}, or `null`
|
|
50
|
+
* when no face is present (`faceCount === 0`). A worklet — runs on the camera
|
|
51
|
+
* thread.
|
|
52
|
+
*/
|
|
53
|
+
function toFaceData(raw: FaceResult | null): LivenessFaceData | null {
|
|
54
|
+
'worklet';
|
|
55
|
+
// Always forward the result (even with faceCount 0) so the caller can read
|
|
56
|
+
// `brightness` for the low-light gate before a face is in frame. `faceCount`
|
|
57
|
+
// distinguishes face / no-face downstream.
|
|
58
|
+
if (raw == null) return null;
|
|
59
|
+
return {
|
|
60
|
+
headEulerAngleX: raw.headEulerAngleX,
|
|
61
|
+
headEulerAngleY: raw.headEulerAngleY,
|
|
62
|
+
headEulerAngleZ: raw.headEulerAngleZ,
|
|
63
|
+
smilingProbability: raw.smilingProbability,
|
|
64
|
+
leftEyeOpenProbability: raw.leftEyeOpenProbability,
|
|
65
|
+
rightEyeOpenProbability: raw.rightEyeOpenProbability,
|
|
66
|
+
faceSizeRatio: raw.faceSizeRatio,
|
|
67
|
+
faceCount: raw.faceCount,
|
|
68
|
+
brightness: raw.brightness,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* WORKLET: runs native face detection on a VisionCamera v5 frame and returns the
|
|
74
|
+
* dominant face's gesture signals, or `null` when no face is present / the native
|
|
75
|
+
* detector is unavailable. Call this from inside `useFrameOutput({ onFrame })`.
|
|
76
|
+
*/
|
|
77
|
+
export function detectFaceOnFrame(frame: Frame): LivenessFaceData | null {
|
|
78
|
+
'worklet';
|
|
79
|
+
if (boxedDetector == null) return null;
|
|
80
|
+
const d = boxedDetector.unbox();
|
|
81
|
+
return toFaceData(d.detectFace(frame));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the native Nitro face detector is installed in this build. */
|
|
85
|
+
export function isNativeFaceDetectorAvailable(): boolean {
|
|
86
|
+
return detector != null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// FaceDetectorService registration
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The default detector service. Its `detect(frame)` delegates to the worklet
|
|
95
|
+
* helper — `frame` here is the opaque VisionCamera `Frame`. In practice the
|
|
96
|
+
* LivenessStep calls `detectFaceOnFrame` directly inside its frame output
|
|
97
|
+
* worklet; this service exists so the registry / `hasFaceDetectorFactory()`
|
|
98
|
+
* contract mirrors Flutter's native default.
|
|
99
|
+
*/
|
|
100
|
+
class VisionCameraFaceDetector implements FaceDetectorService {
|
|
101
|
+
initialize(): void {
|
|
102
|
+
/* detector is created at module load; nothing per-instance to do */
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
detect(frame: FaceDetectorFrame): LivenessFaceData | null {
|
|
106
|
+
return detectFaceOnFrame(frame as Frame);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dispose(): void {
|
|
110
|
+
/* the native Nitro detector is process-wide; nothing to release per instance */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Install as the default factory unless a host already overrode it (tests call
|
|
115
|
+
// `registerFaceDetectorFactory(StubFaceDetectorService.new)` before importing).
|
|
116
|
+
registerFaceDetectorFactory(() => new VisionCameraFaceDetector());
|
|
117
|
+
|
|
118
|
+
export { VisionCameraFaceDetector };
|