react-native-biometric-verifier 0.0.16 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-biometric-verifier",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "A React Native module for biometric verification with face recognition and QR code scanning",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import {
3
3
  View,
4
4
  TouchableOpacity,
@@ -8,6 +8,7 @@ import {
8
8
  Platform,
9
9
  AppState,
10
10
  BackHandler,
11
+ Animated,
11
12
  } from 'react-native';
12
13
  import Icon from 'react-native-vector-icons/MaterialIcons';
13
14
  import {
@@ -39,6 +40,7 @@ const CaptureImageWithoutEdit = React.memo(
39
40
  const [faces, setFaces] = useState([]);
40
41
  const [singleFaceDetected, setSingleFaceDetected] = useState(false);
41
42
  const [cameraError, setCameraError] = useState(null);
43
+ const [livenessStep, setLivenessStep] = useState(0);
42
44
 
43
45
  const captured = useRef(false);
44
46
  const appState = useRef(AppState.currentState);
@@ -46,11 +48,15 @@ const CaptureImageWithoutEdit = React.memo(
46
48
  const initializationAttempts = useRef(0);
47
49
  const maxInitializationAttempts = 3;
48
50
 
51
+ // Animation values
52
+ const instructionAnim = useRef(new Animated.Value(1)).current;
53
+
49
54
  // Reset capture state
50
55
  const resetCaptureState = useCallback(() => {
51
56
  captured.current = false;
52
57
  setSingleFaceDetected(false);
53
58
  setFaces([]);
59
+ setLivenessStep(0);
54
60
  }, []);
55
61
 
56
62
  // Code scanner
@@ -132,19 +138,46 @@ const CaptureImageWithoutEdit = React.memo(
132
138
  }
133
139
  }, []);
134
140
 
141
+ const onLivenessUpdate = useCallback((step) => {
142
+ setLivenessStep(step);
143
+
144
+ // Animate instruction change
145
+ instructionAnim.setValue(0);
146
+ Animated.timing(instructionAnim, {
147
+ toValue: 1,
148
+ duration: 300,
149
+ useNativeDriver: true,
150
+ }).start();
151
+ }, [instructionAnim]);
152
+
135
153
  // Use the face detection frame processor hook
136
154
  const {
137
155
  frameProcessor,
138
156
  resetCaptureState: resetFrameProcessor,
157
+ forceResetCaptureState,
139
158
  updateShowCodeScanner,
159
+ updateIsActive,
140
160
  capturedSV,
141
161
  } = useFaceDetectionFrameProcessor({
142
162
  onStableFaceDetected,
143
163
  onFacesUpdate,
164
+ onLivenessUpdate,
144
165
  showCodeScanner,
145
166
  isLoading,
167
+ isActive: showCamera && cameraInitialized, // Pass active state
146
168
  });
147
169
 
170
+ // Sync captured state with the shared value
171
+ useEffect(() => {
172
+ if (capturedSV?.value && !captured.current) {
173
+ captured.current = true;
174
+ setSingleFaceDetected(true);
175
+ } else if (!capturedSV?.value && captured.current) {
176
+ captured.current = false;
177
+ setSingleFaceDetected(false);
178
+ }
179
+ }, [capturedSV?.value]);
180
+
148
181
  // Initialize camera
149
182
  const initializeCamera = useCallback(async () => {
150
183
  try {
@@ -203,8 +236,15 @@ const CaptureImageWithoutEdit = React.memo(
203
236
  return () => {
204
237
  isMounted.current = false;
205
238
  setShowCamera(false);
239
+ // Reset frame processor state on unmount
240
+ forceResetCaptureState();
206
241
  };
207
- }, [initializeCamera]);
242
+ }, [initializeCamera, forceResetCaptureState]);
243
+
244
+ // Update frame processor active state when camera state changes
245
+ useEffect(() => {
246
+ updateIsActive(showCamera && cameraInitialized);
247
+ }, [showCamera, cameraInitialized, updateIsActive]);
208
248
 
209
249
  // app state change
210
250
  useEffect(() => {
@@ -213,6 +253,11 @@ const CaptureImageWithoutEdit = React.memo(
213
253
  if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
214
254
  if (cameraPermission === 'granted') {
215
255
  setShowCamera(true);
256
+ // Reset capture state when app comes back to foreground
257
+ if (captured.current) {
258
+ forceResetCaptureState();
259
+ resetCaptureState();
260
+ }
216
261
  }
217
262
  } else if (nextAppState.match(/inactive|background/)) {
218
263
  setShowCamera(false);
@@ -230,7 +275,7 @@ const CaptureImageWithoutEdit = React.memo(
230
275
  console.error('Error removing app state listener:', error);
231
276
  }
232
277
  };
233
- }, [cameraPermission]);
278
+ }, [cameraPermission, forceResetCaptureState, resetCaptureState]);
234
279
 
235
280
  // android back handler
