@trustchex/react-native-sdk 1.475.1 → 1.481.1

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 (108) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/ios/Camera/TrustchexCameraView.swift +20 -15
  4. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  5. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  6. package/lib/module/Screens/Static/ResultScreen.js +145 -113
  7. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  8. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
  10. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  11. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  12. package/lib/module/Shared/EIDReader/eidReader.js +159 -38
  13. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  14. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  15. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  16. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  17. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  18. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  19. package/lib/module/Shared/Libs/http-client.js +19 -6
  20. package/lib/module/Shared/Libs/mrz.utils.js +193 -33
  21. package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
  22. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  23. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  24. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  25. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  26. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  27. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  28. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  29. package/lib/module/Translation/Resources/en.js +6 -0
  30. package/lib/module/Translation/Resources/tr.js +6 -0
  31. package/lib/module/Trustchex.js +20 -4
  32. package/lib/module/version.js +1 -1
  33. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  35. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  38. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  41. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  43. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  46. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  48. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  49. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  50. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  51. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  52. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  53. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  54. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  55. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  56. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  57. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  59. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
  60. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  62. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  64. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  65. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  66. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  67. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  69. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  70. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  71. package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
  72. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  73. package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  75. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  76. package/lib/typescript/src/version.d.ts +1 -1
  77. package/package.json +17 -3
  78. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  79. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  80. package/src/Screens/Static/ResultScreen.tsx +172 -162
  81. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  82. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  83. package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
  84. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  85. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  86. package/src/Shared/Components/NavigationManager.tsx +7 -7
  87. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  88. package/src/Shared/EIDReader/eidReader.ts +181 -52
  89. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  90. package/src/Shared/Libs/SignalingClient.ts +10 -5
  91. package/src/Shared/Libs/crypto.utils.ts +41 -4
  92. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  93. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  94. package/src/Shared/Libs/diagnostics.ts +251 -0
  95. package/src/Shared/Libs/http-client.ts +24 -8
  96. package/src/Shared/Libs/mrz.utils.ts +200 -34
  97. package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
  98. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  99. package/src/Shared/Libs/promise.utils.ts +11 -5
  100. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  101. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  102. package/src/Shared/Services/AnalyticsService.ts +17 -1
  103. package/src/Shared/Services/DataUploadService.ts +155 -166
  104. package/src/Shared/Services/VideoSessionService.ts +15 -3
  105. package/src/Translation/Resources/en.ts +7 -0
  106. package/src/Translation/Resources/tr.ts +7 -0
  107. package/src/Trustchex.tsx +26 -3
  108. package/src/version.ts +1 -1
@@ -28,6 +28,7 @@ import { useKeepAwake } from '../Libs/native-keep-awake.utils';
28
28
  import { useIsFocused } from '@react-navigation/native';
29
29
  import { useTranslation } from 'react-i18next';
30
30
  import { debugLog, logError, isDebugEnabled } from '../Libs/debug.utils';
31
+ import { diagnostics } from '../Libs/diagnostics';
31
32
  import LottieView from 'lottie-react-native';
32
33
  import StyledButton from './StyledButton';
33
34
  import { SafeAreaView } from 'react-native-safe-area-context';
