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,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useEffect, useState, useCallback
|
|
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
|
-
{/*
|
|
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
|
-
{
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
backgroundColor: 'rgba(0,
|
|
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
|
-
|
|
678
|
+
stepsContainer: {
|
|
551
679
|
flexDirection: 'row',
|
|
552
680
|
alignItems: 'center',
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 =
|
|
6
|
+
const progress = useRef(new Animated.Value(1)).current;
|
|
7
|
+
const pulseAnim = useRef(new Animated.Value(1)).current; // for pulsing effect
|
|
7
8
|
|
|
8
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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',
|
package/src/components/Loader.js
CHANGED
|
@@ -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
|
|
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={
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
57
|
-
() =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
[
|
|
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
|
-
//
|
|
71
|
-
if (
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
125
|
+
frame.release?.();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
frame.release?.();
|
|
128
|
+
return;
|
|
82
129
|
}
|
|
83
130
|
|
|
84
131
|
if (!detected || detected.length === 0) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 && !
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
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
|
-
|
|
100
|
-
const x = Math.max(0,
|
|
101
|
-
const y = Math.max(0,
|
|
102
|
-
const width = Math.max(0,
|
|
103
|
-
const height = Math.max(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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
346
|
+
updateIsActive,
|
|
347
|
+
capturedSV: { value: sharedState.value.captured },
|
|
166
348
|
};
|
|
167
|
-
};
|
|
349
|
+
};
|