236
281
  useEffect(() => {
@@ -271,12 +316,17 @@ const CaptureImageWithoutEdit = React.memo(
271
316
 
272
317
  setCameraError(errorMessage);
273
318
  setShowCamera(false);
274
- }, []);
319
+ // Reset capture state on error
320
+ forceResetCaptureState();
321
+ resetCaptureState();
322
+ }, [forceResetCaptureState, resetCaptureState]);
275
323
 
276
324
  const handleCameraInitialized = useCallback(() => {
277
325
  setCameraInitialized(true);
278
326
  setCameraError(null);
279
- }, []);
327
+ // Ensure frame processor is active after initialization
328
+ updateIsActive(true);
329
+ }, [updateIsActive]);
280
330
 
281
331
  // format selection
282
332
  const format = useCameraFormat(cameraDevice, [
@@ -288,22 +338,51 @@ const CaptureImageWithoutEdit = React.memo(
288
338
  useEffect(() => {
289
339
  try {
290
340
  updateShowCodeScanner(!!showCodeScanner);
341
+ // Reset capture state when switching modes
342
+ if (showCodeScanner && captured.current) {
343
+ forceResetCaptureState();
344
+ resetCaptureState();
345
+ }
291
346
  } catch (error) {
292
347
  console.error('Error updating code scanner:', error);
293
348
  }
294
- }, [showCodeScanner, updateShowCodeScanner]);
349
+ }, [showCodeScanner, updateShowCodeScanner, forceResetCaptureState, resetCaptureState]);
295
350
 
296
351
  // Retry camera initialization
297
352
  const handleRetry = useCallback(() => {
298
353
  setCameraError(null);
299
354
  setShowCamera(false);
355
+ // Reset both local and frame processor state
356
+ forceResetCaptureState();
300
357
  resetCaptureState();
301
358
  setTimeout(() => {
302
359
  if (isMounted.current) {
303
360
  initializeCamera();
304
361
  }
305
362
  }, 500);
306
- }, [initializeCamera, resetCaptureState]);
363
+ }, [initializeCamera, resetCaptureState, forceResetCaptureState]);
364
+
365
+ // Render liveness instructions
366
+ const renderLivenessInstruction = () => {
367
+ if (faces.length === 0) return 'Position your face in the frame';
368
+
369
+ switch (livenessStep) {
370
+ case 0:
371
+ return 'Look straight into the camera';
372
+ case 1:
373
+ return 'Turn your face to the right';
374
+ case 2:
375
+ return 'Now turn your face to the left';
376
+ case 3:
377
+ if (captured.current) {
378
+ return 'Capturing...';
379
+ } else {
380
+ return 'Perfect! Hold still for capture...';
381
+ }
382
+ default:
383
+ return 'Position your face in the frame';
384
+ }
385
+ };
307
386
 
308
387
  // camera placeholder / UI
309
388
  const renderCameraPlaceholder = () => (
@@ -392,32 +471,65 @@ const CaptureImageWithoutEdit = React.memo(
392
471
  renderCameraPlaceholder()
393
472
  )}
394
473
 
395
- {/* Face detection status text */}
474
+ {/* Liveness Detection UI */}
396
475
  {!showCodeScanner && shouldRenderCamera && (
476
+ <View style={styles.livenessContainer}>
477
+ {/* Instructions with animation */}
478
+ <Animated.View
479
+ style={[
480
+ styles.instructionContainer,
481
+ {
482
+ opacity: instructionAnim,
483
+ transform: [
484
+ {
485
+ translateY: instructionAnim.interpolate({
486
+ inputRange: [0, 1],
487
+ outputRange: [10, 0],
488
+ }),
489
+ },
490
+ ],
491
+ },
492
+ ]}
493
+ >
494
+ <Text style={styles.instructionText}>{renderLivenessInstruction()}</Text>
495
+ </Animated.View>
496
+
497
+ {/* Step Indicators */}
498
+ <View style={styles.stepsContainer}>
499
+ {[0, 1, 2].map((step) => (
500
+ <React.Fragment key={step}>
501
+ <View style={[
502
+ styles.stepIndicator,
503
+ livenessStep > step ? styles.stepCompleted :
504
+ livenessStep === step ? styles.stepCurrent : styles.stepPending
505
+ ]}>
506
+ <Text style={styles.stepText}>{step + 1}</Text>
507
+ </View>
508
+ {step < 2 && (
509
+ <View style={[
510
+ styles.stepConnector,
511
+ livenessStep > step ? styles.connectorCompleted : {}
512
+ ]} />
513
+ )}
514
+ </React.Fragment>
515
+ ))}
516
+ </View>
517
+
518
+ {/* Step Labels */}
519
+ <View style={styles.stepLabelsContainer}>
520
+ <Text style={styles.stepLabel}>Center</Text>
521
+ <Text style={styles.stepLabel}>Right</Text>
522
+ <Text style={styles.stepLabel}>Left</Text>
523
+ </View>
524
+ </View>
525
+ )}
526
+
527
+ {/* Face detection status */}
528
+ {!showCodeScanner && shouldRenderCamera && faces.length > 1 && (
397
529
  <View style={styles.faceDetectionStatus}>
398
- {faces.length === 0 && (
399
- <Text style={styles.faceDetectionText}>
400
- Position your face in the frame
401
- </Text>
402
- )}
403
- {faces.length > 1 && (
404
- <Text style={[styles.faceDetectionText, styles.multipleFacesText]}>
405
- Multiple faces detected. Please ensure only one person is in the frame.
406
- </Text>
407
- )}
408
- {singleFaceDetected && !captured.current && (
409
- <Text style={[styles.faceDetectionText, styles.singleFaceText]}>
410
- Face detected. Hold still...
411
- </Text>
412
- )}
413
- {captured.current && (
414
- <View style={styles.capturingContainer}>
415
- <ActivityIndicator size="small" color={COLORS.light} />
416
- <Text style={[styles.faceDetectionText, styles.capturingText]}>
417
- Capturing...
418
- </Text>
419
- </View>
420
- )}
530
+ <Text style={[styles.faceDetectionText, styles.multipleFacesText]}>
531
+ Multiple faces detected. Please ensure only one person is in the frame.
532
+ </Text>
421
533
  </View>
422
534
  )}
