@sanctum-key/react-native-sdk 1.0.33 → 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,30 +551,44 @@ 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
-
561
- const safeCapturePath = (Platform.OS === 'ios' && !result.path.startsWith('file://'))
562
- ? `file://${result.path}`
563
- : result.path;
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);
564
590
 
565
591
  try {
566
- // 1. Check & Crop the Template visually first
567
592
  if (!templatePath) {
568
593
  const templateType = await checkTemplateType({
569
594
  path: safeCapturePath,
@@ -572,63 +597,78 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
572
597
  postfix: currentSide,
573
598
  issuingSubdivision: issuingSubdivision
574
599
  }, env);
575
-
600
+
576
601
  templateResponse = templateType;
577
602
  if (templateType.template_path) templatePath = templateType.template_path;
578
-
579
603
  if (templateType.card_obb) {
580
604
  const obbConfidence = getObbConfidence((templateType as any).card_obb);
581
605
  if (obbConfidence !== null && obbConfidence < OBB_CONFIDENCE_THRESHOLD) {
606
+ clearTimeout(watchdog);
582
607
  setSilentCaptureResult((prev) => ({ ...prev, isAnalyzing: false, success: false, error: t('kyc.idCardCapture.cardNotFullyInFrame') }));
583
608
  return;
584
609
  }
585
610
  try {
586
- const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb); templateBbox = crop.bbox;
611
+ const crop = await cropByObb(safeCapturePath, (templateType as any).card_obb);
612
+ templateBbox = crop.bbox;
587
613
  } catch {}
588
614
  }
589
615
  }
590
-
616
+
591
617
  const extractedCountryKey = templatePath ? templatePath.split('/')[0] : (ISO_TO_COUNTRY_NAME[countryData?.code || ''] || 'root');
592
618
  const regionMappings = getCurrentSideVerification(currentSide, extractedCountryKey);
619
+
593
620
  let verificationRes: any;
594
-
595
621
  if (currentSide === 'front') {
596
622
  const matchedAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'front');
597
623
  const mrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || 'TD1';
598
-
624
+
599
625
  verificationRes = await frontVerification({
600
626
  path: safeCapturePath,
601
- regionMapping: { authMethod: matchedAuthMethod ? [matchedAuthMethod] : [], mrzTypes: mrzType ? [mrzType] : [] },
627
+ regionMapping: {
628
+ authMethod: matchedAuthMethod ? [matchedAuthMethod] : [],
629
+ mrzTypes: mrzType ? [mrzType] : []
630
+ },
602
631
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType?.type as keyof typeof GovernmentDocumentTypeShorted] || '',
603
- code: countryData?.code || '', currentSide, templatePath, mrzType,
604
- issuingSubdivision: issuingSubdivision, clientSideBarcodeData: silentCaptureResult.barcode
632
+ code: countryData?.code || '',
633
+ currentSide,
634
+ templatePath,
635
+ mrzType,
636
+ issuingSubdivision: issuingSubdivision,
637
+ clientSideBarcodeData: silentCaptureResult.barcode
605
638
  }, env);
639
+
606
640
  } else {
607
641
  logger.log("Attempting backVerification with:", { path: safeCapturePath, templatePath, side: currentSide });
608
642
  const matchedBackAuthMethod = getCorrespondingAuthMethod(templatePath, regionMappings.regionMapping, regionMappings.key || '', 'back');
609
643
  const backMrzType = getCorrespondingMrzType(templatePath, regionMappings.regionMapping, regionMappings.key || '') || '';
610
644
  const frontTemplatePath = capturedImages['front']?.templatePath || '';
611
-
612
- // This validates if the back matches the front template!
645
+
613
646
  verificationRes = await backVerification({
614
647
  path: safeCapturePath,
615
- regionMapping: { authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [], mrzTypes: backMrzType ? [backMrzType] : [] },
648
+ regionMapping: {
649
+ authMethod: matchedBackAuthMethod ? [matchedBackAuthMethod] : [],
650
+ mrzTypes: backMrzType ? [backMrzType] : []
651
+ },
616
652
  selectedDocumentType: GovernmentDocumentTypeShorted[selectedDocumentType.type as keyof typeof GovernmentDocumentTypeShorted] || '',
617
- code: countryData?.code || '', currentSide, templatePath, mrzType: backMrzType, templateResponse,
618
- issuingSubdivision: issuingSubdivision, frontTemplatePath: frontTemplatePath
653
+ code: countryData?.code || '',
654
+ currentSide,
655
+ templatePath,
656
+ mrzType: backMrzType,
657
+ templateResponse,
658
+ issuingSubdivision: issuingSubdivision,
659
+ frontTemplatePath: frontTemplatePath
619
660
  }, env);
620
661
  }
