@trustchex/react-native-sdk 1.464.0 → 1.475.0

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.
Files changed (64) hide show
  1. package/android/build.gradle +3 -3
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +190 -12
  3. package/ios/Camera/TrustchexCameraView.swift +244 -46
  4. package/ios/Permission/PermissionModule.m +22 -0
  5. package/ios/Permission/PermissionModule.swift +67 -0
  6. package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
  7. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +24 -3
  8. package/lib/module/Screens/Dynamic/VerbalConsentScreen.js +1 -1
  9. package/lib/module/Screens/Dynamic/VideoCallScreen.js +7 -12
  10. package/lib/module/Screens/Static/ResultScreen.js +9 -1
  11. package/lib/module/Shared/Components/EIDScanner.js +166 -163
  12. package/lib/module/Shared/Components/FaceCamera.js +14 -8
  13. package/lib/module/Shared/Components/IdentityDocumentCamera.js +45 -10
  14. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  15. package/lib/module/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
  16. package/lib/module/Shared/Libs/index.js +20 -0
  17. package/lib/module/Shared/Libs/mrz.utils.js +570 -16
  18. package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
  19. package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
  20. package/lib/module/Shared/Libs/permissions.utils.js +199 -0
  21. package/lib/module/Translation/Resources/en.js +1 -0
  22. package/lib/module/Translation/Resources/tr.js +1 -0
  23. package/lib/module/version.js +1 -1
  24. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  25. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  26. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
  27. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
  30. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  31. package/lib/typescript/src/Shared/Libs/index.d.ts +20 -0
  32. package/lib/typescript/src/Shared/Libs/index.d.ts.map +1 -0
  33. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  34. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  35. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
  36. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
  37. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
  38. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
  39. package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts +58 -0
  40. package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts.map +1 -0
  41. package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
  42. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  43. package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
  44. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  45. package/lib/typescript/src/version.d.ts +1 -1
  46. package/package.json +14 -10
  47. package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
  48. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +45 -5
  49. package/src/Screens/Dynamic/VerbalConsentScreen.tsx +1 -1
  50. package/src/Screens/Dynamic/VideoCallScreen.tsx +8 -19
  51. package/src/Screens/Static/ResultScreen.tsx +18 -9
  52. package/src/Shared/Components/EIDScanner.tsx +210 -160
  53. package/src/Shared/Components/FaceCamera.tsx +19 -16
  54. package/src/Shared/Components/IdentityDocumentCamera.tsx +53 -13
  55. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  56. package/src/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
  57. package/src/Shared/Libs/index.ts +63 -0
  58. package/src/Shared/Libs/mrz.utils.ts +639 -11
  59. package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
  60. package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
  61. package/src/Shared/Libs/permissions.utils.ts +251 -0
  62. package/src/Translation/Resources/en.ts +1 -0
  63. package/src/Translation/Resources/tr.ts +1 -0
  64. package/src/version.ts +1 -1
@@ -3,6 +3,8 @@ import { Alert, View, Text, Image, StyleSheet, Animated } from 'react-native';
3
3
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
4
  import NFCManager from 'react-native-nfc-manager';
5
5
  import DeviceInfo from 'react-native-device-info';
6
+ import PermissionManager from '../Libs/permissions.utils';
7
+ import { debugError } from '../Libs/debug.utils';
6
8
  import { MRZInfo } from '../EIDReader/lds/icao/mrzInfo';
7
9
  import { eidReader } from '../EIDReader/eidReader';
8
10
  import NativeProgressBar from './NativeProgressBar';
