@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.
Files changed (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. 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 };