@@ -112,12 +113,25 @@ const IdentityDocumentCamera = ({
112
113
  const [currentFaceImage, setCurrentFaceImage] = useState<string | undefined>(
113
114
  undefined
114
115
  );
116
+ // Synchronous mirror of the locked face image (see currentHologramImageRef).
117
+ // Used as a fallback when completing SCAN_HOLOGRAM: the torch is on during
118
+ // hologram capture so the live frame's face is often rejected, leaving the
119
+ // state-derived faceImageToUse null within the stale closure — without this
120
+ // the completion gate's `&& !!faceImageToUse` would never pass.
121
+ const currentFaceImageRef = useRef<string | undefined>(undefined);
115
122
  const [_currentHologramMaskImage, setCurrentHologramMaskImage] = useState<
116
123
  string | undefined
117
124
  >(undefined);
118
125
  const [currentHologramImage, setCurrentHologramImage] = useState<
119
126
  string | undefined
120
127
  >(undefined);
128
+ // Mirror of currentHologramImage for synchronous reads inside the per-frame
129
+ // callback. The setState above is async and currentHologramImage is a
130
+ // useCallback dependency, so frames processed before React re-renders the
131
+ // closure would otherwise still see the old (undefined) value — causing the
132
+ // SCAN_HOLOGRAM step to re-collect faces forever and never complete after a
133
+ // successful detection. Always keep this ref in lockstep with the state.
134
+ const currentHologramImageRef = useRef<string | undefined>(undefined);
121
135
  const [currentSecondaryFaceImage, setCurrentSecondaryFaceImage] = useState<
122
136
  string | undefined
123
137
  >(undefined);
@@ -127,6 +141,11 @@ const IdentityDocumentCamera = ({
127
141
  const [status, setStatus] = useState<
128
142
  'SEARCHING' | 'SCANNING' | 'SCANNED' | 'INCORRECT'
129
143
  >('SEARCHING');
144
+ // True once the multi-frame voted MRZ consensus is STABLE + valid (the same
145
+ // reading matched across `stabilityTarget` consecutive frames, with a clear
146
+ // vote margin on contested cells). Drives the green "GO" signal — we never show
147
+ // green on an early/transient valid read, only on a reliably-detected MRZ.
148
+ const [mrzReliable, setMrzReliable] = useState(false);
130
149
  const [nextStep, setNextStep] = useState<
131
150
  'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM' | 'COMPLETED'
132
151
  >('SCAN_ID_FRONT_OR_PASSPORT');
@@ -148,7 +167,10 @@ const IdentityDocumentCamera = ({
148
167
  // Multi-frame per-character voting: fuses OCR across frames so a single-frame
149
168
  // misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
150
169
  // resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
151
- const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 2 }));
170
+ // stabilityTarget: 3 the voted consensus must read the SAME valid MRZ across
171
+ // 3 consecutive contributing frames before we accept, so a transient look-alike
172
+ // misread (e.g. OCR-B "4" vs "A") can never lock in on one or two early frames.
173
+ const mrzAggregator = useRef(new MRZFrameAggregator({ stabilityTarget: 3 }));
152
174
 
153
175
  // Document type stability tracking - require consistent detections from good quality frames
154
176
  const lastDetectedDocType = useRef<
@@ -180,10 +202,30 @@ const IdentityDocumentCamera = ({
180
202
  const isHologramDetectionInProgress = useRef(false); // Prevent concurrent hologram detection calls
181
203
  const isCompletionCallbackInvoked = useRef(false); // Prevent multiple callback invocations when COMPLETED
182
204
  const isStepTransitionInProgress = useRef(false); // Prevent duplicate step transitions from rapid frames
205
+ // The step a transition is currently in progress FROM. The latch above is
206
+ // reset asynchronously (via a nextStep effect), so in fast flows (e.g. demo
207
+ // mode auto-advancing) a later transition from a DIFFERENT step could be
208
+ // wrongly blocked while the latch is still set for the previous step. Gating
209
+ // on the originating step lets a genuine next-step transition through while
210
+ // still de-duping rapid repeat frames of the SAME transition.
211
+ const stepTransitionFrom = useRef<
212
+ 'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM' | null
213
+ >(null);
183
214
  const isCompletionAnnouncementSpoken = useRef(false); // Prevent duplicate "scan completed" voice guidance
184
215
 
185
216
  const faceDetectionErrorCount = useRef(0);
186
217
  const brightnessHistory = useRef<number[]>([]);
218
+ // Running camera-quality stats for the support diagnostic report.
219
+ const diagStats = useRef({
220
+ frames: 0,
221
+ brightnessSum: 0,
222
+ minBrightness: 255,
223
+ maxBrightness: 0,
224
+ frameW: 0,
225
+ frameH: 0,
226
+ hadLowBrightness: false,
227
+ hadBlurryFrames: false,
228
+ });
187
229
  const [faceDetectionEnabled, setFaceDetectionEnabled] = useState(true);
188
230
  const faceImages = useRef<string[]>([]);
189
231
  const hologramImageCountRef = useRef(0);
@@ -269,6 +311,7 @@ const IdentityDocumentCamera = ({
269
311
  setIsActive(true);
270
312
  isCompletionCallbackInvoked.current = false; // Reset callback flag when starting new scan
271
313
  isStepTransitionInProgress.current = false;
314
+ stepTransitionFrom.current = null;
272
315
  isCompletionAnnouncementSpoken.current = false;
273
316
  } else {
274
317
  setIsActive(false);
@@ -283,12 +326,28 @@ const IdentityDocumentCamera = ({
283
326
  lastValidMRZFields.current = null;
284
327
  validMRZConsecutiveCount.current = 0;
285
328
  mrzAggregator.current.reset();
329
+ setMrzReliable(false);
330
+ // Start a fresh diagnostics session for the support report.
331
+ diagnostics.reset(Date.now());
332
+ diagStats.current = {
333
+ frames: 0,
334
+ brightnessSum: 0,
335
+ minBrightness: 255,
336
+ maxBrightness: 0,
337
+ frameW: 0,
338
+ frameH: 0,
339
+ hadLowBrightness: false,
340
+ hadBlurryFrames: false,
341
+ };
286
342
  cachedBarcode.current = null;
287
343
  isCompletionCallbackInvoked.current = false;
288
344
  isStepTransitionInProgress.current = false;
345
+ stepTransitionFrom.current = null;
289
346
  isCompletionAnnouncementSpoken.current = false;
290
347
  // Clear all captured image states from previous scan
348
+ currentFaceImageRef.current = undefined;
291
349
  setCurrentFaceImage(undefined);
350
+ currentHologramImageRef.current = undefined;
292
351
  setCurrentHologramImage(undefined);
293
352
  setCurrentSecondaryFaceImage(undefined);
294
353
  resetLastMessage();
@@ -302,9 +361,12 @@ const IdentityDocumentCamera = ({
302
361
  setLatestHologramFaceImage(undefined);
303
362
  isCompletionCallbackInvoked.current = false; // Reset callback flag on unmount
304
363
  isStepTransitionInProgress.current = false;
364
+ stepTransitionFrom.current = null;
305
365
  isCompletionAnnouncementSpoken.current = false;
306
366
  // Clear all captured image states on unmount
367
+ currentFaceImageRef.current = undefined;
307
368
  setCurrentFaceImage(undefined);
369
+ currentHologramImageRef.current = undefined;
308
370
  setCurrentHologramImage(undefined);
309
371
  setCurrentSecondaryFaceImage(undefined);
310
372
  resetLastMessage();
@@ -378,6 +440,7 @@ const IdentityDocumentCamera = ({
378
440
  isCompletionCallbackInvoked.current = false;
379
441
  }
380
442
  isStepTransitionInProgress.current = false;
443
+ stepTransitionFrom.current = null;
381
444
  }, [nextStep]);
382
445
 
383
446
  // Update status bar based on guide visibility
@@ -578,6 +641,7 @@ const IdentityDocumentCamera = ({
578
641
  setHologramImageCount(0);
579
642
  setLatestHologramFaceImage(undefined);
580
643
  // Clear previous hologram state to prevent premature completion
644
+ currentHologramImageRef.current = undefined;
581
645
  setCurrentHologramImage(undefined);
582
646
  setCurrentHologramMaskImage(undefined);
583
647
  }
@@ -609,6 +673,7 @@ const IdentityDocumentCamera = ({
609
673
  lastValidMRZFields.current = null;
610
674
  // New MRZ expected on the back — start voting fresh.
611
675
  mrzAggregator.current.reset();
676
+ setMrzReliable(false);
612
677
  }
613
678
  validMRZConsecutiveCount.current = 0;
614
679
  cachedBarcode.current = null; // Clear cached barcode on step change
@@ -633,11 +698,18 @@ const IdentityDocumentCamera = ({
633
698
  fromStep: 'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM',
634
699
  scannedData: DocumentScannedData
635
700
  ) => {
636
- if (isStepTransitionInProgress.current) {
701
+ // De-dupe only rapid repeat frames of the SAME originating step. A
702
+ // transition from a different step is a genuine advance (the async latch
703
+ // reset may not have run yet in fast flows like demo mode), so allow it.
704
+ if (
705
+ isStepTransitionInProgress.current &&
706
+ stepTransitionFrom.current === fromStep
707
+ ) {
637
708
  return;
638
709
  }
639
710
 
640
711
  isStepTransitionInProgress.current = true;
712
+ stepTransitionFrom.current = fromStep;
641
713
 
642
714
  // Torch only needed during SCAN_HOLOGRAM - turn off for all other transitions
643
715
  if (nextStepType !== 'SCAN_HOLOGRAM') {
@@ -870,9 +942,32 @@ const IdentityDocumentCamera = ({
870
942
  )
871
943
  : 0;
872
944
  const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
945
+ // ML Kit per-symbol confidence over MRZ characters (Android bundled library
946
+ // only; undefined on iOS). When present it down-weights frames the
947
+ // recognizer was unsure about, so a confident frame outvotes a shaky one of
948
+ // the same band height on look-alike ties (A↔4, S↔5, B↔8).
949
+ const frameConfidence = scannedText?.textConfidence?.mean;
873
950
  const consensus = mrzAggregator.current.addFrame({
874
951
  text: textForValidation,
875
952
  weight: frameWeight,
953
+ confidence: frameConfidence,
954
+ });
955
+
956
+ // Record MRZ consensus metrics for the support diagnostic report (counts
957
+ // and pass/fail only — never the MRZ values themselves). `reachedStable`
958
+ // is sticky in the collector, so a later flicker can't unset it.
959
+ // `stabilityFrames`/`minContestedMargin` and the MRZ band pixel height are
960
+ // device-variance signals (convergence speed, look-alike resolution
961
+ // confidence, and whether the band cleared the ~16px/char OCR floor).
962
+ diagnostics.setMrz({
963
+ framesProcessed: consensus.frames,
964
+ reachedStable: consensus.stable,
965
+ format: consensus.validation?.format ?? undefined,
966
+ stabilityFrames: consensus.stableStreak,
967
+ minContestedMargin: Number.isFinite(consensus.minContestedMargin)
968
+ ? consensus.minContestedMargin
969
+ : 0,
970
+ maxMrzBandHeightPx: mrzBandHeight,
876
971
  });
877
972
 
878
973
  // Use the fused consensus when it is valid; otherwise fall back to a
@@ -912,6 +1007,9 @@ const IdentityDocumentCamera = ({
912
1007
  ? 'PASSPORT'
913
1008
  : detectedDocumentType;
914
1009
 
1010
+ // Record the detected document type for the diagnostic report.
1011
+ if (documentType) diagnostics.setDocument(documentType);
1012
+
915
1013
  // Crop faces once document type is confirmed or we're past the initial step
916
1014
  const shouldCropFaces =
917
1015
  documentType === 'ID_FRONT' ||
@@ -995,6 +1093,7 @@ const IdentityDocumentCamera = ({
995
1093
  // Continue scanning without locking this face
996
1094
  } else {
997
1095
  faceImageToUse = croppedFaces[0];
1096
+ currentFaceImageRef.current = croppedFaces[0];
998
1097
  setCurrentFaceImage(croppedFaces[0]);
999
1098
  }
1000
1099
  }
@@ -1009,15 +1108,6 @@ const IdentityDocumentCamera = ({
1009
1108
  // During SCAN_HOLOGRAM, allow processing even if text is not readable
1010
1109
  }
1011
1110
 
1012
- // Capture test mode data
1013
- if (testMode && text && text.includes('<')) {
1014
- const mrzOnlyText = scannedText?.mrzOnlyText || text;
1015
- setTestModeData({
1016
- mrzText: mrzOnlyText,
1017
- timestamp: Date.now(),
1018
- });
1019
- }
1020
-
1021
1111
  // MRZ stability: require consistent valid reads across frames
1022
1112
  // Skip during SCAN_HOLOGRAM - document type already locked
1023
1113
  const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
@@ -1041,14 +1131,39 @@ const IdentityDocumentCamera = ({
1041
1131
  }
1042
1132
  // else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
1043
1133
 
1044
- // Check if we have enough consistent valid reads
1134
+ // Accept ONLY when the multi-frame voted consensus is stable.
1135
+ //
1136
+ // Early frames can OCR an ambiguous glyph wrongly yet still produce a
1137
+ // check-digit-VALID reading — e.g. a Turkish document number "A53032352"
1138
+ // misread as "453032352" (the OCR-B 4 looks like A, and 453032352 also
1139
+ // passes its check digit). The old code also accepted on a legacy path of
1140
+ // "N identical valid single-frame reads", which those transient frames
1141
+ // satisfy — locking in the WRONG number before the voted consensus settles
1142
+ // on the correct one. We now require `consensus.stable`: the per-character
1143
+ // vote must be unchanged across `stabilityTarget` consecutive contributing
1144
+ // frames, which is exactly the "only accept once stable" guarantee.
1145
+ // `addFrame` runs for every frame above, so the consensus is always current.
1045
1146
  const mrzStableAndValid =
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));
1147
+ consensus.stable && parsedMRZData?.valid === true;
1148
+
1149
+ // Surface the reliable-detection signal to the render so the UI can show a
1150
+ // green "GO" the moment (and only once) the consensus is stable + valid.
1151
+ // Never flips green on an early/transient valid read.
1152
+ setMrzReliable((prev) => (prev === mrzStableAndValid ? prev : mrzStableAndValid));
1153
+
1154
+ // Test-mode panel: show the MRZ ONLY once it is stable + valid (the same
1155
+ // consensus matched across `stabilityTarget` consecutive frames). Showing
1156
+ // the raw per-frame OCR here would flash transient, possibly mis-converted
1157
+ // readings (e.g. a momentary "4" for "A") before the vote settles — the
1158
+ // opposite of what the screen should convey. We display the corrected,
1159
+ // check-digit-valid consensus and never an unstable intermediate.
1160
+ if (testMode && mrzStableAndValid && mrzText) {
1161
+ setTestModeData((prev) =>
1162
+ prev?.mrzText === mrzText
1163
+ ? prev
1164
+ : { mrzText, timestamp: Date.now() }
1165
+ );
1166
+ }
1052
1167
 
1053
1168
  // ============================================================================
1054
1169
  // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
@@ -1278,8 +1393,8 @@ const IdentityDocumentCamera = ({
1278
1393
  if (primaryFaceOnly) {
1279
1394
  hologramFramesWithoutFace.current = 0;
1280
1395
 
1281
- if (currentHologramImage) {
1282
- scannedData.hologramImage = currentHologramImage;
1396
+ if (currentHologramImageRef.current) {
1397
+ scannedData.hologramImage = currentHologramImageRef.current;
1283
1398
  } else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
1284
1399
  // Space out captures for better variation
1285
1400
  const timeSinceLastCapture =
@@ -1366,6 +1481,7 @@ const IdentityDocumentCamera = ({
1366
1481
  if (hologram) {
1367
1482
  setCurrentHologramMaskImage(hologramMask);
1368
1483
  scannedData.hologramImage = hologram;
1484
+ currentHologramImageRef.current = hologram;
1369
1485
  setCurrentHologramImage(hologram);
1370
1486
  if (isDebugEnabled()) {
1371
1487
  debugLog(
@@ -1397,8 +1513,8 @@ const IdentityDocumentCamera = ({
1397
1513
  } else {
1398
1514
  hologramFramesWithoutFace.current++;
1399
1515
  }
1400
- } else if (currentHologramImage) {
1401
- scannedData.hologramImage = currentHologramImage;
1516
+ } else if (currentHologramImageRef.current) {
1517
+ scannedData.hologramImage = currentHologramImageRef.current;
1402
1518
  }
1403
1519
 
1404
1520
  // Secondary face capture (continuous during initial scan and hologram detection)
@@ -1611,13 +1727,23 @@ const IdentityDocumentCamera = ({
1611
1727
  return;
1612
1728
  }
1613
1729
 
1614
- // Complete when hologram captured OR retries exhausted (not mid-collection)
1730
+ // Complete when hologram captured OR retries exhausted (not mid-collection).
1731
+ // Fall back to the previously locked face (ref) when the live frame's face
1732
+ // was rejected — the torch is on during hologram capture, so faceImageToUse
1733
+ // is frequently null even though a valid face was locked during the front
1734
+ // scan. Without this the gate's face requirement could never be satisfied.
1735
+ const faceForCompletion = faceImageToUse ?? currentFaceImageRef.current;
1615
1736
  const stepComplete =
1616
1737
  (!!scannedData.hologramImage ||
1617
1738
  (maxRetriesReached && !isCollecting)) &&
1618
- !!faceImageToUse; // Require face before completing
1739
+ !!faceForCompletion; // Require face before completing
1619
1740
 
1620
1741
  if (stepComplete) {
1742
+ // Make sure the locked face rides along even if this frame's face was
1743
+ // rejected.
1744
+ if (!scannedData.faceImage && faceForCompletion) {
1745
+ scannedData.faceImage = faceForCompletion;
1746
+ }
1621
1747
  // Ensure preserved MRZ data is included (current frame may not have
1622
1748
  // readable MRZ due to flash/tilting during hologram capture)
1623
1749
  if (!scannedData.mrzText && lastValidMRZText.current) {
@@ -1820,6 +1946,21 @@ const IdentityDocumentCamera = ({
1820
1946
  const avgBrightness =
1821
1947
  brightnessHistory.current.reduce((a, b) => a + b, 0) /
1822
1948
  brightnessHistory.current.length;
1949
+
1950
+ // Accumulate camera diagnostics (frame count, brightness range, dims) for
1951
+ // the support diagnostic report. Cheap, side-effect-only.
1952
+ diagStats.current.frames += 1;
1953
+ diagStats.current.brightnessSum += frameBrightness;
1954
+ diagStats.current.minBrightness = Math.min(
1955
+ diagStats.current.minBrightness,
1956
+ frameBrightness
1957
+ );
1958
+ diagStats.current.maxBrightness = Math.max(
1959
+ diagStats.current.maxBrightness,
1960
+ frameBrightness
1961
+ );
1962
+ diagStats.current.frameW = frame.width ?? diagStats.current.frameW;
1963
+ diagStats.current.frameH = frame.height ?? diagStats.current.frameH;
1823
1964
  const isOverallBright = avgBrightness >= MIN_BRIGHTNESS_THRESHOLD;
1824
1965
 
1825
1966
  // Check brightness in center region (area of interest)
@@ -1838,6 +1979,7 @@ const IdentityDocumentCamera = ({
1838
1979
  }
1839
1980
 
1840
1981
  setIsBrightnessLow(!isRegionBright);
1982
+ if (!isRegionBright) diagStats.current.hadLowBrightness = true;
1841
1983
 
1842
1984
  // Check blur only in center region (area of interest) to avoid false positives
1843
1985
  // from iOS depth-of-field background blur
@@ -1856,10 +1998,31 @@ const IdentityDocumentCamera = ({
1856
1998
  );
1857
1999
  isNotBlurry = !isBlurry;
1858
2000
  setIsFrameBlurry(isBlurry);
2001
+ if (isBlurry) diagStats.current.hadBlurryFrames = true;
1859
2002
  } catch (error) {
1860
2003
  setIsFrameBlurry(false);
1861
2004
  }
1862
2005
 
2006
+ // Flush accumulated camera stats into the diagnostics collector.
2007
+ const ds = diagStats.current;
2008
+ diagnostics.setCamera({
2009
+ frames: ds.frames,
2010
+ frameW: ds.frameW,
2011
+ frameH: ds.frameH,
2012
+ avgBrightness:
2013
+ ds.frames > 0 ? Math.round(ds.brightnessSum / ds.frames) : 0,
2014
+ minBrightness: ds.minBrightness === 255 && ds.frames === 0 ? 0 : ds.minBrightness,
2015
+ maxBrightness: ds.maxBrightness,
2016
+ hadLowBrightness: ds.hadLowBrightness,
2017
+ hadBlurryFrames: ds.hadBlurryFrames,
2018
+ // The actual gates the measured values are judged against — lets support
2019
+ // read "measured 45 vs floor 45" instead of just a pass/fail flag.
2020
+ thresholds: {
2021
+ minBrightness: MIN_BRIGHTNESS_THRESHOLD,
2022
+ blur: 50,
2023
+ },
2024
+ });
2025
+
1863
2026
  // Only proceed if image quality is acceptable
1864
2027
  const hasAcceptableQuality = isRegionBright && isNotBlurry;
1865
2028
 
@@ -1908,6 +2071,7 @@ const IdentityDocumentCamera = ({
1908
2071
  const scannedText: BlockText = {
1909
2072
  resultText,
1910
2073
  mrzOnlyText: undefined, // Will be set below if MRZ blocks detected
2074
+ textConfidence: frame.textConfidence,
1911
2075
  blocks: textBlocks.map((block) => ({
1912
2076
  blockText: block.text || '',
1913
2077
  blockFrame: block.blockFrame ?? {
@@ -2430,8 +2594,11 @@ const IdentityDocumentCamera = ({
2430
2594
  style={[
2431
2595
  styles.topZoneText,
2432
2596
  // Priority order for coloring (later styles override earlier ones)
2433
- // 1. Success (green) - scan completed
2434
- nextStep === 'COMPLETED' && styles.topZoneTextSuccess,
2597
+ // 1. Success/GO (green) - scan completed OR MRZ reliably detected
2598
+ // (stable consensus). This is the "GO" signal; never shown on a
2599
+ // transient/early valid read.
2600
+ (nextStep === 'COMPLETED' || mrzReliable) &&
2601
+ styles.topZoneTextSuccess,
2435
2602
  // 2. Error (red) - wrong side - with flash opacity
2436
2603
  status === 'INCORRECT' && styles.topZoneTextError,
2437
2604
  status === 'INCORRECT' && {
@@ -2457,7 +2624,8 @@ const IdentityDocumentCamera = ({
2457
2624
  isFrameBlurry,
2458
2625
  allElementsDetected,
2459
2626
  elementsOutsideScanArea,
2460
- t
2627
+ t,
2628
+ mrzReliable
2461
2629
  )}
2462
2630
  </AnimatedText>
2463
2631
  </View>
@@ -2469,7 +2637,9 @@ const IdentityDocumentCamera = ({
2469
2637
  styles.scanArea,
2470
2638
  {
2471
2639
  borderColor:
2472
- nextStep === 'COMPLETED'
2640
+ // Green once the scan is done OR the MRZ is reliably detected
2641
+ // (stable consensus) — the "GO" signal. Never green before that.
2642
+ nextStep === 'COMPLETED' || mrzReliable
2473
2643
  ? '#4CAF50'
2474
2644
  : status === 'INCORRECT'
2475
2645
  ? '#f44336'
@@ -2478,7 +2648,8 @@ const IdentityDocumentCamera = ({
2478
2648
  : isBrightnessLow || isFrameBlurry
2479
2649
  ? '#FFC107'
2480
2650
  : 'white',
2481
- borderWidth: status === 'SCANNING' ? 3 : 2,
2651
+ borderWidth:
2652
+ mrzReliable || status === 'SCANNING' ? 3 : 2,
2482
2653
  },
2483
2654
  ]}
2484
2655
  >
@@ -30,6 +30,12 @@ export type BlockText = {
30
30
  blocks: BlocksData[];
31
31
  resultText: string;
32
32
  mrzOnlyText?: string;
33
+ /**
34
+ * Per-frame OCR confidence over MRZ characters from ML Kit per-symbol
35
+ * confidence (Android bundled library only; undefined on iOS). Feeds the
36
+ * multi-frame vote weight so low-confidence frames count less.
37
+ */
38
+ textConfidence?: { mean: number; min: number; count: number };
33
39
  };
34
40
 
35
41
  export type BlocksData = {
@@ -96,12 +96,20 @@ export function getStatusMessage(
96
96
  isFrameBlurry: boolean,
97
97
  allElementsDetected: boolean,
98
98
  elementsOutsideScanArea: string[],
99
- t: (key: string, params?: Record<string, unknown>) => string
99
+ t: (key: string, params?: Record<string, unknown>) => string,
100
+ mrzReliable: boolean = false
100
101
  ): string {
101
102
  if (nextStep === 'COMPLETED') {
102
103
  return t('identityDocumentCamera.scanCompleted');
103
104
  }
104
105
 
106
+ // MRZ reliably detected (stable consensus) — the green "GO" message. Ranks
107
+ // above the in-progress states so the user gets a clear positive signal the
108
+ // moment the reading has settled, but never on an early/transient valid read.
109
+ if (mrzReliable) {
110
+ return t('identityDocumentCamera.mrzDetected');
111
+ }
112
+
105
113
  if (status === 'INCORRECT') {
106
114
  if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
107
115
  return t('identityDocumentCamera.alignIDFront');
@@ -13,7 +13,6 @@ import {
13
13
  } from '@react-navigation/native';
14
14
  import { View, StyleSheet, Alert } from 'react-native';
15
15
  import { useTranslation } from 'react-i18next';
16
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
16
  import i18n from '../../Translation';
18
17
  import StyledButton from './StyledButton';
19
18
  import { analyticsService } from '../Services/AnalyticsService';
@@ -37,7 +36,6 @@ const NavigationManager = forwardRef(
37
36
  const appContext = useContext(AppContext);
38
37
  const navigation = useNavigation();
39
38
  const { t } = useTranslation();
40
- const insets = useSafeAreaInsets();
41
39
 
42
40
  const routes = {
43
41
  VERIFICATION_SESSION_CHECK: 'VerificationSessionCheckScreen',
@@ -274,7 +272,7 @@ const NavigationManager = forwardRef(
274
272
  return (
275
273
  appContext.currentWorkflowStep &&
276
274
  (!appContext.currentWorkflowStep?.required || canSkipStep) && (
277
- <View style={[styles.container, { paddingBottom: insets.bottom }]}>
275
+ <View style={styles.container}>
278
276
  <StyledButton mode="text" onPress={goToNextRouteWithAlert}>
279
277
  {t('navigationManager.skipStepLabel')}
280
278
  </StyledButton>
@@ -285,11 +283,13 @@ const NavigationManager = forwardRef(
285
283
  );
286
284
 
287
285
  const styles = StyleSheet.create({
286
+ // Hug the text button: centered, minimal padding, no full-width opaque bar so
287
+ // it occupies the least area and never overlays sibling content. The screen's
288
+ // footer wrapper already applies the bottom safe-area inset.
288
289
  container: {
289
- justifyContent: 'center',
290
- alignItems: 'center',
291
- padding: 20,
292
- backgroundColor: 'white',
290
+ alignSelf: 'center',
291
+ paddingHorizontal: 16,
292
+ paddingVertical: 8,
293
293
  },
294
294
  });
295
295
 
@@ -62,6 +62,12 @@ export interface Frame {
62
62
  faces?: NativeFace[];
63
63
  textBlocks?: NativeTextBlock[];
64
64
  resultText?: string;
65
+ /**
66
+ * Per-frame OCR confidence over MRZ characters, from ML Kit per-symbol
67
+ * confidence. Android (bundled ML Kit library) only — iOS ML Kit exposes no
68
+ * symbol-level confidence, so this is undefined there.
69
+ */
70
+ textConfidence?: { mean: number; min: number; count: number };
65
71
  barcodes?: NativeBarcode[];
66
72
  }
67
73