@@ -142,6 +144,21 @@ const EIDScanner = ({
142
144
  setProgressStage('');
143
145
  resetLastMessage();
144
146
 
147
+ // Check NFC permissions before attempting to scan
148
+ const hasNFCPermission = await PermissionManager.requestPermission('nfc');
149
+ if (!hasNFCPermission) {
150
+ debugError('EIDScanner', 'NFC permission denied');
151
+ setIsScanning(false);
152
+ Alert.alert(
153
+ t('eidScannerScreen.error'),
154
+ t(
155
+ 'eidScannerScreen.nfcPermissionDenied',
156
+ 'NFC permission is required to scan your document'
157
+ )
158
+ );
159
+ return;
160
+ }
161
+
145
162
  // Track EID scan start using analytics helper
146
163
  const docType =
147
164
  (documentType as 'ID' | 'PASSPORT' | 'UNKNOWN') || 'UNKNOWN';
@@ -473,116 +490,124 @@ const EIDScanner = ({
473
490
  </View>
474
491
  ) : (
475
492
  <>
476
- {isScanned && (
477
- <View style={styles.resultCard}>
478
- <View style={styles.resultHeader}>
479
- {documentFaceImage &&
480
- (documentFaceImageMimeType === 'image/jpeg' ||
481
- documentFaceImageMimeType === 'image/png') ? (
482
- <Image
483
- source={{
484
- uri: `data:${documentFaceImageMimeType};base64,${documentFaceImage}`,
485
- }}
486
- style={styles.faceImage}
487
- resizeMode="cover"
488
- />
489
- ) : (
490
- <View style={styles.faceImagePlaceholder}>
491
- <Text style={styles.faceImagePlaceholderText}>?</Text>
493
+ {isScanned && documentMRZInfo && (
494
+ <View style={styles.idCardWrapper}>
495
+ <View style={styles.idCard}>
496
+ {/* ── Card header strip: doc type + issuing state ── */}
497
+ <View
498
+ style={[
499
+ styles.idCardStrip,
500
+ { backgroundColor: appContext.branding.primaryColor },
501
+ ]}
502
+ >
503
+ <Text style={styles.idCardStripDocType}>
504
+ {formatDocumentType(documentMRZInfo.getDocumentCode())}
505
+ </Text>
506
+ </View>
507
+
508
+ {/* ── Photo (left) + fields (right) ── */}
509
+ <View style={styles.idCardBody}>
510
+ <View style={styles.idCardPhotoContainer}>
511
+ <View style={styles.idCardPhotoFrame}>
512
+ {documentFaceImage &&
513
+ (documentFaceImageMimeType === 'image/jpeg' ||
514
+ documentFaceImageMimeType === 'image/png') ? (
515
+ <Image
516
+ source={{
517
+ uri: `data:${documentFaceImageMimeType};base64,${documentFaceImage}`,
518
+ }}
519
+ style={styles.idCardPhoto}
520
+ resizeMode="cover"
521
+ />
522
+ ) : (
523
+ <View style={styles.idCardPhotoPlaceholder}>
524
+ <Text style={styles.idCardPhotoPlaceholderText}>
525
+ ?
526
+ </Text>
527
+ </View>
528
+ )}
529
+ </View>
492
530
  </View>
493
- )}
494
- {documentMRZInfo && (
495
- <View style={styles.resultHeaderInfo}>
496
- <Text style={styles.fullName} numberOfLines={2}>
497
- {documentMRZInfo.getSecondaryIdentifier()}{' '}
498
- {documentMRZInfo.getPrimaryIdentifier()}
499
- </Text>
500
- <Text style={styles.subInfo}>
501
- {formatNationality(documentMRZInfo.getNationality())} ·{' '}
502
- {formatDocumentType(documentMRZInfo.getDocumentCode())}
503
- </Text>
504
- <Text style={styles.subInfo}>
505
- {formatGender(documentMRZInfo.getGender().getStrCode())} ·{' '}
506
- {formatDate(documentMRZInfo.getDateOfBirth())}
507
- </Text>
531
+
532
+ {/* Right column: all fields as grid */}
533
+ <View style={styles.idCardFields}>
534
+ <IDCardField
535
+ label={t('eidScannerScreen.surname')}
536
+ value={documentMRZInfo.getPrimaryIdentifier()}
537
+ />
538
+ <IDCardField
539
+ label={t('eidScannerScreen.name')}
540
+ value={documentMRZInfo.getSecondaryIdentifier()}
541
+ />
542
+ <IDCardField
543
+ label={t('eidScannerScreen.documentNumber')}
544
+ value={documentMRZInfo.getDocumentNumber()}
545
+ />
546
+ <IDCardField
547
+ label={t('eidScannerScreen.birthDate')}
548
+ value={formatDate(documentMRZInfo.getDateOfBirth())}
549
+ />
550
+ <IDCardField
551
+ label={t('eidScannerScreen.expirationDate')}
552
+ value={formatDate(documentMRZInfo.getDateOfExpiry())}
553
+ />
554
+ <IDCardField
555
+ label={t('eidScannerScreen.gender')}
556
+ value={formatGender(
557
+ documentMRZInfo.getGender().getStrCode()
558
+ )}
559
+ />
560
+ <IDCardField
561
+ label={t('eidScannerScreen.nationality')}
562
+ value={formatNationality(
563
+ documentMRZInfo.getNationality()
564
+ )}
565
+ />
566
+ <IDCardField
567
+ label={t('eidScannerScreen.issuingState')}
568
+ value={formatNationality(
569
+ documentMRZInfo.getIssuingState()
570
+ )}
571
+ />
572
+ {!!(
573
+ documentMRZInfo.getPersonalNumber() ||
574
+ documentMRZInfo.getOptionalData1()
575
+ ) && (
576
+ <IDCardField
577
+ label={t('eidScannerScreen.personalNumber')}
578
+ value={
579
+ documentMRZInfo.getPersonalNumber() ||
580
+ documentMRZInfo.getOptionalData1() ||
581
+ ''
582
+ }
583
+ />
584
+ )}
508
585
  </View>
509
- )}
586
+ </View>
510
587
  </View>
511
588
 
512
- {documentMRZInfo && (
513
- <View style={styles.mrzInfo}>
514
- <View style={styles.mrzInfoItem}>
515
- <Text style={styles.mrzInfoLabel}>
516
- {t('eidScannerScreen.name')}
517
- </Text>
518
- <Text style={styles.mrzInfoText}>
519
- {documentMRZInfo.getSecondaryIdentifier()}
520
- </Text>
521
- </View>
522
- <View style={styles.mrzInfoItem}>
523
- <Text style={styles.mrzInfoLabel}>
524
- {t('eidScannerScreen.surname')}
525
- </Text>
526
- <Text style={styles.mrzInfoText}>
527
- {documentMRZInfo.getPrimaryIdentifier()}
528
- </Text>
529
- </View>
530
- <View style={styles.mrzInfoItem}>
531
- <Text style={styles.mrzInfoLabel}>
532
- {t('eidScannerScreen.documentCode')}
533
- </Text>
534
- <Text style={styles.mrzInfoText}>
535
- {formatDocumentType(documentMRZInfo.getDocumentCode())}
536
- </Text>
537
- </View>
538
- <View style={styles.mrzInfoItem}>
539
- <Text style={styles.mrzInfoLabel}>
540
- {t('eidScannerScreen.documentNumber')}
541
- </Text>
542
- <Text style={styles.mrzInfoText}>
543
- {documentMRZInfo.getDocumentNumber()}
544
- </Text>
545
- </View>
546
- <View style={styles.mrzInfoItem}>
547
- <Text style={styles.mrzInfoLabel}>
548
- {t('eidScannerScreen.personalNumber')}
549
- </Text>
550
- <Text style={styles.mrzInfoText}>
551
- {documentMRZInfo.getOptionalData1()}
552
- </Text>
553
- </View>
554
- <View style={styles.mrzInfoItem}>
555
- <Text style={styles.mrzInfoLabel}>
556
- {t('eidScannerScreen.nationality')}
557
- </Text>
558
- <Text style={styles.mrzInfoText}>
559
- {formatNationality(documentMRZInfo.getNationality())}
560
- </Text>
561
- </View>
562
- <View style={styles.mrzInfoItem}>
563
- <Text style={styles.mrzInfoLabel}>
564
- {t('eidScannerScreen.gender')}
565
- </Text>
566
- <Text style={styles.mrzInfoText}>
567
- {formatGender(documentMRZInfo.getGender().getStrCode())}
568
- </Text>
569
- </View>
570
- <View style={styles.mrzInfoItem}>
571
- <Text style={styles.mrzInfoLabel}>
572
- {t('eidScannerScreen.birthDate')}
573
- </Text>
574
- <Text style={styles.mrzInfoText}>
575
- {formatDate(documentMRZInfo.getDateOfBirth())}
576
- </Text>
577
- </View>
578
- <View style={styles.mrzInfoItem}>
579
- <Text style={styles.mrzInfoLabel}>
580
- {t('eidScannerScreen.expirationDate')}
581
- </Text>
582
- <Text style={styles.mrzInfoText}>
583
- {formatDate(documentMRZInfo.getDateOfExpiry())}
584
- </Text>
585
- </View>
589
+ {hasNfc && isEnabled && isScanned && (
590
+ <View style={styles.buttonsContainer}>
591
+ <StyledButton
592
+ mode="contained"
593
+ disabled={!documentFaceImage || !documentMRZInfo}
594
+ onPress={() => {
595
+ if (
596
+ onScanSuccess &&
597
+ documentMRZInfo &&
598
+ documentFaceImage &&
599
+ documentFaceImageMimeType
600
+ ) {
601
+ onScanSuccess(
602
+ getFieldsFromMRZ(documentMRZInfo),
603
+ documentFaceImage,
604
+ documentFaceImageMimeType
605
+ );
606
+ }
607
+ }}
608
+ >
609
+ {t('eidScannerScreen.approveAndContinue')}
610
+ </StyledButton>
586
611
  </View>
587
612
  )}
588
613
  </View>
@@ -648,7 +673,7 @@ const EIDScanner = ({
648
673
  </View>
649
674
  )}
650
675
 
651
- {hasNfc && isEnabled && isScanned && (
676
+ {hasNfc && isEnabled && isScanned && !documentMRZInfo && (
652
677
  <View style={styles.buttonsContainer}>
653
678
  <StyledButton
654
679
  mode="contained"
@@ -747,83 +772,99 @@ const styles = StyleSheet.create({
747
772
  color: 'black',
748
773
  textAlign: 'center',
749
774
  },
750
- resultCard: {
775
+ // ── ID Card result layout ──────────────────────────────────────────────
776
+ idCardWrapper: {
777
+ gap: 10,
778
+ },
779
+ idCard: {
751
780
  backgroundColor: '#fff',
752
- borderRadius: 16,
753
- padding: 16,
754
- gap: 16,
781
+ borderRadius: 14,
782
+ overflow: 'hidden',
755
783
  shadowColor: '#000',
756
- shadowOffset: { width: 0, height: 2 },
757
- shadowOpacity: 0.08,
758
- shadowRadius: 8,
759
- elevation: 3,
784
+ shadowOffset: { width: 0, height: 3 },
785
+ shadowOpacity: 0.12,
786
+ shadowRadius: 10,
787
+ elevation: 4,
760
788
  borderWidth: 1,
761
- borderColor: '#f0f0f0',
789
+ borderColor: '#e8e8e8',
762
790
  },
763
- resultHeader: {
791
+ idCardStrip: {
764
792
  flexDirection: 'row',
765
- gap: 14,
766
- alignItems: 'flex-start',
767
- },
768
- resultHeaderInfo: {
769
- flex: 1,
770
- gap: 4,
771
- justifyContent: 'center',
793
+ justifyContent: 'space-between',
794
+ alignItems: 'center',
795
+ paddingHorizontal: 12,
796
+ paddingVertical: 6,
772
797
  },
773
- fullName: {
774
- fontSize: 17,
798
+ idCardStripDocType: {
799
+ color: '#fff',
800
+ fontSize: 12,
775
801
  fontWeight: '700',
776
- color: '#111',
777
- letterSpacing: 0.2,
802
+ letterSpacing: 0.5,
803
+ textTransform: 'uppercase',
778
804
  },
779
- subInfo: {
780
- fontSize: 13,
781
- color: '#666',
782
- fontWeight: '500',
805
+ idCardStripIssuer: {
806
+ color: 'rgba(255,255,255,0.85)',
807
+ fontSize: 12,
808
+ fontWeight: '600',
809
+ letterSpacing: 0.3,
810
+ },
811
+ idCardBody: {
812
+ flexDirection: 'row',
813
+ alignItems: 'stretch',
783
814
  },
784
- faceImage: {
785
- width: 90,
786
- height: 115,
815
+ idCardPhotoContainer: {
816
+ width: 110,
817
+ padding: 8,
818
+ alignSelf: 'stretch',
819
+ },
820
+ idCardPhotoFrame: {
821
+ flex: 1,
787
822
  borderRadius: 10,
788
823
  overflow: 'hidden',
824
+ borderWidth: 1,
825
+ borderColor: 'rgba(0,0,0,0.12)',
789
826
  },
790
- faceImagePlaceholder: {
791
- width: 90,
792
- height: 115,
827
+ idCardPhoto: {
828
+ flex: 1,
829
+ },
830
+ idCardPhotoPlaceholder: {
831
+ flex: 1,
793
832
  borderRadius: 10,
794
833
  backgroundColor: '#f5f5f5',
795
834
  alignItems: 'center',
796
835
  justifyContent: 'center',
797
836
  },
798
- faceImagePlaceholderText: {
799
- fontSize: 32,
837
+ idCardPhotoPlaceholderText: {
838
+ fontSize: 30,
800
839
  color: '#ccc',
801
840
  },
802
- mrzInfo: {
803
- flexDirection: 'column',
804
- width: '100%',
805
- gap: 2,
806
- },
807
- mrzInfoItem: {
841
+ idCardFields: {
842
+ flex: 1,
808
843
  flexDirection: 'row',
809
- justifyContent: 'space-between',
810
- alignItems: 'center',
811
- paddingVertical: 8,
812
- borderBottomColor: '#f0f0f0',
813
- borderBottomWidth: 1,
844
+ flexWrap: 'wrap',
845
+ gap: 6,
846
+ alignContent: 'flex-start',
847
+ padding: 10,
814
848
  },
815
- mrzInfoLabel: {
816
- color: '#888',
817
- fontSize: 13,
818
- fontWeight: '500',
849
+ idCardFieldItem: {
850
+ flexGrow: 1,
851
+ flexShrink: 1,
852
+ flexBasis: '45%',
853
+ minWidth: 0,
819
854
  },
820
- mrzInfoText: {
821
- color: '#111',
822
- fontSize: 13,
855
+ idCardFieldLabel: {
856
+ fontSize: 8,
857
+ fontWeight: '600',
858
+ color: '#999',
859
+ letterSpacing: 0.3,
860
+ marginBottom: 1,
861
+ },
862
+ idCardFieldValue: {
863
+ fontSize: 11,
823
864
  fontWeight: '600',
824
- textAlign: 'right',
865
+ color: '#111',
825
866
  flexShrink: 1,
826
- marginLeft: 8,
867
+ flexWrap: 'wrap',
827
868
  },
828
869
  guide: {
829
870
  flex: 1,
@@ -855,4 +896,13 @@ const styles = StyleSheet.create({
855
896
  },
856
897
  });
857
898
 
899
+ const IDCardField = ({ label, value }: { label: string; value: string }) => (
900
+ <View style={styles.idCardFieldItem}>
901
+ <Text style={styles.idCardFieldLabel} numberOfLines={1}>
902
+ {label}
903
+ </Text>
904
+ <Text style={styles.idCardFieldValue}>{value || '—'}</Text>
905
+ </View>
906
+ );
907
+
858
908
  export default EIDScanner;
@@ -1,16 +1,15 @@
1
1
  import { useIsFocused } from '@react-navigation/native';
2
2
  import { useKeepAwake } from '../Libs/native-keep-awake.utils';
3
3
  import { debugLog, debugWarn, debugError, logError } from '../Libs/debug.utils';
4
+ import PermissionManager from '../Libs/permissions.utils';
4
5
  import React, { useEffect, useState, useRef, useCallback } from 'react';
5
6
  import {
6
7
  StyleSheet,
7
8
  Text,
8
9
  View,
9
- Platform,
10
10
  Linking,
11
11
  ActivityIndicator,
12
12
  NativeModules,
13
- PermissionsAndroid,
14
13
  type NativeSyntheticEvent,
15
14
  } from 'react-native';
16
15
  import {
@@ -81,20 +80,24 @@ const FaceCamera = ({
81
80
 
82
81
  useEffect(() => {
83
82
  const requestPermissions = async () => {
84
- if (Platform.OS === 'android') {
85
- const cameraGranted = await PermissionsAndroid.request(
86
- PermissionsAndroid.PERMISSIONS.CAMERA
87
- );
88
- const audioGranted = await PermissionsAndroid.request(
89
- PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
90
- );
91
- setHasPermission(
92
- cameraGranted === PermissionsAndroid.RESULTS.GRANTED &&
93
- audioGranted === PermissionsAndroid.RESULTS.GRANTED
94
- );
95
- } else {
96
- // iOS permissions handled by native camera
97
- setHasPermission(true);
83
+ try {
84
+ const permissions = await PermissionManager.requestPermissions([
85
+ 'camera',
86
+ 'microphone',
87
+ ]);
88
+
89
+ const hasCamera = permissions.get('camera') ?? false;
90
+ const hasMicrophone = permissions.get('microphone') ?? false;
91
+
92
+ debugLog('FaceCamera', 'Permissions requested:', {
93
+ camera: hasCamera,
94
+ microphone: hasMicrophone,
95
+ });
96
+
97
+ setHasPermission(hasCamera && hasMicrophone);
98
+ } catch (error) {
99
+ debugError('FaceCamera', 'Permission request failed:', error);
100
+ setHasPermission(false);
98
101
  }
99
102
  setPermissionsRequested(true);
100
103
  };
@@ -21,7 +21,9 @@ import {
21
21
  type Frame,
22
22
  } from './TrustchexCamera';
23
23
  import { NativeModules } from 'react-native';
24
+ import PermissionManager from '../Libs/permissions.utils';
24
25
  import mrzUtils from '../Libs/mrz.utils';
26
+ import { MRZFrameAggregator } from '../Libs/mrzFrameAggregator';
25
27
  import { useKeepAwake } from '../Libs/native-keep-awake.utils';
26
28
  import { useIsFocused } from '@react-navigation/native';
27
29
  import { useTranslation } from 'react-i18next';
@@ -143,6 +145,10 @@ const IdentityDocumentCamera = ({
143
145
  const lastValidMRZText = useRef<string | null>(null);
144
146
  const lastValidMRZFields = useRef<any>(null);
145
147
  const validMRZConsecutiveCount = useRef(0);
148
+ // Multi-frame per-character voting: fuses OCR across frames so a single-frame
149
+ // misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
150
+ // resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
151
+ const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
146
152
 
147
153
  // Document type stability tracking - require consistent detections from good quality frames
148
154
  const lastDetectedDocType = useRef<
@@ -246,13 +252,12 @@ const IdentityDocumentCamera = ({
246
252
 
247
253
  useEffect(() => {
248
254
  const requestPermissions = async () => {
249
- if (Platform.OS === 'android') {
250
- const granted = await PermissionsAndroid.request(
251
- PermissionsAndroid.PERMISSIONS.CAMERA
252
- );
253
- setHasPermission(granted === PermissionsAndroid.RESULTS.GRANTED);
254
- } else {
255
- setHasPermission(true);
255
+ try {
256
+ const hasCameraPermission =
257
+ await PermissionManager.requestPermission('camera');
258
+ setHasPermission(hasCameraPermission);
259
+ } catch (error) {
260
+ setHasPermission(false);
256
261
  }
257
262
  setPermissionsRequested(true);
258
263
  };
@@ -277,6 +282,7 @@ const IdentityDocumentCamera = ({
277
282
  lastValidMRZText.current = null;
278
283
  lastValidMRZFields.current = null;
279
284
  validMRZConsecutiveCount.current = 0;
285
+ mrzAggregator.current.reset();
280
286
  cachedBarcode.current = null;
281
287
  isCompletionCallbackInvoked.current = false;
282
288
  isStepTransitionInProgress.current = false;
@@ -601,6 +607,8 @@ const IdentityDocumentCamera = ({
601
607
  if (nextStepType === 'SCAN_ID_BACK') {
602
608
  lastValidMRZText.current = null;
603
609
  lastValidMRZFields.current = null;
610
+ // New MRZ expected on the back — start voting fresh.
611
+ mrzAggregator.current.reset();
604
612
  }
605
613
  validMRZConsecutiveCount.current = 0;
606
614
  cachedBarcode.current = null; // Clear cached barcode on step change
@@ -848,14 +856,43 @@ const IdentityDocumentCamera = ({
848
856
  // Prefer MRZ-only text if available (from detected MRZ blocks),
849
857
  // otherwise fall back to all text (for backward compatibility)
850
858
  const textForValidation = scannedText?.mrzOnlyText || text;
859
+
860
+ // Multi-frame per-character voting: feed this frame's OCR into the
861
+ // aggregator (weighted by the MRZ band's pixel height as a sharpness/
862
+ // closeness proxy — a taller band means more pixels per character, which
863
+ // ML Kit reads more reliably) and prefer the fused consensus. This outvotes
864
+ // single-frame OCR errors (B↔8, S↔5, <↔K, dropped/inserted digit) instead
865
+ // of letting one noisy frame reset progress.
866
+ const mrzBandHeight = scannedText?.blocks?.length
867
+ ? Math.max(
868
+ 0,
869
+ ...scannedText.blocks.map((b) => b.blockFrame?.height ?? 0)
870
+ )
871
+ : 0;
872
+ const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
873
+ const consensus = mrzAggregator.current.addFrame({
874
+ text: textForValidation,
875
+ weight: frameWeight,
876
+ });
877
+
878
+ // Use the fused consensus when it is valid; otherwise fall back to a
879
+ // single-frame validation so document-type detection still works on the
880
+ // early frames. Only run the (expensive) single-frame pass when the
881
+ // consensus has NOT yet validated — once it has, the per-frame validation
882
+ // is redundant work that would needlessly load the JS thread.
851
883
  const mrzValidationResult =
852
- mrzUtils.validateMRZWithCorrections(textForValidation);
884
+ consensus.validation?.valid && consensus.validation.fields
885
+ ? consensus.validation
886
+ : mrzUtils.validateMRZWithCorrections(textForValidation);
853
887
  const parsedMRZData = {
854
888
  valid: mrzValidationResult.valid,
855
889
  fields: mrzValidationResult.fields || null,
856
890
  };
857
- // Extract raw MRZ lines from text if validation succeeded
858
- const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
891
+ // Raw MRZ lines: prefer the fused/corrected consensus when valid, else the
892
+ // lightweight per-frame clean. correctedMrz is the noise-free recovered MRZ.
893
+ const mrzText = parsedMRZData.valid
894
+ ? (mrzValidationResult.correctedMrz ?? mrzUtils.fixMRZ(text))
895
+ : null;
859
896
 
860
897
  // CRITICAL: Only detect document type during initial scan step
861
898
  // For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
@@ -1006,9 +1043,12 @@ const IdentityDocumentCamera = ({
1006
1043
 
1007
1044
  // Check if we have enough consistent valid reads
1008
1045
  const mrzStableAndValid =
1009
- validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1010
- parsedMRZData?.valid === true &&
1011
- areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
1046
+ // Either: the multi-frame voted consensus has converged (valid + stable),
1047
+ (consensus.stable && parsedMRZData?.valid === true) ||
1048
+ // or the legacy N-identical-reads path is satisfied.
1049
+ (validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1050
+ parsedMRZData?.valid === true &&
1051
+ areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields));
1012
1052
 
1013
1053
  // ============================================================================
1014
1054
  // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card