import { Text, View, Pressable, Image, Platform, ActivityIndicator, } from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; import {Animated} from 'react-native'; import {styles} from './styles'; import {globalStyles} from '../../../globalStyles'; import CaptureButton from '../../design-system/capture-button/capture-button.component'; import FlipButton from '../../design-system/flip-button/flip-button.component'; import Icon from 'react-native-vector-icons/Ionicons'; import { Camera, CameraDeviceFormat, useCameraDevice, useCameraDevices, useFrameProcessor, } from 'react-native-vision-camera'; import {getBranding} from '../../branding'; import Spinner from 'react-native-loading-spinner-overlay'; import overlayImage from '../../assets/faceki-overlay-camera-L2.png'; import guideImage from '../../assets/MFXiWh.png'; import branding from '../../branding'; import axios, {AxiosResponse} from 'axios'; import Toast from 'react-native-toast-message'; import ImageResizer from '@bam.tech/react-native-image-resizer'; import {useMyStepsVerification} from '../../provider/verification.context'; import RNFS from 'react-native-fs'; const IDCARD = '../../assets/idwhite.png'; const DLCARD = '../../assets/dlwhite.png'; const Passport = '../../assets/passportwhite.png'; const Resizer = require('@bam.tech/react-native-image-resizer'); type props = { webcamRef: React.MutableRefObject; handleSingleCapturePhoto: ( step: number, image?: any, refOverride?: any, ) => void; userStep: number; skipGuidanceScreens?: boolean; livenessScoreOverride?: number | null | undefined; goBackUserSteps: (index?: number) => void; findOutStepContent: () => { step: number; heading: string; subHeading: string; }; }; /** * A component for capturing an image of the user with the device's camera. * * @param {Object} props - The component props. * @param {string} props.cameraMode - The type of camera to use (front or back). * @param {Function} props.flipCamera - A function that switches between the front and back camera. * @param {Object} props.webcamRef - A mutable ref object for accessing the camera component. * @param {Function} props.handleSingleCapturePhoto - A function that handles capturing a single photo for the current verification step. * @param {number} props.userStep - The current step in the user verification process. * @returns {JSX.Element} - The rendered component as a JSX element. */ const CaptureUserWebcam = ({ webcamRef, handleSingleCapturePhoto, userStep, goBackUserSteps, findOutStepContent, skipGuidanceScreens, livenessScoreOverride, }: props) => { const context = useMyStepsVerification(); const { t } = context; const devices: any = useCameraDevices(); const device = useCameraDevice('back', { physicalDevices: [ 'ultra-wide-angle-camera', 'wide-angle-camera', 'telephoto-camera', ], }); const [loading, setLoading] = useState(false); const [lowResWarned, setLowResWarned] = useState(false); const [cameraReady, setCameraReady] = useState(false); const [showGuide, setShowGuide] = useState(true); const guideOpacity = useRef(new Animated.Value(1)).current; const tets = useRef(null); var form: FormData | undefined; // Delay camera activation to let Android release previous camera session useEffect(() => { const timer = setTimeout(() => setCameraReady(true), Platform.OS === 'android' ? 300 : 100); return () => clearTimeout(timer); }, []); // Show guide image for 3s then fade out useEffect(() => { const fadeTimer = setTimeout(() => { Animated.timing(guideOpacity, { toValue: 0, duration: 500, useNativeDriver: true, }).start(() => setShowGuide(false)); }, 3000); return () => clearTimeout(fadeTimer); }, []); useEffect(() => { async function name() { try { var te = await tets?.current?.takePhoto?.({ enableShutterSound: false, // enableAutoStabilization: true, qualityPrioritization: 'speed', }); if (!te) { // console.log('Photo capture failed'); return; } if (!te.path) { // console.log('Photo capture failed'); return; } // Check actual photo resolution from the device if (!lowResWarned && te.width && te.height) { const w = Math.max(te.width, te.height); const h = Math.min(te.width, te.height); if (w < 1920 && h < 1080) { setLowResWarned(true); Toast.show({ type: 'info', text1: t.lowCameraResolution, text2: t.lowCameraMsg.replace('{w}', String(w)).replace('{h}', String(h)), visibilityTime: 5000, }); } } form = new FormData(); form.append('image', { uri: Platform.OS == 'android' ? 'file://' + te?.path : te?.path, type: 'image/jpeg', name: `photo_id_back_image.jpg`, }); console.log(te?.path); } catch (error) { setTimeout(name, Platform.OS == 'android' ? 100 : 1500); // Retry after 700ms } try { const response = await axios.post( 'https://addon.faceki.com/detect', form, { headers: {'Content-Type': 'multipart/form-data'}, }, ); const objectsDetected = response?.data?.objects_detected?.length; console.log(response?.data); if (objectsDetected < 1) { setTimeout(name, Platform.OS == 'android' ? 100 : 1500); // Retry after 700ms } else { handleSingleCapturePhoto(userStep, te); } } catch (error: any) { console.error('API request failed:', JSON.stringify(error)); // Handle the error or retry if needed setTimeout(name, Platform.OS == 'android' ? 100 : 1500); // Retry after 700ms } } // setTimeout(() => { // name(); // }, 3000); }, []); const HandleCapture = async () => { let te: any; let response: AxiosResponse | undefined; try { // {userStep === 7 ? 'BACK SIDE' : 'FRONT SIDE'} te = await tets?.current?.takePhoto?.({ enableShutterSound: false, // enableAutoStabilization: true, qualityPrioritization: 'quality', }); if (!te?.path) { setLoading(false); Toast.show({ type: 'error', text1: t.captureFailed, text2: t.captureFailedMsg, }); return; } try { // 10MB limit per document combo (front + back + selfie) // Budget per doc image: ~3.2MB leaves room for selfie const MAX_BYTES = 3.2 * 1024 * 1024; let quality = 100; let result = await Resizer.default.createResizedImage( (Platform.OS === 'android' ? 'file://' : '') + te.path, 1920, 1080, 'JPEG', quality, 0, undefined, false, {mode: 'contain'}, ); // If file exceeds budget, re-compress at lower quality if (result.size > MAX_BYTES) { quality = Math.max(40, Math.floor((MAX_BYTES / result.size) * 100)); result = await Resizer.default.createResizedImage( (Platform.OS === 'android' ? 'file://' : '') + te.path, 1920, 1080, 'JPEG', quality, 0, undefined, false, {mode: 'contain'}, ); } console.log(`Image resized: ${(result.size / 1024).toFixed(0)}KB @ quality ${quality}`); te = result; } catch (error) { console.log(error); } const imageUri = Platform.OS == 'android' ? 'file://' + te?.path : te?.path; console.log(`Uploading image: ${imageUri}, size: ${te?.size ? (te.size / 1024).toFixed(0) + 'KB' : 'unknown'}`); const MAX_RETRIES = 2; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { // Rebuild FormData each attempt — Android streams are consumed on first use const uploadForm = new FormData(); uploadForm.append('image', { uri: imageUri, type: 'image/jpeg', name: `photo_id_back_image.jpg`, }); response = await axios.post( 'https://addon.faceki.com/advance/detect', uploadForm, { headers: {'Content-Type': 'multipart/form-data'}, timeout: 30000, }, ); break; } catch (uploadErr: any) { console.warn(`Upload attempt ${attempt + 1} failed:`, uploadErr?.message); if (attempt === MAX_RETRIES) throw uploadErr; await new Promise(r => setTimeout(r, 1000)); } } console.log('Detection Response', response); if ( response?.data?.liveness?.livenessScore && response.data.liveness.livenessScore > (livenessScoreOverride || 0.7) ) { setLoading(false); handleSingleCapturePhoto(userStep, te); } else { setLoading(false); Toast.show({ type: 'error', text1: t.pleaseTryAgain, text2: t.imageQualityFail, }); if (context.onLivenessError && te?.path) { try { const base64 = await RNFS.readFile(te.path, 'base64'); context.onLivenessError('data:image/jpeg;base64,' + base64, JSON.stringify(response?.data)); } catch (readErr) { console.error('base64 read failed (liveness):', readErr); } } } } catch (error: any) { // console.error('HandleCapture error:', error?.message || error, error?.response?.status, error?.code); setLoading(false); Toast.show({ type: 'error', text1: t.somethingWentWrong, text2: t.tryCapturingAgain, }); if (context.onLivenessError && te?.path) { try { const base64 = await RNFS.readFile(te.path, 'base64'); context.onLivenessError( 'data:image/jpeg;base64,' + base64, response?.data ? JSON.stringify(response.data) : JSON.stringify(error), ); } catch (readErr) { console.error('base64 read failed (catch):', readErr); } } } }; return ( {/* Camera Screen */} {device && cameraReady && ( )} {/* Tap-to-focus layer */} { if (tets.current?.focus) { try { tets.current.focus({ x: e.nativeEvent.locationX, y: e.nativeEvent.locationY }); } catch (_) {} } }}> {/* Overlay Image Screen */} {/* Camera icon hint in center of frame */} {!loading && ( {t.placeDocument} )} {/* Header Component */} { skipGuidanceScreens && userStep != 7 ? goBackUserSteps(2) : goBackUserSteps(); }} style={({pressed}) => pressed && styles.opacity}> {findOutStepContent()?.heading} {findOutStepContent()?.subHeading} {userStep === 7 ? t.backSide : t.frontSide} {context.onCancel ? ( pressed && styles.opacity}> ) : ( )} {/* Footer Component */} { setLoading(true); HandleCapture(); }} /> ); }; export default CaptureUserWebcam;