UNPKG

react-native-biometric-verifier

Version:

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

647 lines (569 loc) 19.4 kB
import React, { useState, useEffect, useRef, useCallback, useMemo, } from "react"; import { View, TouchableOpacity, Text, Modal, InteractionManager, StyleSheet, Platform, Animated, } from "react-native"; import Icon from "react-native-vector-icons/MaterialIcons"; import { useNavigation } from "@react-navigation/native"; // Custom hooks import { useCountdown } from "./hooks/useCountdown"; import { useGeolocation } from "./hooks/useGeolocation"; import { useImageProcessing } from "./hooks/useImageProcessing"; import { useNotifyMessage } from "./hooks/useNotifyMessage"; import { useSafeCallback } from "./hooks/useSafeCallback"; // Utils import { getDistanceInMeters } from "./utils/distanceCalculator"; import { Global } from "./utils/Global"; import networkServiceCall from "./utils/NetworkServiceCall"; import { getLoaderGif } from "./utils/getLoaderGif"; // Components import Loader from "./components/Loader"; import { CountdownTimer } from "./components/CountdownTimer"; import { Card } from "./components/Card"; import { Notification } from "./components/Notification"; import CaptureImageWithoutEdit from "./components/CaptureImageWithoutEdit"; import StepIndicator from "./components/StepIndicator"; const BiometricModal = React.memo( ({ data, qrscan = false, callback, apiurl, onclose, frameProcessorFps, livenessLevel, fileurl, imageurl }) => { const navigation = useNavigation(); // Custom hooks const { countdown, startCountdown, resetCountdown, pauseCountdown, resumeCountdown } = useCountdown(); const { requestLocationPermission, getCurrentLocation } = useGeolocation(); const { convertImageToBase64 } = useImageProcessing(); const { notification, fadeAnim, slideAnim, notifyMessage, clearNotification } = useNotifyMessage(); const safeCallback = useSafeCallback(callback, notifyMessage); // State const [modalVisible, setModalVisible] = useState(false); const [cameraType, setCameraType] = useState("front"); const [state, setState] = useState({ isLoading: false, loadingType: Global.LoadingTypes.none, currentStep: "Start", employeeData: null, animationState: Global.AnimationStates.faceScan, }); // Refs const dataRef = useRef(data); const mountedRef = useRef(true); const responseRef = useRef(null); const processedRef = useRef(false); const resetTimeoutRef = useRef(null); // Animation values const iconScaleAnim = useRef(new Animated.Value(1)).current; const iconOpacityAnim = useRef(new Animated.Value(0)).current; // Cleanup on unmount useEffect(() => { return () => { mountedRef.current = false; if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } clearNotification(); }; }, []); // Update dataRef when data changes useEffect(() => { dataRef.current = data; }, [data]); // Animation helper const animateIcon = useCallback(() => { // Reset animation iconScaleAnim.setValue(1); iconOpacityAnim.setValue(0); // Start animation sequence Animated.sequence([ Animated.parallel([ Animated.timing(iconOpacityAnim, { toValue: 1, duration: 300, useNativeDriver: true, }), Animated.spring(iconScaleAnim, { toValue: 1.2, friction: 3, useNativeDriver: true, }), ]), Animated.spring(iconScaleAnim, { toValue: 1, friction: 5, useNativeDriver: true, }), ]).start(); }, [iconScaleAnim, iconOpacityAnim]); // State update helper const updateState = useCallback((newState) => { if (mountedRef.current) { setState((prev) => { const merged = { ...prev, ...newState }; if (JSON.stringify(prev) !== JSON.stringify(merged)) { // Pause/resume countdown based on loading state if (newState.isLoading !== undefined) { if (newState.isLoading) { pauseCountdown(); } else { resumeCountdown(); } } // Animate icon when step changes if (newState.currentStep && newState.currentStep !== prev.currentStep) { animateIcon(); } return merged; } return prev; }); } }, [animateIcon, pauseCountdown, resumeCountdown]); // Reset state helper const resetState = useCallback(() => { onclose(false); setState({ isLoading: false, loadingType: Global.LoadingTypes.none, currentStep: "Start", employeeData: null, animationState: Global.AnimationStates.faceScan, }); setModalVisible(false); processedRef.current = false; resetCountdown(); clearNotification(); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = null; } }, [resetCountdown, clearNotification]); // Error handler const handleProcessError = useCallback( (message, errorObj = null) => { if (errorObj) { console.error("Process Error:", errorObj); } notifyMessage(message, "error"); updateState({ animationState: Global.AnimationStates.error, isLoading: false, loadingType: Global.LoadingTypes.none, }); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } resetTimeoutRef.current = setTimeout(() => { resetState(); }, 1200); }, [notifyMessage, resetState, updateState] ); // Countdown finish handler const handleCountdownFinish = useCallback(() => { handleProcessError("Time is up! Please try again."); if (navigation.canGoBack()) { navigation.goBack(); } }, [handleProcessError, navigation]); // API URL validation const validateApiUrl = useCallback(() => { if (!apiurl || typeof apiurl !== "string") { handleProcessError("Invalid API URL configuration."); return false; } return true; }, [apiurl, handleProcessError]); // Face scan upload const uploadFaceScan = useCallback( async (selfie) => { if (!validateApiUrl()) return; const currentData = dataRef.current; if (!currentData) { handleProcessError("Employee data not found."); return; } updateState({ isLoading: true, loadingType: Global.LoadingTypes.faceRecognition, animationState: Global.AnimationStates.processing, }); InteractionManager.runAfterInteractions(async () => { let base64; try { updateState({ loadingType: Global.LoadingTypes.imageProcessing, }); base64 = await convertImageToBase64(selfie?.uri); } catch (err) { console.error("Image conversion failed:", err); handleProcessError("Image conversion failed.", err); return; } if (!base64) { handleProcessError("Failed to process image."); return; } try { const body = { image: base64 }; const header = { faceid: currentData }; const buttonapi = `${apiurl}python/recognize`; updateState({ loadingType: Global.LoadingTypes.networkRequest, }); const response = await networkServiceCall( "POST", buttonapi, header, body ); if (response?.httpstatus === 200) { responseRef.current = response; updateState({ employeeData: response.data?.data || null, animationState: Global.AnimationStates.success, isLoading: false, loadingType: Global.LoadingTypes.none, }); notifyMessage("Identity verified successfully!", "success"); if (qrscan) { setTimeout(() => startQRCodeScan(), 1200); } else { safeCallback(responseRef.current); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } resetTimeoutRef.current = setTimeout(() => { resetState(); }, 1200); } } else { handleProcessError( response?.data?.message || "Face not recognized. Please try again." ); } } catch (error) { console.error("Network request failed:", error); handleProcessError( "Connection error. Please check your network.", error ); } }); }, [ convertImageToBase64, notifyMessage, qrscan, resetState, updateState, validateApiUrl, safeCallback, handleProcessError ] ); // QR code processing const handleQRScanned = useCallback( async (qrCodeData) => { if (!validateApiUrl()) return; updateState({ animationState: Global.AnimationStates.processing, isLoading: true, loadingType: Global.LoadingTypes.locationVerification, }); try { updateState({ loadingType: Global.LoadingTypes.locationPermission, }); const hasPermission = await requestLocationPermission(); if (!hasPermission) { handleProcessError("Location permission not granted."); return; } const qrString = typeof qrCodeData === "object" ? qrCodeData?.data : qrCodeData; if (!qrString || typeof qrString !== "string") { handleProcessError("Invalid QR code. Please try again."); return; } updateState({ loadingType: Global.LoadingTypes.gettingLocation, }); const location = await getCurrentLocation(); const [latStr, lngStr] = qrString.split(","); const lat = parseFloat(latStr); const lng = parseFloat(lngStr); const validCoords = !isNaN(lat) && !isNaN(lng); const validDev = !isNaN(location?.latitude) && !isNaN(location?.longitude); if (validCoords && validDev) { updateState({ loadingType: Global.LoadingTypes.calculateDistance, }); const distance = getDistanceInMeters( lat, lng, location.latitude, location.longitude ); if (distance <= Global.MaxDistanceMeters) { safeCallback(responseRef.current); notifyMessage("Location verified successfully!", "success"); updateState({ animationState: Global.AnimationStates.success, isLoading: false, loadingType: Global.LoadingTypes.none, }); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } resetTimeoutRef.current = setTimeout(() => { resetState(); }, 1200); } else { handleProcessError( `Location mismatch (${distance.toFixed(0)}m away).` ); } } else { handleProcessError("Invalid coordinates in QR code."); } } catch (error) { console.error("Location verification failed:", error); handleProcessError( "Unable to verify location. Please try again.", error ); } }, [ getCurrentLocation, notifyMessage, requestLocationPermission, resetState, updateState, validateApiUrl, safeCallback, handleProcessError ] ); // Image capture handler const handleImageCapture = useCallback( async (capturedData) => { if (state.currentStep === "Identity Verification") { uploadFaceScan(capturedData); } else if (state.currentStep === "Location Verification") { handleQRScanned(capturedData); } }, [state.currentStep, uploadFaceScan, handleQRScanned] ); // Start face scan const handleStartFaceScan = useCallback(() => { updateState({ currentStep: "Identity Verification", animationState: Global.AnimationStates.faceScan, }); setCameraType("front"); }, [updateState]); // Start QR code scan const startQRCodeScan = useCallback(() => { updateState({ currentStep: "Location Verification", animationState: Global.AnimationStates.qrScan, }); setCameraType("back"); }, [updateState]); // Start the verification process const startProcess = useCallback(() => { startCountdown(handleCountdownFinish); handleStartFaceScan(); }, [handleCountdownFinish, handleStartFaceScan, startCountdown]); // Open modal when data is received useEffect(() => { if (data && !modalVisible && !processedRef.current) { processedRef.current = true; setModalVisible(true); startProcess(); } }, [data, modalVisible, startProcess]); // Determine if camera should be shown const shouldShowCamera = (state.currentStep === "Identity Verification" || state.currentStep === "Location Verification") && state.animationState !== Global.AnimationStates.success && state.animationState !== Global.AnimationStates.error; return ( <Modal visible={modalVisible} animationType="slide" transparent onRequestClose={resetState} statusBarTranslucent > <View style={styles.modalContainer}> {/* Camera component - full screen */} {shouldShowCamera && !state.isLoading && ( <View style={styles.cameraContainer}> <CaptureImageWithoutEdit cameraType={cameraType} onCapture={handleImageCapture} showCodeScanner={state.currentStep === "Location Verification"} isLoading={state.isLoading} frameProcessorFps={frameProcessorFps} livenessLevel={livenessLevel} /> </View> )} {/* UI elements positioned absolutely on top of camera */} <TouchableOpacity style={styles.closeButton} onPress={resetState} accessibilityLabel="Close modal" hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }} > <Icon name="close" size={24} color={Global.AppTheme.light} /> </TouchableOpacity> <View style={styles.topContainer}> {!shouldShowCamera && ( <View style={styles.headerContainer}> <View style={styles.titleContainer}> <Text style={styles.title}>Biometric Verification</Text> <Text style={styles.subtitle}>{state.currentStep}</Text> </View> </View> )} </View> <View style={styles.topContainerstep}> <StepIndicator currentStep={state.currentStep} qrscan={qrscan} /> </View> {state.employeeData && ( <View style={styles.cardContainer}> <Card employeeData={state.employeeData} apiurl={apiurl} fileurl={fileurl} /> </View> )} <View style={styles.notificationContainer}> <Notification notification={notification} fadeAnim={fadeAnim} slideAnim={slideAnim} /> </View> <View style={styles.timerContainer}> <CountdownTimer duration={Global.CountdownDuration} currentTime={countdown} /> </View> <Loader state={state} gifSource={getLoaderGif(state.animationState, state.currentStep, apiurl, imageurl)} /> </View> </Modal> ); } ); const styles = StyleSheet.create({ modalContainer: { flex: 1, backgroundColor: Global.AppTheme.modalBackground || 'rgba(0, 0, 0, 0.85)', }, cameraContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, }, topContainer: { position: 'absolute', top: Platform.OS === 'ios' ? 50 : 30, left: 0, right: 0, zIndex: 10, paddingHorizontal: 20, }, topContainerstep: { position: 'absolute', bottom: Platform.OS === 'ios' ? 50 : 30, left: 0, zIndex: 10, }, headerContainer: { flexDirection: 'row', alignItems: 'center', marginBottom: 15, marginTop: 10, }, titleContainer: { flex: 1, marginLeft: 10 }, title: { fontSize: 26, fontWeight: '700', color: Global.AppTheme.textLight || Global.AppTheme.light, marginBottom: 5, textAlign: 'left', fontFamily: Platform.OS === 'ios' ? 'Helvetica Neue' : 'sans-serif', textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 3, }, subtitle: { fontSize: 18, color: Global.AppTheme.textLight || Global.AppTheme.light, marginBottom: 0, fontWeight: '600', textAlign: 'left', opacity: 0.9, fontFamily: Platform.OS === 'ios' ? 'Helvetica Neue' : 'sans-serif-medium', textShadowColor: 'rgba(0, 0, 0, 0.2)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }, closeButton: { position: 'absolute', top: Platform.OS === 'ios' ? 40 : 20, right: 20, zIndex: 20, backgroundColor: 'rgba(255, 255, 255, 0.15)', borderRadius: 20, padding: 8, }, cardContainer: { position: 'absolute', bottom: 100, left: 0, right: 0, zIndex: 10, paddingHorizontal: 20, }, notificationContainer: { position: 'absolute', top: Platform.OS === 'ios' ? 120 : 100, left: 0, right: 0, zIndex: 10, paddingHorizontal: 20, }, timerContainer: { position: 'absolute', bottom: Platform.OS === 'ios' ? 40 : 20, left: 0, right: 0, zIndex: 10, }, }); export default BiometricModal;