@sanctum-key/react-native-sdk 1.0.32 → 1.0.34

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.
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity, useWindowDimensions } from 'react-native';
2
+ import { View, Text, StyleSheet, Image, ScrollView, Platform, ActivityIndicator, TouchableOpacity, useWindowDimensions, Alert } from 'react-native';
3
3
  import { showAlert } from '../../utils/platformAlert';
4
4
  import { EnhancedCameraView } from '../EnhancedCameraView';
5
5
  import { TemplateComponent, LocalizedText, GovernmentDocumentType, ISilentCaptureResult, IBbox, GovernmentDocumentTypeShorted, GovernmentDocumentTypeBackend } from '../../types/KYC.types';
@@ -96,6 +96,10 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
96
96
  const [pendingCAImage, setPendingCAImage] = useState<{ path: string, templateResponse: any, templatePath: string, bbox?: IBbox } | null>(null);
97
97
 
98
98
  const scannedBarcodeRef = useRef<string | null>(null);
99
+ // Ref mirrors — prevent stale closure reads in handleSilentCapture on iOS
100
+ const isAnalyzingRef = useRef<boolean>(false);
101
+ const isProcessingCaptureRef = useRef<boolean>(false);
102
+ const scanPhaseRef = useRef<'image' | 'barcode'>('image');
99
103
 
100
104
  const [generatedCode, setGeneratedCode] = useState<string | null>(null);
101
105
  const [isGeneratingCode, setIsGeneratingCode] = useState(false);
@@ -256,6 +260,11 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
256
260
  }
257
261
  }, [value, currentSide]);
258
262
 
263
+ // Keep refs in sync with state so handleSilentCapture always reads current values
264
+ useEffect(() => { isAnalyzingRef.current = silentCaptureResult.isAnalyzing ?? false; }, [silentCaptureResult.isAnalyzing]);
265
+ useEffect(() => { isProcessingCaptureRef.current = isProcessingCapture; }, [isProcessingCapture]);
266
+ useEffect(() => { scanPhaseRef.current = scanPhase; }, [scanPhase]);
267
+
259
268
  const cameraConfig = useMemo(() => {
260
269
  let instructions = selectedDocumentType
261
270
  ? (locale === 'en' ? getDocumentTypeInfo(selectedDocumentType.type).instructions.en : getDocumentTypeInfo(selectedDocumentType.type).instructions.fr)
@@ -418,7 +427,9 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
418
427
  const base64 = await pathToBase64(safeCroppedPath);
419
428
  const newImages = {
420
429
  ...capturedImages,
421
- [currentSide]: { dir: imagePathForUpload, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
430
+ // Store safeCroppedPath (guaranteed file:// on iOS) as dir so
431
+ // Image source={{ uri }} never receives a bare path on iOS.
432
+ [currentSide]: { dir: safeCroppedPath, file: base64, mrz: verified.mrz || "", templatePath: verified.templatePath },
422
433
  };
423
434
 
424
435
  setCapturedImages(newImages);
@@ -540,92 +551,124 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
540
551
  };
541
552
 
542
553
  const handleSilentCapture = async (result: { success: boolean; path?: string; error?: string; barcode?: unknown }) => {
543
- if (isProcessingCapture) return;
544
- if (scanPhase === 'barcode') return;
545
- if (silentCaptureResult.isAnalyzing) return;
546
-
554
+ if (isProcessingCaptureRef.current) return;
555
+ if (scanPhaseRef.current === 'barcode') return;
556
+ if (isAnalyzingRef.current) return;
557
+
547
558
  if (result.success && result.path) {
548
559
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: true, success: false, error: '' }));
549
-
560
+
550
561
  let templatePath = capturedImages[currentSide]?.templatePath || '';
551
562
  let templateBbox: IBbox | undefined;
552
563
  let templateResponse: any;
553
-
564
+
554
565
  if (!selectedDocumentType) {
555
566
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
556
567
  return;
557
568
  }
558
-
569
+
559
570
  const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
560
-
571
+ const safeCapturePath = (Platform.OS === 'ios' && !result.path.startsWith('file://')) ? `file://${result.path}` : result.path;
572
+
573
+ // 🚨 WATCHDOG TIMER: If the iOS network drops and doesn't throw, this guarantees the UI unlocks after 20 seconds.
574
+ const watchdog = setTimeout(() => {
575
+ if (isAnalyzingRef.current) {
576
+ setSilentCaptureResult((prev) => ({
577
+ ...prev,
578
+ isAnalyzing: false,
579
+ success: false,
580
+ error: state.currentLanguage === 'en' ? 'Connection timeout. Please try again.' : 'Délai de connexion dépassé. Veuillez réessayer.'
581
+ }));
582
+ Alert.alert(
583
+ state.currentLanguage === 'en' ? "Timeout" : "Délai dépassé",
584
+ state.currentLanguage === 'en'
585
+ ? "The request took too long. Please verify your network and try again."
586
+ : "La requête a pris trop de temps. Veuillez vérifier votre connexion et réessayer."
587
+ );
588
+ }
589
+ }, 20000);
590
+
561
591
  try {
562
- // 1. Check & Crop the Template visually first
563
592
  if (!templatePath) {
564
593
  const templateType = await checkTemplateType({
565
- path: result.path || '',
594
+ path: safeCapturePath,
566
595
  docType: selectedDocumentType?.type as GovernmentDocumentType,
567
596
  docRegion: countryData?.code || "",
568
597
  postfix: currentSide,
569
598
  issuingSubdivision: issuingSubdivision
570
599
  }, env);
571
-
600
+
572
601
  templateResponse = templateType;
573
602
  if (templateType.template_path) templatePath = templateType.template_path;
574
-
575
603
  if (templateType.card_obb) {
576
604
  const obbConfidence = getObbConfidence((templateType as any).card_obb);
577
605
  if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
606
+ clearTimeout(watchdog);
578
607
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
579
608
  return;
580
609
  }
581
610
  try {
582
- const crop = await cropByObb(result.path, (templateType as any).card_obb);
611
+ const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb);
583
612
  templateBbox = crop.bbox;
584
613
  } catch {}
585
614
  }
586
615
  }
