UNPKG

react-native-biometric-verifier

Version:

A React Native module for biometric verification with face recognition and QR code scanning

846 lines (782 loc) 24.5 kB
import React, { useRef, useEffect, useState, useCallback } from 'react'; import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Animated, Dimensions, } from 'react-native'; import { Camera, getCameraDevice, useCodeScanner, useCameraFormat, } from 'react-native-vision-camera'; import { Global } from '../utils/Global'; import { useFaceDetectionFrameProcessor } from '../hooks/useFaceDetectionFrameProcessor'; const CaptureImageWithoutEdit = React.memo( ({ cameraType = 'front', onCapture, showCodeScanner = false, isLoading = false, frameProcessorFps = 1, livenessLevel = 0, // 0 = anti-spoof only, 1 = anti-spoof + blinking }) => { const cameraRef = useRef(null); const [cameraDevice, setCameraDevice] = useState(null); const [showCamera, setShowCamera] = useState(false); const [cameraInitialized, setCameraInitialized] = useState(false); const [currentCameraType, setCurrentCameraType] = useState(cameraType); const [isInitializing, setIsInitializing] = useState(true); const [faces, setFaces] = useState([]); const [livenessStep, setLivenessStep] = useState(0); const [blinkCount, setBlinkCount] = useState(0); const [progress, setProgress] = useState(0); const [faceCount, setFaceCount] = useState(0); const [isFaceLive, setIsFaceLive] = useState(false); const [antiSpoofConfidence, setAntiSpoofConfidence] = useState(0); const [isFaceCentered, setIsFaceCentered] = useState(false); const [hasSingleFace, setHasSingleFace] = useState(false); const captured = useRef(false); const isMounted = useRef(true); const instructionAnim = useRef(new Animated.Value(1)).current; const liveIndicatorAnim = useRef(new Animated.Value(0)).current; const resetCaptureState = useCallback(() => { captured.current = false; setFaces([]); setLivenessStep(0); setBlinkCount(0); setProgress(0); setFaceCount(0); setIsFaceLive(false); setAntiSpoofConfidence(0); setIsFaceCentered(false); setHasSingleFace(false); }, []); const codeScanner = useCodeScanner({ codeTypes: ['qr', 'ean-13'], onCodeScanned: (codes) => { try { if (showCodeScanner && codes && codes[0]?.value && !isLoading) { onCapture(codes[0].value); } } catch (error) { console.error('Error processing scanned code:', error); } }, }); const onStableFaceDetected = useCallback( async (faceRect) => { if (!isMounted.current) return; if (captured.current) return; captured.current = true; setFaces([faceRect]); try { if (!cameraRef.current) { throw new Error('Camera ref not available'); } const photo = await cameraRef.current.takePhoto({ flash: 'off', qualityPrioritization: 'quality', enableShutterSound: false, skipMetadata: true, }); if (!photo || !photo.path) { throw new Error('Failed to capture photo - no path returned'); } const photopath = `file://${photo.path}`; const fileName = photopath.substr(photopath.lastIndexOf('/') + 1); const photoData = { uri: photopath, filename: fileName, filetype: 'image/jpeg', }; onCapture(photoData, faceRect); } catch (e) { console.error('Capture error:', e); captured.current = false; resetCaptureState(); } }, [onCapture, resetCaptureState] ); const onFacesUpdate = useCallback((payload) => { if (!isMounted.current) return; try { const { count, progress, antiSpoofState } = payload; setFaceCount(count); setProgress(progress); // Update anti-spoof related states if (antiSpoofState) { setIsFaceLive(antiSpoofState.isLive || false); setAntiSpoofConfidence(antiSpoofState.confidence || 0); setIsFaceCentered(antiSpoofState.isFaceCentered || false); setHasSingleFace(antiSpoofState.hasSingleFace || false); } if (count === 1) { setFaces((prev) => { if (prev.length === 1) return prev; return [{ x: 0, y: 0, width: 0, height: 0 }]; }); } else { setFaces([]); } } catch (error) { console.error('Error updating faces:', error); } }, []); const onLivenessUpdate = useCallback( (step, extra) => { setLivenessStep(step); if (extra?.blinkCount !== undefined) setBlinkCount(extra.blinkCount); instructionAnim.setValue(0); Animated.timing(instructionAnim, { toValue: 1, duration: 300, useNativeDriver: true, }).start(); }, [instructionAnim] ); const onAntiSpoofUpdate = useCallback((result) => { if (!isMounted.current) return; try { // Animate live indicator when face becomes live if (result?.isLive && !isFaceLive) { Animated.spring(liveIndicatorAnim, { toValue: 1, tension: 50, friction: 7, useNativeDriver: true, }).start(); } else if (!result?.isLive && isFaceLive) { Animated.timing(liveIndicatorAnim, { toValue: 0, duration: 200, useNativeDriver: true, }).start(); } setIsFaceLive(result?.isLive || false); setAntiSpoofConfidence(result?.confidence || 0); setIsFaceCentered(result?.isFaceCentered || false); } catch (error) { console.error('Error updating anti-spoof:', error); } }, [isFaceLive, liveIndicatorAnim]); const { frameProcessor, forceResetCaptureState, updateShowCodeScanner, updateIsActive, capturedSV, } = useFaceDetectionFrameProcessor({ onStableFaceDetected, onFacesUpdate, onLivenessUpdate, onAntiSpoofUpdate, showCodeScanner, isLoading, isActive: showCamera && cameraInitialized, livenessLevel: livenessLevel, }); useEffect(() => { if (capturedSV?.value && !captured.current) { captured.current = true; } else if (!capturedSV?.value && captured.current) { captured.current = false; } }, [capturedSV?.value]); const getPermission = useCallback(async () => { try { if (!isMounted.current) return; setIsInitializing(true); setShowCamera(false); const newCameraPermission = await Camera.requestCameraPermission(); if (newCameraPermission === 'granted') { let devices = await Camera.getAvailableCameraDevices(); // Retry once after short delay if no devices found if (!devices || devices.length === 0) { await new Promise((resolve) => setTimeout(resolve, 300)); devices = await Camera.getAvailableCameraDevices(); } if (!devices || devices.length === 0) { throw new Error('No camera devices available'); } const device = getCameraDevice(devices, currentCameraType); if (!device) throw new Error(`No ${currentCameraType} camera available`); setCameraDevice(device); setShowCamera(true); } else { console.warn('Camera permission not granted'); } } catch (error) { console.error('Camera permission error:', error); setShowCamera(false); } finally { if (isMounted.current) { setIsInitializing(false); } } }, [currentCameraType]); const initializeCamera = useCallback(async () => { await getPermission(); }, [getPermission]); useEffect(() => { isMounted.current = true; const initOnMount = async () => { try { await initializeCamera(); } catch (error) { console.error('Failed to initialize camera on mount:', error); } }; initOnMount(); return () => { isMounted.current = false; setShowCamera(false); forceResetCaptureState(); }; }, [initializeCamera, forceResetCaptureState]); useEffect(() => { updateIsActive(showCamera && cameraInitialized); }, [showCamera, cameraInitialized, updateIsActive]); useEffect(() => { if (cameraType !== currentCameraType) { setCurrentCameraType(cameraType); initializeCamera(); } }, [cameraType, currentCameraType, initializeCamera]); const format = useCameraFormat(cameraDevice, [ { fps: 30 }, ]); useEffect(() => { try { updateShowCodeScanner(!!showCodeScanner); if (showCodeScanner && captured.current) { forceResetCaptureState(); resetCaptureState(); } } catch (error) { console.error('Error updating code scanner:', error); } }, [ showCodeScanner, updateShowCodeScanner, forceResetCaptureState, resetCaptureState, ]); const handleRetry = useCallback(async () => { try { setShowCamera(false); setCameraInitialized(false); forceResetCaptureState(); resetCaptureState(); await initializeCamera(); } catch (error) { console.error('Retry failed:', error); } }, [initializeCamera, resetCaptureState, forceResetCaptureState]); const getInstruction = useCallback(() => { if (faceCount > 1) { return 'Multiple faces detected'; } if (!hasSingleFace) { return 'Position your face in the frame'; } if (!isFaceCentered) { return 'Center your face in the frame'; } if (livenessLevel === 0) { if (!isFaceLive) return 'Verifying liveness...'; if (progress < 100) return 'Hold still...'; return 'Perfect! Capturing...'; } if (livenessLevel === 1) { switch (livenessStep) { case 0: return 'Face the camera straight'; case 1: if (!isFaceLive) return 'Verifying liveness...'; return `Blink your eyes ${blinkCount} of 3 times`; case 2: if (!isFaceLive) return 'Verifying liveness...'; if (progress < 100) return 'Hold still...'; return 'Perfect! Capturing...'; default: return 'Align your face in frame'; } } return 'Align your face in frame'; }, [ livenessLevel, livenessStep, blinkCount, progress, faceCount, hasSingleFace, isFaceCentered, isFaceLive, ]); const getInstructionContainerStyle = useCallback(() => { const baseStyle = [ styles.instructionContainer, { opacity: instructionAnim, transform: [ { translateY: instructionAnim.interpolate({ inputRange: [0, 1], outputRange: [10, 0], }), }, ], }, ]; if (faceCount > 1) { return [...baseStyle, styles.errorInstructionContainer]; } if (isFaceLive) { return [...baseStyle, styles.liveInstructionContainer]; } if (hasSingleFace && isFaceCentered) { return [...baseStyle, styles.verifyingInstructionContainer]; } return baseStyle; }, [faceCount, isFaceLive, hasSingleFace, isFaceCentered, instructionAnim]); const getInstructionStyle = useCallback(() => { if (faceCount > 1) { return [styles.instructionText, styles.errorInstructionText]; } if (isFaceLive) { return [styles.instructionText, styles.liveInstructionText]; } return styles.instructionText; }, [faceCount, isFaceLive]); const getStepConfig = useCallback(() => { switch (livenessLevel) { case 0: return { totalSteps: 0, showSteps: false }; case 1: return { totalSteps: 1, showSteps: true }; default: return { totalSteps: 0, showSteps: false }; } }, [livenessLevel]); const stepConfig = getStepConfig(); return ( <View style={styles.container}> <View style={styles.cameraContainer}> {!isInitializing && showCamera && cameraDevice ? ( <Camera ref={cameraRef} style={styles.camera} device={cameraDevice} isActive={showCamera && !isLoading} photo={true} format={format} codeScanner={showCodeScanner ? codeScanner : undefined} enableZoomGesture={false} lowLightBoost={cameraDevice.supportsLowLightBoost} frameProcessor={ !showCodeScanner && cameraInitialized ? frameProcessor : undefined } frameProcessorFps={frameProcessorFps} onInitialized={() => { setCameraInitialized(true); }} onError={(error) => { console.error('Camera error:', error); }} exposure={0} pixelFormat="yuv" preset="medium" orientation="portrait" /> ) : ( <View style={styles.placeholderContainer}> {isInitializing && ( <View style={styles.loadingContainer}> <ActivityIndicator size="large" color={Global.AppTheme.primary} /> <Text style={styles.placeholderText}>Initializing camera...</Text> </View> )} <TouchableOpacity style={styles.retryButton} onPress={handleRetry} accessibilityLabel="Retry camera initialization" > <Text style={styles.retryButtonText}>Retry</Text> </TouchableOpacity> </View> )} {!showCodeScanner && showCamera && cameraDevice && livenessLevel === 1 && ( <View style={styles.livenessContainer}> <Animated.View style={getInstructionContainerStyle()}> <Text style={getInstructionStyle()}>{getInstruction()}</Text> </Animated.View> {livenessStep === 1 && ( <View style={styles.blinkProgressContainer}> {[1, 2, 3].map((i) => ( <View key={i} style={[ styles.blinkDot, blinkCount >= i && styles.blinkDotActive, ]} /> ))} </View> )} {stepConfig.showSteps && faceCount <= 1 && ( <> <View style={styles.stepsContainer}> {Array.from({ length: stepConfig.totalSteps + 1 }).map( (_, step) => ( <React.Fragment key={step}> <View style={[ styles.stepIndicator, livenessStep > step ? styles.stepCompleted : livenessStep === step ? styles.stepCurrent : styles.stepPending, ]} > <Text style={styles.stepText}>{step + 1}</Text> </View> {step < stepConfig.totalSteps && ( <View style={[ styles.stepConnector, livenessStep > step ? styles.connectorCompleted : {}, ]} /> )} </React.Fragment> ) )} </View> <View style={styles.stepLabelsContainer}> {livenessLevel === 1 && ( <> <Text style={styles.stepLabel}>Center</Text> <Text style={styles.stepLabel}>Blink</Text> </> )} </View> </> )} </View> )} {!showCodeScanner && showCamera && cameraDevice && livenessLevel === 0 && ( <View style={styles.livenessContainer}> <Animated.View style={getInstructionContainerStyle()}> <Text style={getInstructionStyle()}>{getInstruction()}</Text> </Animated.View> {isFaceCentered && ( <View style={styles.confidenceContainer}> <Text style={styles.confidenceText}> Confidence: {Math.round(antiSpoofConfidence * 10)}% </Text> <View style={styles.confidenceBar}> <View style={[ styles.confidenceProgress, { width: `${antiSpoofConfidence * 10}%`, backgroundColor: antiSpoofConfidence > 6 ? Global.AppTheme.success : antiSpoofConfidence > 3 ? Global.AppTheme.warning : Global.AppTheme.error } ]} /> </View> </View> )} </View> )} </View> </View> ); } ); CaptureImageWithoutEdit.displayName = 'CaptureImageWithoutEdit'; const styles = StyleSheet.create({ container: { flex: 1, width: '100%', }, cameraContainer: { flex: 1, width: '100%', borderRadius: 12, overflow: 'hidden', backgroundColor: Global.AppTheme.dark, minHeight: 300, }, camera: { flex: 1, width: '100%', }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20, }, placeholderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20, }, placeholderText: { color: Global.AppTheme.light, fontSize: 16, textAlign: 'center', marginTop: 16, }, retryButton: { backgroundColor: Global.AppTheme.primary, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8, marginTop: 10, }, retryButtonText: { color: Global.AppTheme.light, fontWeight: 'bold', }, livenessContainer: { position: 'absolute', top: 40, left: 0, right: 0, alignItems: 'center', paddingHorizontal: 20, }, instructionContainer: { backgroundColor: 'rgba(0,0,0,0.8)', paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, marginBottom: 10, }, liveInstructionContainer: { backgroundColor: Global.AppTheme.success, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, marginBottom: 10, }, verifyingInstructionContainer: { backgroundColor: Global.AppTheme.warning, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, marginBottom: 10, }, errorInstructionContainer: { backgroundColor: Global.AppTheme.error, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, marginBottom: 10, }, instructionText: { color: 'white', fontSize: 16, fontWeight: 'bold', textAlign: 'center', }, liveInstructionText: { color: 'white', fontWeight: 'bold', }, errorInstructionText: { color: 'white', fontWeight: 'bold', }, // Live Indicator liveIndicator: { position: 'absolute', top: 20, right: 20, backgroundColor: Global.AppTheme.success, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, }, liveIndicatorInner: { flexDirection: 'row', alignItems: 'center', }, livePulse: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'white', marginRight: 6, }, liveIndicatorText: { color: 'white', fontSize: 12, fontWeight: 'bold', }, // Status Overview statusOverview: { position: 'absolute', bottom: 20, left: 20, right: 20, backgroundColor: 'rgba(0,0,0,0.8)', borderRadius: 12, padding: 16, }, statusRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12, }, statusItem: { flexDirection: 'row', alignItems: 'center', }, statusDot: { width: 10, height: 10, borderRadius: 5, marginRight: 6, }, statusGood: { backgroundColor: Global.AppTheme.success, }, statusPending: { backgroundColor: '#666', }, statusText: { color: 'white', fontSize: 12, }, confidenceContainer: { marginTop: 8, }, confidenceText: { color: 'white', fontSize: 12, marginBottom: 4, textAlign: 'center', }, confidenceBar: { height: 4, backgroundColor: 'rgba(255,255,255,0.3)', borderRadius: 2, overflow: 'hidden', }, confidenceProgress: { height: '100%', borderRadius: 2, }, // Existing styles blinkProgressContainer: { flexDirection: 'row', marginVertical: 8, }, blinkDot: { width: 16, height: 16, borderRadius: 8, backgroundColor: '#555', marginHorizontal: 4, }, blinkDotActive: { backgroundColor: Global.AppTheme.success, }, stepsContainer: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, }, stepIndicator: { width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', borderWidth: 2, }, stepCompleted: { backgroundColor: Global.AppTheme.primary, borderColor: Global.AppTheme.primary, }, stepCurrent: { backgroundColor: Global.AppTheme.primary, borderColor: Global.AppTheme.primary, opacity: 0.7, }, stepPending: { backgroundColor: 'transparent', borderColor: 'rgba(255,255,255,0.5)', }, stepText: { color: 'white', fontSize: 12, fontWeight: 'bold', }, stepConnector: { flex: 1, height: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginHorizontal: 4, }, connectorCompleted: { backgroundColor: Global.AppTheme.primary, }, stepLabelsContainer: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: 5, }, stepLabel: { color: 'white', fontSize: 12, opacity: 0.8, textAlign: 'center', flex: 1, }, stabilityContainer: { alignItems: 'center', width: '100%', }, stabilityBar: { width: '80%', height: 6, backgroundColor: 'rgba(255,255,255,0.3)', borderRadius: 3, overflow: 'hidden', marginBottom: 8, }, stabilityProgress: { height: '100%', backgroundColor: Global.AppTheme.primary, borderRadius: 3, }, }); export default CaptureImageWithoutEdit;