423
535
 
@@ -427,10 +539,10 @@ const CaptureImageWithoutEdit = React.memo(
427
539
  <TouchableOpacity
428
540
  style={[
429
541
  styles.flipButton,
430
- (isLoading || captured.current) && styles.flipButtonDisabled,
542
+ (isLoading || captured.current || livenessStep > 0) && styles.flipButtonDisabled,
431
543
  ]}
432
544
  onPress={onToggleCamera}
433
- disabled={isLoading || captured.current}
545
+ disabled={isLoading || captured.current || livenessStep > 0}
434
546
  accessibilityLabel="Flip camera"
435
547
  >
436
548
  <Icon name="flip-camera-ios" size={28} color={COLORS.light} />
@@ -541,19 +653,80 @@ const styles = StyleSheet.create({
541
653
  multipleFacesText: {
542
654
  backgroundColor: 'rgba(255,0,0,0.7)',
543
655
  },
544
- singleFaceText: {
545
- backgroundColor: 'rgba(0,255,0,0.7)',
656
+ // Liveness detection styles
657
+ livenessContainer: {
658
+ position: 'absolute',
659
+ top: 40,
660
+ left: 0,
661
+ right: 0,
662
+ alignItems: 'center',
663
+ paddingHorizontal: 20,
546
664
  },
547
- capturingText: {
548
- backgroundColor: 'rgba(0,100,255,0.7)',
665
+ instructionContainer: {
666
+ backgroundColor: 'rgba(0,0,0,0.8)',
667
+ paddingHorizontal: 20,
668
+ paddingVertical: 12,
669
+ borderRadius: 8,
670
+ marginBottom: 20,
671
+ },
672
+ instructionText: {
673
+ color: 'white',
674
+ fontSize: 16,
675
+ fontWeight: 'bold',
676
+ textAlign: 'center',
549
677
  },
550
- capturingContainer: {
678
+ stepsContainer: {
551
679
  flexDirection: 'row',
552
680
  alignItems: 'center',
553
- backgroundColor: 'rgba(0,100,255,0.7)',
554
- paddingHorizontal: 16,
555
- paddingVertical: 8,
556
- borderRadius: 8,
681
+ marginBottom: 8,
682
+ },
683
+ stepIndicator: {
684
+ width: 30,
685
+ height: 30,
686
+ borderRadius: 15,
687
+ justifyContent: 'center',
688
+ alignItems: 'center',
689
+ borderWidth: 2,
690
+ },
691
+ stepCompleted: {
692
+ backgroundColor: COLORS.primary,
693
+ borderColor: COLORS.primary,
694
+ },
695
+ stepCurrent: {
696
+ backgroundColor: COLORS.primary,
697
+ borderColor: COLORS.primary,
698
+ opacity: 0.7,
699
+ },
700
+ stepPending: {
701
+ backgroundColor: 'transparent',
702
+ borderColor: 'rgba(255,255,255,0.5)',
703
+ },
704
+ stepText: {
705
+ color: 'white',
706
+ fontSize: 12,
707
+ fontWeight: 'bold',
708
+ },
709
+ stepConnector: {
710
+ flex: 1,
711
+ height: 2,
712
+ backgroundColor: 'rgba(255,255,255,0.3)',
713
+ marginHorizontal: 4,
714
+ },
715
+ connectorCompleted: {
716
+ backgroundColor: COLORS.primary,
717
+ },
718
+ stepLabelsContainer: {
719
+ flexDirection: 'row',
720
+ justifyContent: 'space-between',
721
+ width: '100%',
722
+ paddingHorizontal: 10,
723
+ },
724
+ stepLabel: {
725
+ color: 'white',
726
+ fontSize: 12,
727
+ opacity: 0.8,
728
+ textAlign: 'center',
729
+ flex: 1,
557
730
  },
558
731
  });
559
732
 
@@ -1,11 +1,12 @@
1
- import React from 'react';
1
+ import React, { useEffect, useRef } from 'react';
2
2
  import { View, Text, Animated, StyleSheet, Platform } from 'react-native';
3
3
  import { COLORS } from '../utils/constants';
4
4
 
5
5
  export const CountdownTimer = ({ duration, currentTime }) => {
6
- const progress = React.useRef(new Animated.Value(1)).current;
6
+ const progress = useRef(new Animated.Value(1)).current;
7
+ const pulseAnim = useRef(new Animated.Value(1)).current; // for pulsing effect
7
8
 
8
- React.useEffect(() => {
9
+ useEffect(() => {
9
10
  Animated.timing(progress, {
10
11
  toValue: currentTime / duration,
11
12
  duration: 1000,
@@ -13,26 +14,69 @@ export const CountdownTimer = ({ duration, currentTime }) => {
13
14
  }).start();
14
15
  }, [currentTime, duration, progress]);
15
16
 
17
+ // Pulse animation only in last 10 seconds
18
+ useEffect(() => {
19
+ if (currentTime <= 10 && currentTime > 0) {
20
+ Animated.loop(
21
+ Animated.sequence([
22
+ Animated.timing(pulseAnim, {
23
+ toValue: 1.2,
24
+ duration: 500,
25
+ useNativeDriver: true,
26
+ }),
27
+ Animated.timing(pulseAnim, {
28
+ toValue: 1,
29
+ duration: 500,
30
+ useNativeDriver: true,
31
+ }),
32
+ ])
33
+ ).start();
34
+ } else {
35
+ pulseAnim.stopAnimation();
36
+ pulseAnim.setValue(1); // reset
37
+ }
38
+ }, [currentTime, pulseAnim]);
39
+
16
40
  const circumference = 2 * Math.PI * 40;
17
41
  const strokeDashoffset = progress.interpolate({
18
42
  inputRange: [0, 1],
19
43
  outputRange: [circumference, 0],
20
44
  });
21
45
 
46
+ // Change color when < 10 seconds
47
+ const isEnding = currentTime <= 10;
48
+ const circleColor = isEnding ? 'red' : COLORS.light;
49
+ const textColor = isEnding ? 'red' : COLORS.light;
50
+
22
51
  return (
23
52
  <View style={styles.container}>
24
- <View style={styles.timerWrapper}>
53
+ <Animated.View
54
+ style={[
55
+ styles.timerWrapper,
56
+ { transform: [{ scale: pulseAnim }] },
57
+ ]}
58
+ >
25
59
  <Animated.View style={styles.animatedWrapper}>
26
60
  <Animated.View
27
61
  style={[
28
62
  styles.progressCircle,
29
- { strokeDashoffset, transform: [{ rotate: '-90deg' }] },
63
+ {
64
+ strokeDashoffset,
65
+ transform: [{ rotate: '-90deg' }],
66
+ borderColor: circleColor,
67
+ borderLeftColor: 'transparent',
68
+ borderBottomColor: 'transparent',
69
+ },
30
70
  ]}
31
71
  />
32
72
  </Animated.View>
33
- <Text style={styles.timeText}>{currentTime}s</Text>
34
- </View>
35
- <Text style={styles.remainingText}>Remaining</Text>
73
+ <Text style={[styles.timeText, { color: textColor }]}>
74
+ {currentTime}s
75
+ </Text>
76
+ </Animated.View>
77
+ <Text style={[styles.remainingText, { color: textColor }]}>
78
+ Remaining
79
+ </Text>
36
80
  </View>
37
81
  );
38
82
  };
@@ -61,19 +105,14 @@ const styles = StyleSheet.create({
61
105
  height: 76,
62
106
  borderRadius: 38,
63
107
  borderWidth: 3,
64
- borderColor: COLORS.light,
65
- borderLeftColor: 'transparent',
66
- borderBottomColor: 'transparent',
67
108
  },
68
109
  timeText: {
69
110
  fontSize: 20,
70
111
  fontWeight: '700',
71
- color: COLORS.light,
72
112
  fontFamily: Platform.OS === 'ios' ? 'Helvetica Neue' : 'sans-serif-medium',
73
113
  },
74
114
  remainingText: {
75
115
  fontSize: 14,
76
- color: COLORS.light,
77
116
  marginTop: 5,
78
117
  opacity: 0.8,
79
118
  fontFamily: Platform.OS === 'ios' ? 'Helvetica Neue' : 'sans-serif',
@@ -16,7 +16,7 @@ export default function Loader({
16
16
  overlayColor = 'rgba(0,0,0,0.4)',
17
17
  loaderColor = 'lightblue',
18
18
  size = 50,
19
- gifSource = {uri:`http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif`},
19
+ gifSource = {uri: `http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif`},
20
20
  message = '',
21
21
  messageStyle = {},
22
22
  animationType = 'fade',
@@ -27,6 +27,19 @@ export default function Loader({
27
27
  const [rotation] = useState(new Animated.Value(0));
28
28
  const [pulse] = useState(new Animated.Value(1));
29
29
  const [fade] = useState(new Animated.Value(0));
30
+ const [imageSource, setImageSource] = useState(gifSource);
31
+
32
+ // Reset imageSource whenever gifSource prop changes
33
+ useEffect(() => {
34
+ setImageSource(gifSource);
35
+ }, [gifSource]);
36
+
37
+ const handleImageError = () => {
38
+ // Fallback to default heartpulse GIF when image fails to load
39
+ setImageSource({
40
+ uri: "http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif"
41
+ });
42
+ };
30
43
 
31
44
  // Rotation animation
32
45
  useEffect(() => {
@@ -73,7 +86,9 @@ export default function Loader({
73
86
  const loaderContent = gifSource ? (
74
87
  <FastImage
75
88
  style={[styles.icon_style, { width: normalize(size), height: normalize(size) }]}
76
- source={gifSource}
89
+ source={imageSource}
90
+ resizeMode={FastImage.resizeMode.contain}
91
+ onError={handleImageError}
77
92
  />
78
93
  ) : (
79
94
  <Animated.View style={[
@@ -120,7 +135,6 @@ export default function Loader({
120
135
  ...(shadow && styles.shadowStyle)
121
136
  }
122
137
  ]}>
123
- {console.log('loadersource--------------------',JSON.stringify(gifSource))}
124
138
  {loaderContent}
125
139
  {message ? (
126
140
  <Text style={[styles.messageText, messageStyle]}>
@@ -1,21 +1,27 @@
1
- import { useCallback, useMemo } from 'react';
1
+ import { useCallback, useMemo, useEffect, useRef } from 'react';
2
2
  import { Worklets } from 'react-native-worklets-core';
3
3
  import { useFrameProcessor } from 'react-native-vision-camera';
4
4
  import { useFaceDetector } from 'react-native-vision-camera-face-detector';
5
5
 
6
- // Tuned constants
6
+ // Tuned constants for liveness detection
7
7
  const FACE_STABILITY_THRESHOLD = 3;
8
8
  const FACE_MOVEMENT_THRESHOLD = 15;
9
- const FRAME_PROCESSOR_MIN_INTERVAL_MS = 1000;
9
+ const FRAME_PROCESSOR_MIN_INTERVAL_MS = 800;
10
10
  const MIN_FACE_SIZE = 0.2;
11
11
 
12
+ // Liveness detection constants
13
+ const YAW_LEFT_THRESHOLD = -15; // face turned left (negative yaw)
14
+ const YAW_RIGHT_THRESHOLD = 15; // face turned right (positive yaw)
15
+ const YAW_CENTER_THRESHOLD = 5; // face centered
16
+
12
17
  export const useFaceDetectionFrameProcessor = ({
13
18
  onStableFaceDetected = () => {},
14
19
  onFacesUpdate = () => {},
20
+ onLivenessUpdate = () => {},
15
21
  showCodeScanner = false,
16
22
  isLoading = false,
23
+ isActive = true,
17
24
  }) => {
18
- // Face detector (fast mode only)
19
25
  const { detectFaces } = useFaceDetector({
20
26
  performanceMode: 'fast',
21
27
  landmarkMode: 'none',
@@ -24,144 +30,320 @@ export const useFaceDetectionFrameProcessor = ({
24
30
  minFaceSize: MIN_FACE_SIZE,
25
31
  });
26
32
 
27
- // Shared state
28
- const lastProcessedTime = useMemo(() => Worklets.createSharedValue(0), []);
29
- const lastX = useMemo(() => Worklets.createSharedValue(0), []);
30
- const lastY = useMemo(() => Worklets.createSharedValue(0), []);
31
- const lastW = useMemo(() => Worklets.createSharedValue(0), []);
32
- const lastH = useMemo(() => Worklets.createSharedValue(0), []);
33
- const stableCount = useMemo(() => Worklets.createSharedValue(0), []);
34
- const capturedSV = useMemo(() => Worklets.createSharedValue(false), []);
35
- const showCodeScannerSV = useMemo(
36
- () => Worklets.createSharedValue(showCodeScanner),
37
- [showCodeScanner]
33
+ const isMounted = useRef(true);
34
+
35
+ const sharedState = useMemo(() =>
36
+ Worklets.createSharedValue({
37
+ lastProcessedTime: 0,
38
+ lastX: 0,
39
+ lastY: 0,
40
+ lastW: 0,
41
+ lastH: 0,
42
+ stableCount: 0,
43
+ captured: false,
44
+ showCodeScanner: showCodeScanner,
45
+ isActive: isActive,
46
+ livenessStep: 0, // 0=look straight, 1=left done, 2=right done, 3=ready for capture
47
+ leftTurnVerified: false,
48
+ rightTurnVerified: false,
49
+ currentYaw: 0,
50
+ yawStableCount: 0,
51
+ }), []);
52
+
53
+ useEffect(() => {
54
+ if (!isMounted.current) return;
55
+
56
+ sharedState.value = {
57
+ ...sharedState.value,
58
+ showCodeScanner: showCodeScanner,
59
+ isActive: isActive
60
+ };
61
+
62
+ // Reset when becoming active again
63
+ if (isActive && sharedState.value.captured) {
64
+ sharedState.value = {
65
+ ...sharedState.value,
66
+ captured: false,
67
+ stableCount: 0,
68
+ livenessStep: 0,
69
+ leftTurnVerified: false,
70
+ rightTurnVerified: false,
71
+ yawStableCount: 0,
72
+ };
73
+ }
74
+ }, [showCodeScanner, isActive, sharedState]);
75
+
76
+ const runOnStable = useMemo(() =>
77
+ Worklets.createRunOnJS((faceRect) => {
78
+ try {
79
+ onStableFaceDetected?.(faceRect);
80
+ } catch (error) {
81
+ console.error('Error in runOnStable:', error);
82
+ }
83
+ }), [onStableFaceDetected]
38
84
  );
39
85
 
40
- // Safe JS callbacks
41
- const runOnStable = useMemo(
42
- () =>
43
- Worklets.createRunOnJS((x, y, width, height) => {
44
- try {
45
- onStableFaceDetected?.({
46
- x: Math.max(0, Math.round(x)),
47
- y: Math.max(0, Math.round(y)),
48
- width: Math.max(0, Math.round(width)),
49
- height: Math.max(0, Math.round(height)),
50
- });
51
- } catch {}
52
- }),
53
- [onStableFaceDetected]
86
+ const runOnFaces = useMemo(() =>
87
+ Worklets.createRunOnJS((count, progress, step) => {
88
+ try {
89
+ onFacesUpdate?.({ count, progress, step });
90
+ } catch (error) {
91
+ console.error('Error in runOnFaces:', error);
92
+ }
93
+ }), [onFacesUpdate]
54
94
  );
55
95
 
56
- const runOnFaces = useMemo(
57
- () =>
58
- Worklets.createRunOnJS((count, progress) => {
59
- try {
60
- onFacesUpdate?.({ count, progress });
61
- } catch {}
62
- }),
63
- [onFacesUpdate]
96
+ const runOnLiveness = useMemo(() =>
97
+ Worklets.createRunOnJS((step) => {
98
+ try {
99
+ onLivenessUpdate?.(step);
100
+ } catch (error) {
101
+ console.error('Error in runOnLiveness:', error);
102
+ }
103
+ }), [onLivenessUpdate]
64
104
  );
65
105
 
66
- // Frame processor
67
106
  const frameProcessor = useFrameProcessor((frame) => {
68
107
  'worklet';
108
+ const state = sharedState.value;
69
109
 
70
- // Kill switches
71
- if (showCodeScannerSV.value || capturedSV.value || isLoading) return;
110
+ // Early return conditions
111
+ if (state.showCodeScanner || state.captured || isLoading || !state.isActive) {
112
+ frame.release?.();
113
+ return;
114
+ }
72
115
 
73
116
  const now = frame?.timestamp ? frame.timestamp / 1e6 : Date.now();
74
- if (now - lastProcessedTime.value < FRAME_PROCESSOR_MIN_INTERVAL_MS) return;
75
- lastProcessedTime.value = now;
117
+ if (now - state.lastProcessedTime < FRAME_PROCESSOR_MIN_INTERVAL_MS) {
118
+ frame.release?.();
119
+ return;
120
+ }
76
121
 
77
122
  let detected;
78
123
  try {
79
124
  detected = detectFaces?.(frame);
80
- } catch {
81
- return; // skip invalid frame
125
+ frame.release?.();
126
+ } catch (error) {
127
+ frame.release?.();
128
+ return;
82
129
  }
83
130
 
84
131
  if (!detected || detected.length === 0) {
85
- // No faces → reset
86
- stableCount.value = 0;
87
- capturedSV.value = false;
88
- runOnFaces(0, 0);
132
+ sharedState.value = {
133
+ ...state,
134
+ stableCount: 0,
135
+ lastProcessedTime: now,
136
+ yawStableCount: 0
137
+ };
138
+ runOnFaces(0, 0, state.livenessStep);
89
139
  return;
90
140
  }
91
141
 
92
- if (detected.length === 1 && !capturedSV.value) {
93
- const f = detected[0];
94
- if (!f?.bounds) {
95
- runOnFaces(0, 0);
142
+ if (detected.length === 1 && !state.captured) {
143
+ const face = detected[0];
144
+ if (!face?.bounds || face.yawAngle === undefined) {
145
+ runOnFaces(0, 0, state.livenessStep);
96
146
  return;
97
147
  }
98
148
 
99
- // Clamp invalid values
100
- const x = Math.max(0, f.bounds.x ?? 0);
101
- const y = Math.max(0, f.bounds.y ?? 0);
102
- const width = Math.max(0, f.bounds.width ?? 0);
103
- const height = Math.max(0, f.bounds.height ?? 0);
104
-
105
- if (lastX.value === 0 && lastY.value === 0) {
106
- // First detection
107
- lastX.value = x;
108
- lastY.value = y;
109
- lastW.value = width;
110
- lastH.value = height;
111
- stableCount.value = 1;
112
- } else {
113
- const dx = Math.abs(x - lastX.value);
114
- const dy = Math.abs(y - lastY.value);
149
+ const yaw = face.yawAngle;
150
+ const x = Math.max(0, face.bounds.x);
151
+ const y = Math.max(0, face.bounds.y);
152
+ const width = Math.max(0, face.bounds.width);
153
+ const height = Math.max(0, face.bounds.height);
115
154
 
116
- if (dx < FACE_MOVEMENT_THRESHOLD && dy < FACE_MOVEMENT_THRESHOLD) {
117
- stableCount.value += 1;
155
+ let newLivenessStep = state.livenessStep;
156
+ let newLeftTurnVerified = state.leftTurnVerified;
157
+ let newRightTurnVerified = state.rightTurnVerified;
158
+ let newYawStableCount = state.yawStableCount;
159
+
160
+ // Liveness detection logic
161
+ if (newLivenessStep === 0) {
162
+ // Step 0: Wait for face to be centered and stable
163
+ if (Math.abs(yaw) < YAW_CENTER_THRESHOLD) {
164
+ newYawStableCount++;
165
+ if (newYawStableCount >= 2) {
166
+ newLivenessStep = 1; // Ready for left turn
167
+ runOnLiveness(newLivenessStep);
168
+ newYawStableCount = 0;
169
+ }
118
170
  } else {
119
- stableCount.value = 1; // restart stability
171
+ newYawStableCount = 0;
172
+ }
173
+ }
174
+ else if (newLivenessStep === 1 && !newLeftTurnVerified) {
175
+ // Step 1: Detect left turn
176
+ if (yaw < YAW_LEFT_THRESHOLD) {
177
+ newYawStableCount++;
178
+ if (newYawStableCount >= 2) {
179
+ newLeftTurnVerified = true;
180
+ newLivenessStep = 2; // Ready for right turn
181
+ runOnLiveness(newLivenessStep);
182
+ newYawStableCount = 0;
183
+ }
184
+ } else {
185
+ newYawStableCount = Math.max(0, newYawStableCount - 0.5);
186
+ }
187
+ }
188
+ else if (newLivenessStep === 2 && newLeftTurnVerified && !newRightTurnVerified) {
189
+ // Step 2: Detect right turn
190
+ if (yaw > YAW_RIGHT_THRESHOLD) {
191
+ newYawStableCount++;
192
+ if (newYawStableCount >= 2) {
193
+ newRightTurnVerified = true;
194
+ newLivenessStep = 3; // Ready for capture
195
+ runOnLiveness(newLivenessStep);
196
+ newYawStableCount = 0;
197
+ }
198
+ } else {
199
+ newYawStableCount = Math.max(0, newYawStableCount - 0.5);
200
+ }
201
+ }
202
+ else if (newLivenessStep === 3 && newLeftTurnVerified && newRightTurnVerified) {
203
+ // Step 3: Wait for face to return to center for capture
204
+ if (Math.abs(yaw) < YAW_CENTER_THRESHOLD) {
205
+ newYawStableCount++;
206
+ } else {
207
+ newYawStableCount = 0;
120
208
  }
121
-
122
- lastX.value = x;
123
- lastY.value = y;
124
- lastW.value = width;
125
- lastH.value = height;
126
209
  }
127
210
 
128
- if (stableCount.value >= FACE_STABILITY_THRESHOLD) {
129
- capturedSV.value = true;
130
- runOnStable(lastX.value, lastY.value, lastW.value, lastH.value);
211
+ // Calculate face stability for capture
212
+ let newStableCount = state.stableCount;
213
+ if (state.lastX === 0 && state.lastY === 0) {
214
+ newStableCount = 1;
131
215
  } else {
132
- runOnFaces(1, stableCount.value);
216
+ const dx = Math.abs(x - state.lastX);
217
+ const dy = Math.abs(y - state.lastY);
218
+ if (dx < FACE_MOVEMENT_THRESHOLD && dy < FACE_MOVEMENT_THRESHOLD) {
219
+ newStableCount = state.stableCount + 1;
220
+ } else {
221
+ newStableCount = 1;
222
+ }
223
+ }
224
+
225
+ // Update shared state
226
+ sharedState.value = {
227
+ ...state,
228
+ lastProcessedTime: now,
229
+ lastX: x,
230
+ lastY: y,
231
+ lastW: width,
232
+ lastH: height,
233
+ stableCount: newStableCount,
234
+ livenessStep: newLivenessStep,
235
+ leftTurnVerified: newLeftTurnVerified,
236
+ rightTurnVerified: newRightTurnVerified,
237
+ currentYaw: yaw,
238
+ yawStableCount: newYawStableCount,
239
+ };
240
+
241
+ // Calculate progress for UI
242
+ const progress = Math.min(100, (newStableCount / FACE_STABILITY_THRESHOLD) * 100);
243
+ runOnFaces(1, progress, newLivenessStep);
244
+
245
+ // Capture conditions: liveness complete + face stable + face centered
246
+ if (newLivenessStep === 3 &&
247
+ newLeftTurnVerified &&
248
+ newRightTurnVerified &&
249
+ newStableCount >= FACE_STABILITY_THRESHOLD &&
250
+ Math.abs(yaw) < YAW_CENTER_THRESHOLD &&
251
+ newYawStableCount >= 2) {
252
+
253
+ sharedState.value = {
254
+ ...sharedState.value,
255
+ captured: true
256
+ };
257
+ runOnStable({ x, y, width, height });
133
258
  }
259
+
134
260
  } else {
135
- // Multiple faces not allowed
136
- stableCount.value = 0;
137
- capturedSV.value = false;
138
- runOnFaces(detected.length, 0);
261
+ // Multiple faces or other conditions
262
+ sharedState.value = {
263
+ ...state,
264
+ stableCount: 0,
265
+ lastProcessedTime: now,
266
+ yawStableCount: 0
267
+ };
268
+ runOnFaces(detected.length, 0, state.livenessStep);
139
269
  }
140
270
  }, [detectFaces, isLoading]);
141
271
 
142
- // Reset everything safely
143
272
  const resetCaptureState = useCallback(() => {
144
- capturedSV.value = false;
145
- stableCount.value = 0;
146
- lastX.value = 0;
147
- lastY.value = 0;
148
- lastW.value = 0;
149
- lastH.value = 0;
150
- lastProcessedTime.value = 0;
151
- }, []);
152
-
153
- // Update QR/Scanner toggle
273
+ sharedState.value = {
274
+ ...sharedState.value,
275
+ lastProcessedTime: 0,
276
+ lastX: 0,
277
+ lastY: 0,
278
+ lastW: 0,
279
+ lastH: 0,
280
+ stableCount: 0,
281
+ captured: false,
282
+ livenessStep: 0,
283
+ leftTurnVerified: false,
284
+ rightTurnVerified: false,
285
+ currentYaw: 0,
286
+ yawStableCount: 0,
287
+ };
288
+ }, [sharedState]);
289
+
290
+ const forceResetCaptureState = useCallback(() => {
291
+ sharedState.value = {
292
+ lastProcessedTime: 0,
293
+ lastX: 0,
294
+ lastY: 0,
295
+ lastW: 0,
296
+ lastH: 0,
297
+ stableCount: 0,
298
+ captured: false,
299
+ showCodeScanner: sharedState.value.showCodeScanner,
300
+ isActive: sharedState.value.isActive,
301
+ livenessStep: 0,
302
+ leftTurnVerified: false,
303
+ rightTurnVerified: false,
304
+ currentYaw: 0,
305
+ yawStableCount: 0,
306
+ };
307
+ }, [sharedState]);
308
+
154
309
  const updateShowCodeScanner = useCallback(
155
310
  (value) => {
156
- showCodeScannerSV.value = !!value;
157
- },
158
- [showCodeScannerSV]
311
+ sharedState.value = {
312
+ ...sharedState.value,
313
+ showCodeScanner: !!value
314
+ };
315
+ },
316
+ [sharedState]
317
+ );
318
+
319
+ const updateIsActive = useCallback(
320
+ (active) => {
321
+ sharedState.value = {
322
+ ...sharedState.value,
323
+ isActive: active,
324
+ // Reset capture state when deactivating
325
+ captured: active ? sharedState.value.captured : false
326
+ };
327
+ },
328
+ [sharedState]
159
329
  );
160
330
 
331
+ // Cleanup on unmount
332
+ useEffect(() => {
333
+ isMounted.current = true;
334
+
335
+ return () => {
336
+ isMounted.current = false;
337
+ forceResetCaptureState();
338
+ };
339
+ }, [forceResetCaptureState]);
340
+
161
341
  return {
162
342
  frameProcessor,
163
343
  resetCaptureState,
344
+ forceResetCaptureState,
164
345
  updateShowCodeScanner,
165
- capturedSV,
346
+ updateIsActive,
347
+ capturedSV: { value: sharedState.value.captured },
166
348
  };
167
- };
349
+ };