587
-
616
+
588
617
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
589
618
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
619
+
590
620
  let verificationRes: any;
591
-
592
621
  if (currentSide === 'front') {
593
622
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
594
623
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
595
-
624
+
596
625
  verificationRes = await frontVerification({
597
- path: result.path,
598
- regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] },
626
+ path: safeCapturePath,
627
+ regionMapping: {
628
+ authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
629
+ mrzTypes: mrzType ? [mrzType] : []
630
+ },
599
631
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
600
- code: countryData?.code || '', currentSide, templatePath, mrzType,
601
- issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode
632
+ code: countryData?.code || '',
633
+ currentSide,
634
+ templatePath,
635
+ mrzType,
636
+ issuingSubdivision: issuingSubdivision,
637
+ clientSideBarcodeData: silentCaptureResult.barcode
602
638
  }, env);
639
+
603
640
  } else {
604
- logger.log("Attempting backVerification with:", { path: result.path, templatePath, side: currentSide });
641
+ logger.log("Attempting backVerification with:", { path: safeCapturePath, templatePath, side: currentSide });
605
642
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
606
643
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
607
644
  const frontTemplatePath = capturedImages['front']?.templatePath || '';
608
-
609
- // This validates if the back matches the front template!
645
+
610
646
  verificationRes = await backVerification({
611
- path: result.path,
612
- regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] },
647
+ path: safeCapturePath,
648
+ regionMapping: {
649
+ authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
650
+ mrzTypes: backMrzType ? [backMrzType] : []
651
+ },
613
652
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
614
- code: countryData?.code || '', currentSide, templatePath, mrzType: backMrzType, templateResponse,
615
- issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath
653
+ code: countryData?.code || '',
654
+ currentSide,
655
+ templatePath,
656
+ mrzType: backMrzType,
657
+ templateResponse,
658
+ issuingSubdivision: issuingSubdivision,
659
+ frontTemplatePath: frontTemplatePath
616
660
  }, env);
617
661
  }
618
-
662
+
619
663
  const bbox = verificationRes?.bbox || templateBbox;
620
664
  const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
