@sanctum-key/react-native-sdk 1.0.33 → 1.0.35

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,30 +551,47 @@ 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
+
561
+ const safeCapturePath = Platform.OS === 'ios' && !result.path.startsWith('file://')
562
+ ? `file://${result.path}`
563
+ : result.path;
564
+
550
565
  let templatePath = capturedImages[currentSide]?.templatePath || '';
551
566
  let templateBbox: IBbox | undefined;
552
567
  let templateResponse: any;
553
-
568
+
554
569
  if (!selectedDocumentType) {
555
570
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: 'Document type not selected' }));
556
571
  return;
557
572
  }
558
-
573
+
559
574
  const issuingSubdivision = selectedDocumentType.region !== 'root' ? selectedDocumentType.region : undefined;
560
-
561
- const safeCapturePath = (Platform.OS === 'ios' && !result.path.startsWith('file://'))
562
- ? `file://${result.path}`
563
- : result.path;
575
+
576
+ // 🚨 WATCHDOG TIMER: If the iOS network drops and doesn't throw, this guarantees the UI unlocks after 20 seconds.
577
+ const watchdog = setTimeout(() => {
578
+ if (isAnalyzingRef.current) {
579
+ setSilentCaptureResult((prev) => ({
580
+ ...prev,
581
+ isAnalyzing: false,
582
+ success: false,
583
+ error: state.currentLanguage === 'en' ? 'Connection timeout. Please try again.' : 'Délai de connexion dépassé. Veuillez réessayer.'
584
+ }));
585
+ Alert.alert(
586
+ state.currentLanguage === 'en' ? "Timeout" : "Délai dépassé",
587
+ state.currentLanguage === 'en'
588
+ ? "The request took too long. Please verify your network and try again."
589
+ : "La requête a pris trop de temps. Veuillez vérifier votre connexion et réessayer."
590
+ );
591
+ }
592
+ }, 20000);
564
593
 
565
594
  try {
566
- // 1. Check & Crop the Template visually first
567
595
  if (!templatePath) {
568
596
  const templateType = await checkTemplateType({
569
597
  path: safeCapturePath,
@@ -572,63 +600,78 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
572
600
  postfix: currentSide,
573
601
  issuingSubdivision: issuingSubdivision
574
602
  }, env);
575
-
603
+
576
604
  templateResponse = templateType;
577
605
  if (templateType.template_path) templatePath = templateType.template_path;
578
-
579
606
  if (templateType.card_obb) {
580
607
  const obbConfidence = getObbConfidence((templateType as any).card_obb);
581
608
  if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
609
+ clearTimeout(watchdog);
582
610
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
583
611
  return;
584
612
  }
585
613
  try {
586
- const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb); templateBbox = crop.bbox;
614
+ const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb);
615
+ templateBbox = crop.bbox;
587
616
  } catch {}
588
617
  }
589
618
  }
590
-
619
+
591
620
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
592
621
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
622
+
593
623
  let verificationRes: any;
594
-
595
624
  if (currentSide === 'front') {
596
625
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
597
626
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
598
-
627
+
599
628
  verificationRes = await frontVerification({
600
629
  path: safeCapturePath,
601
- regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] },
630
+ regionMapping: {
631
+ authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
632
+ mrzTypes: mrzType ? [mrzType] : []
633
+ },
602
634
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
603
- code: countryData?.code || '', currentSide, templatePath, mrzType,
604
- issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode
635
+ code: countryData?.code || '',
636
+ currentSide,
637
+ templatePath,
638
+ mrzType,
639
+ issuingSubdivision: issuingSubdivision,
640
+ clientSideBarcodeData: silentCaptureResult.barcode
605
641
  }, env);
642
+
606
643
  } else {
607
644
  logger.log("Attempting backVerification with:", { path: safeCapturePath, templatePath, side: currentSide });
608
645
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
609
646
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
610
647
  const frontTemplatePath = capturedImages['front']?.templatePath || '';
611
-
612
- // This validates if the back matches the front template!
648
+
613
649
  verificationRes = await backVerification({
614
650
  path: safeCapturePath,
615
- regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] },
651
+ regionMapping: {
652
+ authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
653
+ mrzTypes: backMrzType ? [backMrzType] : []
654
+ },
616
655
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
617
- code: countryData?.code || '', currentSide, templatePath, mrzType: backMrzType, templateResponse,
618
- issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath
656
+ code: countryData?.code || '',
657
+ currentSide,
658
+ templatePath,
659
+ mrzType: backMrzType,
660
+ templateResponse,
661
+ issuingSubdivision: issuingSubdivision,
662
+ frontTemplatePath: frontTemplatePath
619
663
  }, env);
620
664
  }
621
-
665
+
622
666
  const bbox = verificationRes?.bbox || templateBbox;
