import React, { createContext, useContext, useState, useEffect, useRef, } from 'react'; import {getWorkflowInfoByLink, submitKYCRequest} from '../service/facekiAPI'; import {Alert, Linking, NativeModules, PermissionsAndroid, Platform} from 'react-native'; import {HEADINGS, HEADING_TYPE} from '../wrapper/HEADINGS'; import type {PropsWithChildren} from 'react'; import {FacekiApiResponse} from '../service/types/facekiresponse'; import {Camera} from 'react-native-vision-camera'; import {Branding} from '../service/types/interfaces'; import Toast from 'react-native-toast-message'; import {translations, Language, Translation} from '../i18n/translations'; import RNFS from 'react-native-fs'; const Resizer = require('@bam.tech/react-native-image-resizer'); type userStepsType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; export type modeType = 'user' | {exact: 'environment'}; type GeoLocation = { latitude: number; longitude: number; }; type GeolocationLike = { setRNConfiguration?: (config: { skipPermissionRequests?: boolean; authorizationLevel?: 'always' | 'whenInUse' | 'auto'; enableBackgroundLocationUpdates?: boolean; }) => void; requestAuthorization?: ( success?: () => void, error?: (error: any) => void, ) => void; getCurrentPosition: ( success: (position: any) => void, error?: (error: any) => void, options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number; }, ) => void; }; const buildNativeGeolocation = (): GeolocationLike | undefined => { const nativeGeo = (NativeModules as any)?.RNCGeolocation; if (!nativeGeo?.getCurrentPosition) { return undefined; } return { setRNConfiguration: config => { nativeGeo.setConfiguration?.(config); }, requestAuthorization: (success, error) => { nativeGeo.requestAuthorization?.( () => success?.(), (nativeError: any) => error?.(nativeError), ); }, getCurrentPosition: (success, error, options) => { nativeGeo.getCurrentPosition?.( options ?? {}, (position: any) => success(position), (nativeError: any) => error?.(nativeError), ); }, }; }; const parseGeolocationEnforce = (value: unknown) => { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value === 1; } if (typeof value === 'string') { const normalizedValue = value.trim().toLowerCase(); return normalizedValue === 'true' || normalizedValue === '1' || normalizedValue === 'yes'; } return false; }; const CONTENT = ['ID Card', 'Passport', 'Driving License']; type ContentType = (typeof CONTENT)[number]; export type ImgUrlsType = { [key in ContentType]: { frontImage: {uri: string; path: string; size?: number}; backImage: {uri: string; path: string; size?: number}; selfie: {uri: string; path: string; size?: number}; }; }; // Define the type of the context value type ContextType = { onLivenessError?: (imageBase64: any, response: string) => void; onCancel?: () => void; userStep: userStepsType; handlerUserSteps: () => void; goBackUserSteps: (index?: number) => void; HEADINGS: HEADING_TYPE; findOutStepContent: () => { step: number; heading: string; subHeading: string; }; selectedOption: string; handleOptionChange: (event: string) => void; webcamRef: React.MutableRefObject; handleSingleCapturePhoto: (step: number) => void; imgUrls: ImgUrlsType; finalResult: | { heading: string; subText: string; success: boolean; } | undefined; routeOfHandler: () => void; allowedKycDocuments: string[]; isChecked: boolean; handleCheckbox: () => void; loading: boolean; kycRuleError: boolean; allowSingle: boolean; skipGuidanceScreens?: boolean; consenttermofuseLink?: string; skipFirstScreen?: boolean; skipResultScreen?: boolean; branding?: Branding; livenessScoreOverride?: number | null; resultContent?: { success: { heading: string; subHeading: string; }; fail: { heading: string; subHeading: string; }; verification: { heading: string; }; }; language: Language; t: Translation; isRTL: boolean; }; type VerificationProviderProps = PropsWithChildren<{ verification_url: string; onError: (message: Error) => void; onComplete: (message: FacekiApiResponse) => void; onCancel?: () => void; onLivenessError?: (imageBase64: string, response: string) => void; skipGuidanceScreens?: boolean; consenttermofuseLink?: string; allowSingleOverride?: boolean; skipFirstScreen?: boolean; skipResultScreen?: boolean; resultContent?: { success: { heading: string; subHeading: string; }; fail: { heading: string; subHeading: string; }; verification: { heading: string; }; }; singleVerificationDoc?: 'Passport' | 'ID Card' | 'Driving License'; branding?: Branding; livenessScoreOverride?: any; record_identifier?: string; language?: Language; geo_location?: GeoLocation; }> // Create the context const VerificationContext = createContext({} as ContextType); export const VerificationProvider: React.FC = ({ children, verification_url, onLivenessError, onError, onComplete, onCancel, allowSingleOverride, skipGuidanceScreens, consenttermofuseLink, skipFirstScreen, skipResultScreen, resultContent, singleVerificationDoc, branding, record_identifier, language = 'en', geo_location, }) => { const t: Translation = translations[language] ?? translations.en; const isRTL = language === 'ar'; const [userStep, setUserStep] = useState( skipFirstScreen ? 2 : 1, ); const webcamRef = useRef(null); const [allowSingle, setAllowSingle] = useState(false); const [allowedKycDocuments, setAllowedKycDocuments] = useState([]); const [isChecked, setIsChecked] = useState(false); const [clientCredentials, setClientCredentials] = useState(); const [token, setToken] = useState(''); const [loading, setLoading] = useState(true); const [kycRuleError, setKycRuleError] = useState(false); const [workflowId, setWorkflowId] = useState(null); const [rulesData, setRulesData] = useState(null); const [livenessScoreOverrideAPI, setLivenessScoreOverrideAPI] = useState(null); const [geolocationEnforce, setGeolocationEnforce] = useState(false); const [resolvedGeoLocation, setResolvedGeoLocation] = useState(geo_location); useEffect(() => { if (geo_location) { setResolvedGeoLocation(geo_location); } }, [geo_location]); useEffect(() => { if (skipFirstScreen) { setUserStep(2); } }, [skipFirstScreen]); const handleCheckbox = () => { setIsChecked(!isChecked); }; const [finalResult, setFinalResult] = useState<{ heading: string; subText: string; success: boolean; }>(); const [selectedOption, setSelectedOption] = useState< (typeof CONTENT)[number] >(CONTENT[0]); const [leftOptions, setLeftOptions] = useState([]); // options that are left before moving ahead const [imgUrls, setImgUrls] = useState({ ['ID Card']: { frontImage: {uri: '', path: ''}, backImage: {uri: '', path: ''}, selfie: {uri: '', path: ''}, }, ['Passport']: { frontImage: {uri: '', path: ''}, backImage: {uri: '', path: ''}, selfie: {uri: '', path: ''}, }, ['Driving License']: { frontImage: {uri: '', path: ''}, backImage: {uri: '', path: ''}, selfie: {uri: '', path: ''}, }, }); useEffect(() => { const requestStartupPermissions = async () => { await checkCameraPermission(false); await requestGeoLocation(false); }; requestStartupPermissions().catch(() => undefined); }, []); const showCameraDeclinedPopup = () => { Alert.alert( 'Camera Permission Denied', 'Camera access is required to continue verification. Please enable it in Settings.', [ {text: 'Cancel', style: 'cancel'}, {text: 'Open Settings', onPress: () => Linking.openSettings()}, ], ); }; const checkCameraPermission = async (showDeniedPopup = true) => { const cameraPermission = await Camera.getCameraPermissionStatus(); if (cameraPermission !== 'granted') { const newCameraPermission = await Camera.requestCameraPermission(); if (newCameraPermission !== 'granted') { if (showDeniedPopup) { showCameraDeclinedPopup(); } return false; } return true; } return true; }; const showLocationDeclinedPopup = (message?: string) => { Alert.alert( 'Location Permission Denied', message ?? (geolocationEnforce ? 'Location is required to continue verification. Please enable it in Settings.' : 'Location permission is denied. You can continue, or enable it in Settings.'), [ {text: 'Cancel', style: 'cancel'}, {text: 'Open Settings', onPress: () => Linking.openSettings()}, ], ); }; const requestGeoLocation = async ( showDeniedPopup = false, ): Promise => { if (resolvedGeoLocation) { return resolvedGeoLocation; } if (geo_location) { return geo_location; } if (Platform.OS === 'android') { const fineLocation = PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION; const status = await PermissionsAndroid.request(fineLocation); if (status !== PermissionsAndroid.RESULTS.GRANTED) { if (showDeniedPopup || geolocationEnforce) { showLocationDeclinedPopup(); } return undefined; } } let geolocation: GeolocationLike | undefined; try { const geolocationModule = require('@react-native-community/geolocation'); geolocation = geolocationModule?.default ?? geolocationModule; } catch (_error) { geolocation = buildNativeGeolocation(); } if (!geolocation) { geolocation = (globalThis as any)?.navigator?.geolocation; } if (!geolocation?.getCurrentPosition) { if (showDeniedPopup || geolocationEnforce) { showLocationDeclinedPopup('Unable to access device location. Please enable location services in Settings.'); } return undefined; } geolocation.setRNConfiguration?.({ skipPermissionRequests: false, authorizationLevel: 'whenInUse', }); if (Platform.OS === 'ios' && geolocation.requestAuthorization) { await new Promise(resolve => { geolocation.requestAuthorization?.( () => resolve(), () => { if (showDeniedPopup || geolocationEnforce) { showLocationDeclinedPopup(); } resolve(); }, ); }); } const currentLocation = await new Promise(resolve => { geolocation.getCurrentPosition( (position: any) => { resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, }); }, (error: any) => { const isPermissionDenied = error?.code === 1 || /denied/i.test(String(error?.message ?? '')); if (showDeniedPopup || geolocationEnforce) { if (isPermissionDenied) { showLocationDeclinedPopup(); } else { showLocationDeclinedPopup('Unable to read your current location. Please try again or check location settings.'); } } resolve(undefined); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0, }, ); }); if (currentLocation) { setResolvedGeoLocation(currentLocation); } return currentLocation; }; const ensureGeoLocationIfRequired = async () => { if (!geolocationEnforce) { return true; } const location = await requestGeoLocation(); if (location) { return true; } Alert.alert('Location Required', 'Please enable GPS location to continue verification.'); return false; }; const getGeoLocationForSubmission = async () => { if (resolvedGeoLocation) { return resolvedGeoLocation; } return requestGeoLocation(true); }; useEffect(() => { if (!verification_url) { console.error('Please provide verification url for faceki kyc'); } }, []); const getKYCRules = async (verificationURL?: any) => { try { const rulesWorkflows = await getWorkflowInfoByLink(verificationURL); if (!rulesWorkflows.result) { console.error('Invalid Workflow ID'); setKycRuleError(true); setLoading(false); return; } var resultValues = rulesWorkflows.result; setWorkflowId(resultValues.workflowId); setRulesData(resultValues); setLivenessScoreOverrideAPI(resultValues.threshold_val); const geoEnforceFromRule = parseGeolocationEnforce( resultValues.geolocation_enforce, ); setGeolocationEnforce(geoEnforceFromRule); // Do not block workflow/rules loading on GPS resolution. requestGeoLocation().catch(() => undefined); setAllowedKycDocuments(resultValues.documents as string[]); // CASE OF SINGLE DOCUMENT Choice if (resultValues.document_optional) { setSelectedOption(resultValues.documents?.[0]); setAllowSingle(true); if (skipFirstScreen) { setUserStep(2); } } // CASE OF No Choice else { setSelectedOption(resultValues.documents?.[0]); if (skipFirstScreen) { setUserStep(3); } } setLoading(false); const copyLeftOptions = [...resultValues.documents]; copyLeftOptions.shift(); // remove first element from an array setLeftOptions(copyLeftOptions); } catch (err) { console.log(err); setKycRuleError(true); setLoading(false); } }; useEffect(() => { if (verification_url) { getKYCRules(verification_url); } }, [verification_url]); const handleSingleCapturePhoto = async ( step: number, image?: any, refOverride?: any, ) => { const allowedToAccessCamera = await checkCameraPermission(true); // if not allowed to access camera dont proceed if (!allowedToAccessCamera) { return; } if (!image) { if (webcamRef.current) { var imageSrc = await webcamRef?.current?.takePhoto?.({ enableShutterSound: false, enableAutoStabilization: true, }); } else { var imageSrc = await refOverride?.current?.takePhoto?.({ enableShutterSound: false, enableAutoStabilization: true, }); } } else { var imageSrc = image; } if (imageSrc?.path) { if (step === 5) { setImgUrls(prev => ({ ...prev, [selectedOption]: { ...prev[selectedOption], frontImage: imageSrc, }, })); } if (step === 7) { setImgUrls(prev => ({ ...prev, [selectedOption]: { ...prev[selectedOption], backImage: imageSrc, }, })); } if (step === 10) { const locationForSubmission = await getGeoLocationForSubmission(); if (geolocationEnforce && !locationForSubmission) { Alert.alert('Location Required', 'Please enable GPS location to continue verification.'); return; } //Compressing try { // 10MB limit per document combo (doc_front + doc_back + selfie) // Calculate selfie budget from actual captured document sizes const LIMIT_PER_COMBO = 10 * 1024 * 1024; // 10MB const DEFAULT_DOC_SIZE = 3 * 1024 * 1024; // assume 3MB if size unknown let selfieBudget = LIMIT_PER_COMBO; Object.values(imgUrls).forEach(doc => { const hasImages = doc.frontImage.path !== ''; if (hasImages) { const frontSize = doc.frontImage.size || DEFAULT_DOC_SIZE; const backSize = doc.backImage.path ? (doc.backImage.size || DEFAULT_DOC_SIZE) : 0; const available = LIMIT_PER_COMBO - frontSize - backSize; selfieBudget = Math.min(selfieBudget, available); } }); // Clamp: at least 1MB, at most 4MB selfieBudget = Math.max(1 * 1024 * 1024, selfieBudget); selfieBudget = Math.min(4 * 1024 * 1024, selfieBudget); console.log(`Selfie budget: ${(selfieBudget / 1024 / 1024).toFixed(1)}MB`); let quality = 100; let result = await Resizer.default.createResizedImage( (Platform.OS === 'android' ? 'file://' : '') + imageSrc.path, 1920, 1080, 'JPEG', quality, 0, undefined, false, {mode: 'contain'}, ); if (result.size > selfieBudget) { quality = Math.max(40, Math.floor((selfieBudget / result.size) * 100)); result = await Resizer.default.createResizedImage( (Platform.OS === 'android' ? 'file://' : '') + imageSrc.path, 1920, 1080, 'JPEG', quality, 0, undefined, false, {mode: 'contain'}, ); } console.log(`Selfie resized: ${(result.size / 1024).toFixed(0)}KB @ quality ${quality}`); imageSrc = result; // setResizedImage(result); } catch (error) { console.log(error); // Alert.alert('Unable to resize the photo'); } setImgUrls(prev => ({ ...prev, [selectedOption]: { ...prev[selectedOption], selfie: imageSrc, }, })); setUserStep(prev => (prev + 1) as userStepsType); console.log(imgUrls); const payload = Object.entries(imgUrls) .map(([documentType, documentData]) => { return { document_type: documentType, document_front: documentData.frontImage ? documentData.frontImage.path : '', document_back: documentData?.backImage ? documentData?.backImage?.path : '', }; }) .filter(({document_front, document_back, document_type}) => { if (document_type == 'Passport') { return document_front !== ''; } else { return document_front !== '' && document_back !== ''; } }); console.log(JSON.stringify(payload), allowSingle); if (!allowSingle) { if (allowedKycDocuments.length == payload.length) { var formData = new FormData(); for (let index = 0; index < payload.length; index++) { const element = payload[index]; formData.append(`document_${index + 1}_front`, { uri: Platform.OS == 'android' ? 'file://' + element.document_front : element.document_front, type: 'image/jpeg', name: `photo_front_image.jpg`, }); if (element.document_back && element.document_back != '') { formData.append(`document_${index + 1}_back`, { uri: Platform.OS == 'android' ? 'file://' + element.document_back : element.document_back, type: 'image/jpeg', name: `photo_front_image.jpg`, }); } } formData.append('selfie', { uri: Platform.OS == 'android' ? 'file://' + `${imageSrc.path}` : `${imageSrc.path}`, type: 'image/jpeg', name: `photo_selfie_image.jpg`, }); formData.append('workflowId', workflowId); formData.append('record_identifier', record_identifier); formData.append('link', verification_url); if (locationForSubmission) { formData.append('geo_location', JSON.stringify(locationForSubmission)); } console.log(formData); submitKYCRequest(formData) .then((res: any) => { console.log(res); const responseWithType = FacekiApiResponse.createInstance( res.status, res.code, res.message, res.appVersion, res.result, ); onComplete && onComplete(responseWithType); handleError(res, imageSrc); }) .catch((err: Error) => { console.log(err); onError && onError(err); }); } } else { var formData = new FormData(); console.log('Starting'); formData.append(`document_1_front`, { uri: Platform.OS == 'android' ? 'file://' + payload[0].document_front : payload[0].document_front, type: 'image/jpeg', name: `photo_front_image.jpg`, }); if (payload[0].document_back && payload[0].document_back != '') { formData.append(`document_1_back`, { uri: Platform.OS == 'android' ? 'file://' + payload[0].document_back : payload[0].document_back, type: 'image/jpeg', name: `photo_front_image.jpg`, }); } formData.append('selfie', { uri: Platform.OS == 'android' ? 'file://' + `${imageSrc.path}` : `${imageSrc.path}`, type: 'image/jpeg', name: `photo_selfie_image.jpg`, }); formData.append('workflowId', workflowId); formData.append('record_identifier', record_identifier); formData.append('link', verification_url); if (locationForSubmission) { formData.append('geo_location', JSON.stringify(locationForSubmission)); } submitKYCRequest(formData) .then(res => { console.log(res); const responseWithType = FacekiApiResponse.createInstance( res.status, res.code, res.message, res.appVersion, res.result, ); onComplete && onComplete(responseWithType); handleError(res, imageSrc); }) .catch(err => { console.log(err); onError && onError(err); }); } return; } if ([5, 7, 10].includes(step)) { setUserStep(prev => prev == step ? ((prev + 1) as userStepsType) : prev, ); } } }; const handleError = async (response: any, imageSrc?: any) => { if (response.result?.decision == 'ACCEPTED') { const result = { heading: resultContent?.success.heading || t.successHeading, subText: resultContent?.success.subHeading || t.successText, success: true, }; setFinalResult(result); if (!skipResultScreen) { setUserStep(prev => (prev + 1) as userStepsType); } } else { if ( [8004, 8005, 8006, 8007, 8008, 8009, 5004, 5005].includes( response?.code, ) ) { // Send liveness error callback with base64 image if (onLivenessError && imageSrc?.path) { try { const base64 = await RNFS.readFile(imageSrc.path, 'base64'); onLivenessError( 'data:image/jpeg;base64,' + base64, JSON.stringify(response), ); } catch (readErr) { console.error('base64 read failed (handleError):', readErr); } } let errorText2: string; switch (response?.code) { case 5005: errorText2 = t.faceNoMatch; break; case 5004: errorText2 = t.livenessTestFail; break; case 8004: errorText2 = t.faceCropped; break; case 8005: errorText2 = t.faceTooClose; break; case 8006: errorText2 = t.faceNotFound; break; case 8007: errorText2 = t.faceNearBorder; break; case 8008: errorText2 = t.faceTooSmall; break; case 8009: errorText2 = t.poorLighting; break; default: errorText2 = t.livenessTestFail; } Toast.show({ type: 'error', text2: t.pleaseRetakeSelfie, text1: errorText2, }); setUserStep(prev => (prev - 1) as userStepsType); } else { const result = { heading: resultContent?.fail.heading || t.failHeading, subText: resultContent?.fail.subHeading || t.failText, success: false, }; setFinalResult(result); if (!skipResultScreen) { setUserStep(prev => (prev + 1) as userStepsType); } } } }; const handlerUserSteps = () => { if (geolocationEnforce && !resolvedGeoLocation) { requestGeoLocation(true).then(location => { if (!location) { showLocationDeclinedPopup( 'Location is required to continue verification. Please enable it in Settings.', ); } }); return; } if (loading && userStep == 1) { return; } if ([3, 8].includes(userStep) && skipGuidanceScreens) { setUserStep(prev => (prev + 2) as userStepsType); return; } if (!allowSingle && userStep === 1) { setUserStep(prev => (prev + 2) as userStepsType); return; } if (userStep === 2) { if (loading || kycRuleError) { Alert.alert(t.selectKycType, '', [ { text: t.cancel, onPress: () => {}, style: 'cancel', }, {text: t.ok, onPress: () => {}}, ]); return; } } if (userStep === 3) { if (!isChecked) { Alert.alert(t.agreeTerms, '', [ { text: t.cancel, onPress: () => {}, style: 'cancel', }, {text: t.ok, onPress: () => {}}, ]); return; } } setUserStep(prev => (prev + 1) as userStepsType); }; const routeOfHandler = () => { if (userStep === 6 && selectedOption === 'Passport') { setUserStep(prev => (prev + 1) as userStepsType); moveForwardonlyIfNoLeftOption(); } if (userStep === 8) { moveForwardonlyIfNoLeftOption(); } else { handlerUserSteps(); } }; const moveForwardonlyIfNoLeftOption = () => { if (leftOptions.length === 0 || allowSingle) { if (skipGuidanceScreens) { setUserStep(prev => (prev + 2) as userStepsType); } else { setUserStep(prev => (prev + 1) as userStepsType); } return; } setUserStep(prev => (prev - 3) as userStepsType); // make the left option to be selected setSelectedOption(leftOptions[0]); // remove left option const copyLeftOptions = [...leftOptions]; copyLeftOptions.shift(); setLeftOptions(copyLeftOptions); }; const goBackUserSteps = (index?: number) => { setUserStep((prev: any) => { if (index) { return (prev - index) as userStepsType; } else { return (prev - 1) as userStepsType; } }); }; // Function for getting Content const findOutStepContent = () => { const stepData = t.steps[userStep] ?? { step: userStep, heading: '', subHeading: '' }; if (userStep === 4 || userStep === 5 || userStep === 6 || userStep === 7) { const docLabel = t.docLabels[selectedOption] || selectedOption; return { ...stepData, heading: `${stepData.heading} ${docLabel}` }; } return stepData; }; // Function for selecting document const handleOptionChange = (event: string) => { setSelectedOption(event); const targetIndex = allowedKycDocuments.indexOf(event); const copyLeftOptions = [...allowedKycDocuments]; // sort them based on new selection if (targetIndex !== -1) { copyLeftOptions.sort((a, b) => { if (a === event) { return -1; // a should come before b } else if (b === event) { return 1; // b should come before a } else { return 0; // leave the order unchanged } }); } copyLeftOptions.shift(); setLoading(false); // copyLeftOptions.shift(); // remove first element from an array setLeftOptions(copyLeftOptions); }; return ( {children} ); }; export const useMyStepsVerification = () => useContext(VerificationContext);