621
-
665
+
622
666
  if (isBarcodeRequired()) {
623
667
  const barcodeData = scannedBarcodeRef.current;
624
668
  const docTypeStr = selectedDocumentType!.type;
625
669
  const isHIC = docTypeStr === 'health_insurance_card';
626
670
  const isValidScan = typeof barcodeData === 'string' && ((isHIC && barcodeData.length > 0) || barcodeData.length > 50);
627
-
628
- // If the barcode was caught extremely fast in the same frame
671
+
629
672
  if (isValidScan) {
630
673
  logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
631
674
  let parsedDictionary;
@@ -635,57 +678,77 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
635
678
  const token = await authentification();
636
679
  parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
637
680
  }
638
-
681
+
639
682
  const verifiedResult: ISilentCaptureResult & { region?: string } = {
640
- path: result.path, templatePath: templatePath, bbox: bbox, success: true,
683
+ path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true,
641
684
  mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
642
685
  country: countryData?.code, documentType: selectedDocumentType!.type,
643
686
  region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
644
687
  };
645
-
688
+
689
+ clearTimeout(watchdog);
646
690
  setSilentCaptureResult(verifiedResult);
647
691
  if (bbox) {
648
692
  const safeBbox = bbox as IBbox;
649
693
  setBboxBySide(prev => ({ ...prev, ['back']: safeBbox }));
650
694
  }
651
- await autoCapture(result.path!, verifiedResult);
695
+ await autoCapture(safeCapturePath!, verifiedResult);
652
696
  return;
653
697
  }
654
-
655
- // If validation passed but we still need the barcode, trigger the UI phase!
698
+
656
699
  logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
657
- setPendingCAImage({ path: result.path!, templateResponse, templatePath, bbox });
700
+ clearTimeout(watchdog);
701
+ setPendingCAImage({ path: safeCapturePath!, templateResponse, templatePath, bbox });
658
702
  setScanPhase('barcode');
659
703
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
660
704
  return;
661
705
  }
662
-
663
- // 4. NORMAL COMPLETION (For standard MRZ / Passport / Non-barcode documents)
706
+
664
707
  const verifiedResult: ISilentCaptureResult & { region?: string } = {
665
- path: result.path, templatePath, bbox, success: true, mrz: verifiedMrz, isAnalyzing: false,
666
- country: countryData?.code, documentType: selectedDocumentType.type, region: issuingSubdivision
708
+ path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz,
709
+ isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type,
710
+ region: issuingSubdivision
667
711
  };
668
712
 
713
+ clearTimeout(watchdog);
669
714
  setSilentCaptureResult(verifiedResult);
670
715
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
671
- await autoCapture(result.path, verifiedResult);
672
-
716
+ await autoCapture(safeCapturePath, verifiedResult);
717
+
673
718
  } catch (error: any) {
719
+ clearTimeout(watchdog);
674
720
  console.log("Backend Verification Error:", error);
675
- const rawMessage = (error?.message || '').toLowerCase();
676
- let userFriendlyMessage ='Verification failed. Please try again.';
677
721
 
678
- if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
679
- userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
680
- } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
681
- userFriendlyMessage = 'The document does not match your selected country.';
682
- } else if (rawMessage.includes('card_not_fully_in_frame')) {
683
- userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
684
- } else if (rawMessage.includes('ne correspond pas au recto')) {
685
- userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
722
+ const isNetworkError =
723
+ !error.response ||
724
+ error.message === 'Network Error' ||
725
+ error.code === 'ECONNABORTED' ||
726
+ error.message?.includes('Network');
727
+
728
+ let userFriendlyMessage = 'Verification failed. Please try again.';
729
+
730
+ if (isNetworkError) {
731
+ userFriendlyMessage = state.currentLanguage === 'en'
732
+ ? 'Cannot connect to verification server. Please check your internet connection.'
733
+ : 'Impossible de se connecter au serveur de vérification. Veuillez vérifier votre connexion internet.';
734
+
735
+ Alert.alert(
736
+ state.currentLanguage === 'en' ? "Connection Failed" : "Échec de connexion",
737
+ userFriendlyMessage
738
+ );
739
+ } else {
740
+ const rawMessage = (error?.message || '').toLowerCase();
741
+ if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
742
+ userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
743
+ } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
744
+ userFriendlyMessage = 'The document does not match your selected country.';
745
+ } else if (rawMessage.includes('card_not_fully_in_frame')) {
746
+ userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
747
+ } else if (rawMessage.includes('ne correspond pas au recto')) {
748
+ userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
749
+ }
686
750
  }
687
-
688
- // The error is thrown here, completely blocking the switch to 'barcode' phase
751
+
689
752
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
690
753
  }
691
754
  }