623
667
  const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
624
-
668
+
625
669
  if (isBarcodeRequired()) {
626
670
  const barcodeData = scannedBarcodeRef.current;
627
671
  const docTypeStr = selectedDocumentType!.type;
628
672
  const isHIC = docTypeStr === 'health_insurance_card';
629
673
  const isValidScan = typeof barcodeData === 'string' && ((isHIC && barcodeData.length > 0) || barcodeData.length > 50);
630
-
631
- // If the barcode was caught extremely fast in the same frame
674
+
632
675
  if (isValidScan) {
633
676
  logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
634
677
  let parsedDictionary;
@@ -638,14 +681,15 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
638
681
  const token = await authentification();
639
682
  parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
640
683
  }
641
-
684
+
642
685
  const verifiedResult: ISilentCaptureResult & { region?: string } = {
643
686
  path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true,
644
687
  mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
645
688
  country: countryData?.code, documentType: selectedDocumentType!.type,
646
689
  region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
647
690
  };
648
-
691
+
692
+ clearTimeout(watchdog);
649
693
  setSilentCaptureResult(verifiedResult);
650
694
  if (bbox) {
651
695
  const safeBbox = bbox as IBbox;
@@ -654,41 +698,60 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
654
698
  await autoCapture(safeCapturePath!, verifiedResult);
655
699
  return;
656
700
  }
657
-
658
- // If validation passed but we still need the barcode, trigger the UI phase!
701
+
659
702
  logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
703
+ clearTimeout(watchdog);
660
704
  setPendingCAImage({ path: safeCapturePath!, templateResponse, templatePath, bbox });
661
705
  setScanPhase('barcode');
662
706
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
663
707
  return;
664
708
  }
665
-
666
- // 4. NORMAL COMPLETION (For standard MRZ / Passport / Non-barcode documents)
709
+
667
710
  const verifiedResult: ISilentCaptureResult & { region?: string } = {
668
- path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz, isAnalyzing: false,
669
- country: countryData?.code, documentType: selectedDocumentType.type, region: issuingSubdivision
711
+ path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz,
712
+ isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type,
713
+ region: issuingSubdivision
670
714
  };
671
715
 
716
+ clearTimeout(watchdog);
672
717
  setSilentCaptureResult(verifiedResult);
673
718
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
674
719
  await autoCapture(safeCapturePath, verifiedResult);
675
-
720
+
676
721
  } catch (error: any) {
722
+ clearTimeout(watchdog);
677
723
  console.log("Backend Verification Error:", error);
678
- const rawMessage = (error?.message || '').toLowerCase();
679
- let userFriendlyMessage ='Verification failed. Please try again.';
680
724
 
681
- if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
682
- userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
683
- } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
684
- userFriendlyMessage = 'The document does not match your selected country.';
685
- } else if (rawMessage.includes('card_not_fully_in_frame')) {
686
- userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
687
- } else if (rawMessage.includes('ne correspond pas au recto')) {
688
- userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
725
+ const isNetworkError =
726
+ !error.response ||
727
+ error.message === 'Network Error' ||
728
+ error.code === 'ECONNABORTED' ||
729
+ error.message?.includes('Network');
730
+
731
+ let userFriendlyMessage = 'Verification failed. Please try again.';
732
+
733
+ if (isNetworkError) {
734
+ userFriendlyMessage = state.currentLanguage === 'en'
735
+ ? 'Cannot connect to verification server. Please check your internet connection.'
736
+ : 'Impossible de se connecter au serveur de vérification. Veuillez vérifier votre connexion internet.';
737
+
738
+ Alert.alert(
739
+ state.currentLanguage === 'en' ? "Connection Failed" : "Échec de connexion",
740
+ userFriendlyMessage
741
+ );
742
+ } else {
743
+ const rawMessage = (error?.message || '').toLowerCase();
744
+ if (rawMessage.includes('impossible') || rawMessage.includes('unreadable')) {
745
+ userFriendlyMessage = 'Document unreadable. Please hold the camera steady in good light.';
746
+ } else if (rawMessage.includes('country mismatch') || rawMessage.includes('pays sélectionné')) {
747
+ userFriendlyMessage = 'The document does not match your selected country.';
748
+ } else if (rawMessage.includes('card_not_fully_in_frame')) {
749
+ userFriendlyMessage = 'Move closer to the document and ensure all corners are visible.';
750
+ } else if (rawMessage.includes('ne correspond pas au recto')) {
751
+ userFriendlyMessage = 'The back of the document does not match the front. You must scan the same ID.';
752
+ }
689
753
  }
690
-
691
- // The error is thrown here, completely blocking the switch to 'barcode' phase
754
+
692
755
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
693
756
  }
694
757
  }