621
-
662
+
622
663
  const bbox = verificationRes?.bbox || templateBbox;
623
664
  const verifiedMrz = verificationRes?.mrz ? JSON.stringify(verificationRes.mrz) : "";
624
-
665
+
625
666
  if (isBarcodeRequired()) {
626
667
  const barcodeData = scannedBarcodeRef.current;
627
668
  const docTypeStr = selectedDocumentType!.type;
628
669
  const isHIC = docTypeStr === 'health_insurance_card';
629
670
  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
671
+
632
672
  if (isValidScan) {
633
673
  logger.log('[CA Barcode] Image captured, verified & barcode ALREADY scanned! Skipping Phase 2.');
634
674
  let parsedDictionary;
@@ -638,14 +678,15 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
638
678
  const token = await authentification();
639
679
  parsedDictionary = await kycService.formatBarcodeData({ textInput: barcodeData, token }, env);
640
680
  }
641
-
681
+
642
682
  const verifiedResult: ISilentCaptureResult & { region?: string } = {
643
683
  path: safeCapturePath, templatePath: templatePath, bbox: bbox, success: true,
644
684
  mrz: JSON.stringify(parsedDictionary), barcode: barcodeData, isAnalyzing: false,
645
685
  country: countryData?.code, documentType: selectedDocumentType!.type,
646
686
  region: selectedDocumentType!.region !== 'root' ? selectedDocumentType!.region : undefined,
647
687
  };
648
-
688
+
689
+ clearTimeout(watchdog);
649
690
  setSilentCaptureResult(verifiedResult);
650
691
  if (bbox) {
651
692
  const safeBbox = bbox as IBbox;
@@ -654,41 +695,60 @@ export const IDCardCapture: React.FC<IDCardCaptureProps> = ({ component, value =
654
695
  await autoCapture(safeCapturePath!, verifiedResult);
655
696
  return;
656
697
  }
657
-
658
- // If validation passed but we still need the barcode, trigger the UI phase!
698
+
659
699
  logger.log('[CA Barcode] Image captured & template VERIFIED. Switching to Barcode phase.');
700
+ clearTimeout(watchdog);
660
701
  setPendingCAImage({ path: safeCapturePath!, templateResponse, templatePath, bbox });
661
702
  setScanPhase('barcode');
662
703
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false }));
663
704
  return;
664
705
  }
665
-
666
- // 4. NORMAL COMPLETION (For standard MRZ / Passport / Non-barcode documents)
706
+
667
707
  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
708
+ path: safeCapturePath, templatePath, bbox, success: true, mrz: verifiedMrz,
709
+ isAnalyzing: false, country: countryData?.code, documentType: selectedDocumentType.type,
710
+ region: issuingSubdivision
670
711
  };
671
712
 
713
+ clearTimeout(watchdog);
672
714
  setSilentCaptureResult(verifiedResult);
673
715
  if (bbox) setBboxBySide(prev => ({ ...prev, [currentSide]: bbox }));
674
716
  await autoCapture(safeCapturePath, verifiedResult);
675
-
717
+
676
718
  } catch (error: any) {
719
+ clearTimeout(watchdog);
677
720
  console.log("Backend Verification Error:", error);
678
- const rawMessage = (error?.message || '').toLowerCase();
679
- let userFriendlyMessage ='Verification failed. Please try again.';
680
721
 
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.';
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
+ }
689
750
  }
690
-
691
- // The error is thrown here, completely blocking the switch to 'barcode' phase
751
+
692
752
  setSilentCaptureResult(prev => ({ ...prev, isAnalyzing: false, success: false, error: userFriendlyMessage }));
693
753
  }
694
754
  }