@trustchex/react-native-sdk 1.478.7 → 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 (61) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
  2. package/ios/Camera/TrustchexCameraView.swift +20 -15
  3. package/lib/module/Screens/Static/ResultScreen.js +42 -6
  4. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  5. package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
  6. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  7. package/lib/module/Shared/EIDReader/eidReader.js +149 -29
  8. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  9. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  10. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  11. package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
  12. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  13. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  14. package/lib/module/Translation/Resources/en.js +5 -0
  15. package/lib/module/Translation/Resources/tr.js +5 -0
  16. package/lib/module/Trustchex.js +20 -4
  17. package/lib/module/version.js +1 -1
  18. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  19. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  20. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  26. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  29. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  30. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  31. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  32. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
  33. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  35. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  37. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  38. package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
  39. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  40. package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
  41. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  42. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  43. package/lib/typescript/src/version.d.ts +1 -1
  44. package/package.json +5 -1
  45. package/src/Screens/Static/ResultScreen.tsx +46 -1
  46. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  47. package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
  48. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  49. package/src/Shared/Components/NavigationManager.tsx +7 -7
  50. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  51. package/src/Shared/EIDReader/eidReader.ts +166 -46
  52. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  53. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  54. package/src/Shared/Libs/diagnostics.ts +251 -0
  55. package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
  56. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  57. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  58. package/src/Translation/Resources/en.ts +6 -0
  59. package/src/Translation/Resources/tr.ts +6 -0
  60. package/src/Trustchex.tsx +26 -3
  61. package/src/version.ts +1 -1
@@ -40,6 +40,7 @@ import com.google.mlkit.vision.text.TextRecognition
40
40
  import com.google.mlkit.vision.text.latin.TextRecognizerOptions
41
41
  import java.io.ByteArrayOutputStream
42
42
  import kotlin.math.max
43
+ import kotlin.math.min
43
44
  import java.io.File
44
45
  import java.util.concurrent.ExecutorService
45
46
  import java.util.concurrent.Executors
@@ -524,8 +525,35 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
524
525
  val result = textTask.result
525
526
  frameData.putString("resultText", result.text)
526
527
 
528
+ // Per-symbol confidence aggregation (ML Kit BUNDLED library
529
+ // only — Text.Symbol.confidence is 0 on the unbundled
530
+ // play-services variant). We walk Block→Line→Element→Symbol
531
+ // and accumulate confidence over MRZ-shaped characters
532
+ // ([A-Z0-9<]) only, so surrounding non-MRZ text doesn't skew
533
+ // the weight. The mean and min are surfaced to JS as a
534
+ // per-frame quality signal that down-weights low-confidence
535
+ // frames in the multi-frame vote.
536
+ var confSum = 0f
537
+ var confCount = 0
538
+ var confMin = 1f
539
+
527
540
  val blocksArray = Arguments.createArray()
528
541
  for (block in result.textBlocks) {
542
+ for (line in block.lines) {
543
+ for (element in line.elements) {
544
+ for (symbol in element.symbols) {
545
+ val ch = symbol.text.firstOrNull() ?: continue
546
+ val upper = ch.uppercaseChar()
547
+ val isMrzChar = upper in 'A'..'Z' ||
548
+ upper in '0'..'9' || upper == '<'
549
+ if (!isMrzChar) continue
550
+ val c = symbol.confidence ?: continue
551
+ confSum += c
552
+ confCount++
553
+ if (c < confMin) confMin = c
554
+ }
555
+ }
556
+ }
529
557
  val blockMap = Arguments.createMap()
530
558
  blockMap.putString("text", block.text)
531
559
  val bb = block.boundingBox
@@ -552,6 +580,17 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
552
580
  blocksArray.pushMap(blockMap)
553
581
  }
554
582
  frameData.putArray("textBlocks", blocksArray)
583
+
584
+ // Frame-level MRZ-character confidence (mean + min over MRZ
585
+ // symbols). Omitted when ML Kit returned no usable confidence
586
+ // (count == 0) so JS can fall back to band-height weighting.
587
+ if (confCount > 0) {
588
+ val confMap = Arguments.createMap()
589
+ confMap.putDouble("mean", (confSum / confCount).toDouble())
590
+ confMap.putDouble("min", confMin.toDouble())
591
+ confMap.putInt("count", confCount)
592
+ frameData.putMap("textConfidence", confMap)
593
+ }
555
594
  } else {
556
595
  frameData.putString("resultText", "")
557
596
  frameData.putArray("textBlocks", Arguments.createArray())
@@ -1085,11 +1124,26 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
1085
1124
  full.recycle()
1086
1125
 
1087
1126
  // Upscale so a ~30–44-char MRZ line clears the per-character pixel
1088
- // minimum. Cap the long edge to keep OCR latency bounded.
1127
+ // minimum — but NO FURTHER. ML Kit gains no accuracy above ~24px per
1128
+ // character (Google's guidance), and upscaling past that only adds
1129
+ // interpolation artifacts and latency. So target ~24px/char and take
1130
+ // the SMALLER of (a) the scale needed to reach that target and (b) the
1131
+ // long-edge/latency cap. When the band is already sharp enough, scale
1132
+ // stays at 1 (skip upscaling entirely).
1089
1133
  val maxLongEdge = 2200
1090
1134
  val longEdge = max(cropW, cropH)
1091
- val rawScale = if (longEdge > 0) (maxLongEdge.toFloat() / longEdge) else 1f
1092
- val scale = rawScale.coerceIn(1f, 2.5f)
1135
+ val capScale = if (longEdge > 0) {
1136
+ (maxLongEdge.toFloat() / longEdge).coerceIn(1f, 2.5f)
1137
+ } else 1f
1138
+ // Estimate current glyph height: the MRZ band is up to ~3 text lines
1139
+ // (TD1) within the crop; ~1.4 accounts for line spacing vs glyph height.
1140
+ val mrzBandLines = 3f
1141
+ val targetGlyphPx = 24f
1142
+ val estGlyphPx = (cropH / mrzBandLines) / 1.4f
1143
+ val targetScale = if (estGlyphPx > 0f) {
1144
+ (targetGlyphPx / estGlyphPx).coerceAtLeast(1f)
1145
+ } else capScale
1146
+ val scale = min(targetScale, capScale)
1093
1147
  val outW = (cropW * scale).toInt()
1094
1148
  val outH = (cropH * scale).toInt()
1095
1149
  val scaled = if (scale != 1f) {
@@ -972,19 +972,14 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
972
972
  blockY = Int(bb.origin.y / roi.scale) + roi.offsetY
973
973
  blockWidth = Int(bb.width / roi.scale)
974
974
  blockHeight = Int(bb.height / roi.scale)
975
- } else if isBufferLandscape {
976
- // Rotate from landscape (1920x1080) to portrait (1080x1920)
977
- // When rotating 90° clockwise (.right):
978
- // new_x = old_y
979
- // new_y = landscape_width - old_x - width
980
- // new_width = old_height
981
- // new_height = old_width
982
- blockX = Int(bb.origin.y)
983
- blockY = pixelWidth - Int(bb.origin.x) - Int(bb.width)
984
- blockWidth = Int(bb.height)
985
- blockHeight = Int(bb.width)
986
975
  } else {
987
- // Already portrait, use directly
976
+ // Fallback when the ROI couldn't be built: OCR ran on
977
+ // `visionImage`, which is created from `orientedImage` — already
978
+ // rotated to portrait (.right applied above when the buffer was
979
+ // landscape) with VisionImage.orientation = .up. ML Kit therefore
980
+ // returns coordinates in PORTRAIT space already, so they are used
981
+ // directly. (A prior landscape→portrait rotation here was a
982
+ // double-rotation bug, since the image was no longer landscape.)
988
983
  blockX = Int(bb.origin.x)
989
984
  blockY = Int(bb.origin.y)
990
985
  blockWidth = Int(bb.width)
@@ -1091,10 +1086,20 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
1091
1086
  // Move the crop origin to (0,0) so downstream extents are simple.
1092
1087
  img = img.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
1093
1088
 
1094
- // Upscale (≤2.5×, long edge capped at 2200) for more pixels per character.
1089
+ // Upscale for more pixels per character, but only up to ~24px/char — ML Kit
1090
+ // gains no accuracy above that (Google's guidance), and scaling further only
1091
+ // adds interpolation artifacts and latency. Take the SMALLER of the scale
1092
+ // needed to reach the target glyph height and the long-edge/latency cap; a
1093
+ // band that's already sharp enough stays at scale 1 (no upscaling).
1095
1094
  let longEdge = max(cropW, cropH)
1096
- let rawScale = longEdge > 0 ? (2200.0 / longEdge) : 1.0
1097
- let scale = min(max(rawScale, 1.0), 2.5)
1095
+ let capScale = longEdge > 0 ? min(max(2200.0 / longEdge, 1.0), 2.5) : 1.0
1096
+ // MRZ band is up to ~3 text lines (TD1) within the crop; ~1.4 accounts for
1097
+ // line spacing vs glyph height.
1098
+ let mrzBandLines: CGFloat = 3.0
1099
+ let targetGlyphPx: CGFloat = 24.0
1100
+ let estGlyphPx = (cropH / mrzBandLines) / 1.4
1101
+ let targetScale = estGlyphPx > 0 ? max(targetGlyphPx / estGlyphPx, 1.0) : capScale
1102
+ let scale = min(targetScale, capScale)
1098
1103
  if scale != 1.0 {
1099
1104
  img = img.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
1100
1105
  }
@@ -18,7 +18,33 @@ import { Video as VideoCompressor } from 'react-native-compressor';
18
18
  import { encryptWithAes, getSessionKey } from "../../Shared/Libs/crypto.utils.js";
19
19
  import Video from 'react-native-video';
20
20
  import StyledButton from "../../Shared/Components/StyledButton.js";
21
+ import DiagnosticReportButton from "../../Shared/Components/DiagnosticReportButton.js";
21
22
  import NativeDeviceInfo from "../../Shared/Libs/native-device-info.utils.js";
23
+
24
+ /**
25
+ * Collect the captured document/face images (base64) as attachments for a
26
+ * diagnostic report. On an ID card the BACK image is the MRZ side; on a passport
27
+ * the front carries the MRZ. The NFC chip face arrives as `faceImage` with its
28
+ * own mime type, which may be png.
29
+ */
30
+ const collectDiagnosticImages = doc => {
31
+ if (!doc) return [];
32
+ const out = [];
33
+ const push = (name, b64, mime = 'jpg') => {
34
+ if (b64 && b64 !== '') out.push({
35
+ name,
36
+ base64: b64,
37
+ mime
38
+ });
39
+ };
40
+ push('mrz-side', doc.backImage);
41
+ push('front', doc.frontImage);
42
+ const faceMime = doc.faceImageMimeType === 'image/png' ? 'png' : 'jpg';
43
+ push('face', doc.faceImage, faceMime);
44
+ push('secondary-face', doc.secondaryFaceImage);
45
+ push('hologram', doc.hologramImage);
46
+ return out;
47
+ };
22
48
  import { trackError, trackFunnelStep, useScreenTracking } from "../../Shared/Libs/analytics.utils.js";
23
49
  import { analyticsService } from "../../Shared/Services/AnalyticsService.js";
24
50
  import { AnalyticsEventName, AnalyticsEventCategory } from "../../Shared/Types/analytics.types.js";
@@ -711,18 +737,28 @@ const ResultScreen = () => {
711
737
  }, `${consentVideo.videoPath}-${index}`))]
712
738
  })]
713
739
  })
714
- }), /*#__PURE__*/_jsx(View, {
715
- style: {
716
- paddingBottom: insets.bottom
717
- },
718
- children: /*#__PURE__*/_jsx(StyledButton, {
740
+ }), /*#__PURE__*/_jsxs(View, {
741
+ style: [styles.footer, {
742
+ paddingBottom: insets.bottom + 20
743
+ }],
744
+ children: [/*#__PURE__*/_jsx(StyledButton, {
719
745
  mode: "contained",
720
746
  onPress: () => {
721
747
  appContext.onCompleted?.();
722
748
  navigationManagerRef.current?.reset();
723
749
  },
724
750
  children: t('resultScreen.demoStartOver')
725
- })
751
+ }), /*#__PURE__*/_jsx(DiagnosticReportButton, {
752
+ sessionId: appContext.identificationInfo?.sessionId,
753
+ scan: {
754
+ documentType: appContext.identificationInfo?.scannedDocument?.documentType,
755
+ dataSource: appContext.identificationInfo?.scannedDocument?.dataSource,
756
+ mrzText: appContext.identificationInfo?.scannedDocument?.mrzText,
757
+ mrzFields: appContext.identificationInfo?.scannedDocument?.mrzFields,
758
+ barcodeValue: appContext.identificationInfo?.scannedDocument?.barcodeValue
759
+ },
760
+ images: collectDiagnosticImages(appContext.identificationInfo?.scannedDocument)
761
+ })]
726
762
  })]
727
763
  }) : /*#__PURE__*/_jsxs(_Fragment, {
728
764
  children: [/*#__PURE__*/_jsx(LottieView, {
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ import React, { useState } from 'react';
4
+ import { Alert } from 'react-native';
5
+ import { useTranslation } from 'react-i18next';
6
+ import StyledButton from "./StyledButton.js";
7
+ import { sendDiagnosticReport, isDiagnosticSharingAvailable } from "../Libs/sendDiagnosticReport.js";
8
+ import { SDK_VERSION } from "../../version.js";
9
+ import { jsx as _jsx } from "react/jsx-runtime";
10
+ /**
11
+ * "Report a scanning issue" button for the result screen.
12
+ *
13
+ * Shows a brief notice describing what will be shared (the draft itself is the
14
+ * user's review/consent step), then opens the OS share sheet — the user picks
15
+ * email / WhatsApp / Drive / etc. and sends the diagnostic bundle to support.
16
+ * Self-contained so the result screen only needs to render it with the scan
17
+ * data + captured images.
18
+ */
19
+ const DiagnosticReportButton = ({
20
+ scan,
21
+ sessionId,
22
+ images
23
+ }) => {
24
+ const {
25
+ t
26
+ } = useTranslation();
27
+ const [sending, setSending] = useState(false);
28
+
29
+ // `react-native-share` is optional — hide the button entirely when the host
30
+ // app hasn't installed it, so apps that don't want diagnostics see no UI.
31
+ if (!isDiagnosticSharingAvailable()) {
32
+ return null;
33
+ }
34
+ const onPress = () => {
35
+ if (sending) return;
36
+ // Brief notice — the share draft is the actual review/consent step.
37
+ Alert.alert(t('diagnosticReport.noticeTitle'), t('diagnosticReport.noticeBody'), [{
38
+ text: t('diagnosticReport.cancel'),
39
+ style: 'cancel'
40
+ }, {
41
+ text: t('diagnosticReport.continue'),
42
+ onPress: async () => {
43
+ setSending(true);
44
+ try {
45
+ await sendDiagnosticReport({
46
+ scan,
47
+ sdkVersion: SDK_VERSION,
48
+ sessionId,
49
+ images
50
+ });
51
+ } finally {
52
+ setSending(false);
53
+ }
54
+ }
55
+ }]);
56
+ };
57
+ return /*#__PURE__*/_jsx(StyledButton, {
58
+ mode: "outlined",
59
+ onPress: onPress,
60
+ disabled: sending,
61
+ children: t('diagnosticReport.button')
62
+ });
63
+ };
64
+ export default DiagnosticReportButton;
@@ -13,6 +13,7 @@ import { useKeepAwake } from "../Libs/native-keep-awake.utils.js";
13
13
  import { useIsFocused } from '@react-navigation/native';
14
14
  import { useTranslation } from 'react-i18next';
15
15
  import { debugLog, logError, isDebugEnabled } from "../Libs/debug.utils.js";
16
+ import { diagnostics } from "../Libs/diagnostics.js";
16
17
  import LottieView from 'lottie-react-native';
17
18
  import StyledButton from "./StyledButton.js";
18
19
  import { SafeAreaView } from 'react-native-safe-area-context';
@@ -52,8 +53,21 @@ const IdentityDocumentCamera = ({
52
53
  }, []);
53
54
  const isCameraInitialized = useRef(false);
54
55
  const [currentFaceImage, setCurrentFaceImage] = useState(undefined);
56
+ // Synchronous mirror of the locked face image (see currentHologramImageRef).
57
+ // Used as a fallback when completing SCAN_HOLOGRAM: the torch is on during
58
+ // hologram capture so the live frame's face is often rejected, leaving the
59
+ // state-derived faceImageToUse null within the stale closure — without this
60
+ // the completion gate's `&& !!faceImageToUse` would never pass.
61
+ const currentFaceImageRef = useRef(undefined);
55
62
  const [_currentHologramMaskImage, setCurrentHologramMaskImage] = useState(undefined);
56
63
  const [currentHologramImage, setCurrentHologramImage] = useState(undefined);
64
+ // Mirror of currentHologramImage for synchronous reads inside the per-frame
65
+ // callback. The setState above is async and currentHologramImage is a
66
+ // useCallback dependency, so frames processed before React re-renders the
67
+ // closure would otherwise still see the old (undefined) value — causing the
68
+ // SCAN_HOLOGRAM step to re-collect faces forever and never complete after a
69
+ // successful detection. Always keep this ref in lockstep with the state.
70
+ const currentHologramImageRef = useRef(undefined);
57
71
  const [currentSecondaryFaceImage, setCurrentSecondaryFaceImage] = useState(undefined);
58
72
  const [isBrightnessLow, setIsBrightnessLow] = useState(false);
59
73
  const [isFrameBlurry, setIsFrameBlurry] = useState(false);
@@ -110,10 +124,28 @@ const IdentityDocumentCamera = ({
110
124
  const isHologramDetectionInProgress = useRef(false); // Prevent concurrent hologram detection calls
111
125
  const isCompletionCallbackInvoked = useRef(false); // Prevent multiple callback invocations when COMPLETED
112
126
  const isStepTransitionInProgress = useRef(false); // Prevent duplicate step transitions from rapid frames
127
+ // The step a transition is currently in progress FROM. The latch above is
128
+ // reset asynchronously (via a nextStep effect), so in fast flows (e.g. demo
129
+ // mode auto-advancing) a later transition from a DIFFERENT step could be
130
+ // wrongly blocked while the latch is still set for the previous step. Gating
131
+ // on the originating step lets a genuine next-step transition through while
132
+ // still de-duping rapid repeat frames of the SAME transition.
133
+ const stepTransitionFrom = useRef(null);
113
134
  const isCompletionAnnouncementSpoken = useRef(false); // Prevent duplicate "scan completed" voice guidance
114
135
 
115
136
  const faceDetectionErrorCount = useRef(0);
116
137
  const brightnessHistory = useRef([]);
138
+ // Running camera-quality stats for the support diagnostic report.
139
+ const diagStats = useRef({
140
+ frames: 0,
141
+ brightnessSum: 0,
142
+ minBrightness: 255,
143
+ maxBrightness: 0,
144
+ frameW: 0,
145
+ frameH: 0,
146
+ hadLowBrightness: false,
147
+ hadBlurryFrames: false
148
+ });
117
149
  const [faceDetectionEnabled, setFaceDetectionEnabled] = useState(true);
118
150
  const faceImages = useRef([]);
119
151
  const hologramImageCountRef = useRef(0);
@@ -151,6 +183,7 @@ const IdentityDocumentCamera = ({
151
183
  setIsActive(true);
152
184
  isCompletionCallbackInvoked.current = false; // Reset callback flag when starting new scan
153
185
  isStepTransitionInProgress.current = false;
186
+ stepTransitionFrom.current = null;
154
187
  isCompletionAnnouncementSpoken.current = false;
155
188
  } else {
156
189
  setIsActive(false);
@@ -166,12 +199,27 @@ const IdentityDocumentCamera = ({
166
199
  validMRZConsecutiveCount.current = 0;
167
200
  mrzAggregator.current.reset();
168
201
  setMrzReliable(false);
202
+ // Start a fresh diagnostics session for the support report.
203
+ diagnostics.reset(Date.now());
204
+ diagStats.current = {
205
+ frames: 0,
206
+ brightnessSum: 0,
207
+ minBrightness: 255,
208
+ maxBrightness: 0,
209
+ frameW: 0,
210
+ frameH: 0,
211
+ hadLowBrightness: false,
212
+ hadBlurryFrames: false
213
+ };
169
214
  cachedBarcode.current = null;
170
215
  isCompletionCallbackInvoked.current = false;
171
216
  isStepTransitionInProgress.current = false;
217
+ stepTransitionFrom.current = null;
172
218
  isCompletionAnnouncementSpoken.current = false;
173
219
  // Clear all captured image states from previous scan
220
+ currentFaceImageRef.current = undefined;
174
221
  setCurrentFaceImage(undefined);
222
+ currentHologramImageRef.current = undefined;
175
223
  setCurrentHologramImage(undefined);
176
224
  setCurrentSecondaryFaceImage(undefined);
177
225
  resetLastMessage();
@@ -184,9 +232,12 @@ const IdentityDocumentCamera = ({
184
232
  setLatestHologramFaceImage(undefined);
185
233
  isCompletionCallbackInvoked.current = false; // Reset callback flag on unmount
186
234
  isStepTransitionInProgress.current = false;
235
+ stepTransitionFrom.current = null;
187
236
  isCompletionAnnouncementSpoken.current = false;
188
237
  // Clear all captured image states on unmount
238
+ currentFaceImageRef.current = undefined;
189
239
  setCurrentFaceImage(undefined);
240
+ currentHologramImageRef.current = undefined;
190
241
  setCurrentHologramImage(undefined);
191
242
  setCurrentSecondaryFaceImage(undefined);
192
243
  resetLastMessage();
@@ -232,6 +283,7 @@ const IdentityDocumentCamera = ({
232
283
  isCompletionCallbackInvoked.current = false;
233
284
  }
234
285
  isStepTransitionInProgress.current = false;
286
+ stepTransitionFrom.current = null;
235
287
  }, [nextStep]);
236
288
 
237
289
  // Update status bar based on guide visibility
@@ -365,6 +417,7 @@ const IdentityDocumentCamera = ({
365
417
  setHologramImageCount(0);
366
418
  setLatestHologramFaceImage(undefined);
367
419
  // Clear previous hologram state to prevent premature completion
420
+ currentHologramImageRef.current = undefined;
368
421
  setCurrentHologramImage(undefined);
369
422
  setCurrentHologramMaskImage(undefined);
370
423
  }
@@ -408,10 +461,14 @@ const IdentityDocumentCamera = ({
408
461
  }
409
462
  }, [setIsTorchOn]);
410
463
  const transitionStepWithCallback = useCallback((nextStepType, fromStep, scannedData) => {
411
- if (isStepTransitionInProgress.current) {
464
+ // De-dupe only rapid repeat frames of the SAME originating step. A
465
+ // transition from a different step is a genuine advance (the async latch
466
+ // reset may not have run yet in fast flows like demo mode), so allow it.
467
+ if (isStepTransitionInProgress.current && stepTransitionFrom.current === fromStep) {
412
468
  return;
413
469
  }
414
470
  isStepTransitionInProgress.current = true;
471
+ stepTransitionFrom.current = fromStep;
415
472
 
416
473
  // Torch only needed during SCAN_HOLOGRAM - turn off for all other transitions
417
474
  if (nextStepType !== 'SCAN_HOLOGRAM') {
@@ -567,9 +624,30 @@ const IdentityDocumentCamera = ({
567
624
  // of letting one noisy frame reset progress.
568
625
  const mrzBandHeight = scannedText?.blocks?.length ? Math.max(0, ...scannedText.blocks.map(b => b.blockFrame?.height ?? 0)) : 0;
569
626
  const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
627
+ // ML Kit per-symbol confidence over MRZ characters (Android bundled library
628
+ // only; undefined on iOS). When present it down-weights frames the
629
+ // recognizer was unsure about, so a confident frame outvotes a shaky one of
630
+ // the same band height on look-alike ties (A↔4, S↔5, B↔8).
631
+ const frameConfidence = scannedText?.textConfidence?.mean;
570
632
  const consensus = mrzAggregator.current.addFrame({
571
633
  text: textForValidation,
572
- weight: frameWeight
634
+ weight: frameWeight,
635
+ confidence: frameConfidence
636
+ });
637
+
638
+ // Record MRZ consensus metrics for the support diagnostic report (counts
639
+ // and pass/fail only — never the MRZ values themselves). `reachedStable`
640
+ // is sticky in the collector, so a later flicker can't unset it.
641
+ // `stabilityFrames`/`minContestedMargin` and the MRZ band pixel height are
642
+ // device-variance signals (convergence speed, look-alike resolution
643
+ // confidence, and whether the band cleared the ~16px/char OCR floor).
644
+ diagnostics.setMrz({
645
+ framesProcessed: consensus.frames,
646
+ reachedStable: consensus.stable,
647
+ format: consensus.validation?.format ?? undefined,
648
+ stabilityFrames: consensus.stableStreak,
649
+ minContestedMargin: Number.isFinite(consensus.minContestedMargin) ? consensus.minContestedMargin : 0,
650
+ maxMrzBandHeightPx: mrzBandHeight
573
651
  });
574
652
 
575
653
  // Use the fused consensus when it is valid; otherwise fall back to a
@@ -593,6 +671,9 @@ const IdentityDocumentCamera = ({
593
671
  // be wrong (ID_FRONT locked before passport MRZ became readable)
594
672
  const documentType = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? detectDocumentType(primaryFaces, text, parsedMRZData?.fields, frameWidth, mrzText) : parsedMRZData?.fields?.documentCode === 'P' ? 'PASSPORT' : detectedDocumentType;
595
673
 
674
+ // Record the detected document type for the diagnostic report.
675
+ if (documentType) diagnostics.setDocument(documentType);
676
+
596
677
  // Crop faces once document type is confirmed or we're past the initial step
597
678
  const shouldCropFaces = documentType === 'ID_FRONT' || documentType === 'PASSPORT' || nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
598
679
  const croppedFaces = shouldCropFaces ? await getFaceImages(primaryFaces, image ?? '', frameWidth, frameHeight) : [];
@@ -646,6 +727,7 @@ const IdentityDocumentCamera = ({
646
727
  // Continue scanning without locking this face
647
728
  } else {
648
729
  faceImageToUse = croppedFaces[0];
730
+ currentFaceImageRef.current = croppedFaces[0];
649
731
  setCurrentFaceImage(croppedFaces[0]);
650
732
  }
651
733
  }
@@ -860,8 +942,8 @@ const IdentityDocumentCamera = ({
860
942
  // Skip face position validation for hologram — flash toggling causes position jitter
861
943
  if (primaryFaceOnly) {
862
944
  hologramFramesWithoutFace.current = 0;
863
- if (currentHologramImage) {
864
- scannedData.hologramImage = currentHologramImage;
945
+ if (currentHologramImageRef.current) {
946
+ scannedData.hologramImage = currentHologramImageRef.current;
865
947
  } else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
866
948
  // Space out captures for better variation
867
949
  const timeSinceLastCapture = Date.now() - lastHologramCaptureTime.current;
@@ -921,6 +1003,7 @@ const IdentityDocumentCamera = ({
921
1003
  if (hologram) {
922
1004
  setCurrentHologramMaskImage(hologramMask);
923
1005
  scannedData.hologramImage = hologram;
1006
+ currentHologramImageRef.current = hologram;
924
1007
  setCurrentHologramImage(hologram);
925
1008
  if (isDebugEnabled()) {
926
1009
  debugLog('IdentityDocumentCamera', '[Hologram] ✓ Saved hologram image');
@@ -946,8 +1029,8 @@ const IdentityDocumentCamera = ({
946
1029
  } else {
947
1030
  hologramFramesWithoutFace.current++;
948
1031
  }
949
- } else if (currentHologramImage) {
950
- scannedData.hologramImage = currentHologramImage;
1032
+ } else if (currentHologramImageRef.current) {
1033
+ scannedData.hologramImage = currentHologramImageRef.current;
951
1034
  }
952
1035
 
953
1036
  // Secondary face capture (continuous during initial scan and hologram detection)
@@ -1081,10 +1164,20 @@ const IdentityDocumentCamera = ({
1081
1164
  return;
1082
1165
  }
1083
1166
 
1084
- // Complete when hologram captured OR retries exhausted (not mid-collection)
1085
- const stepComplete = (!!scannedData.hologramImage || maxRetriesReached && !isCollecting) && !!faceImageToUse; // Require face before completing
1167
+ // Complete when hologram captured OR retries exhausted (not mid-collection).
1168
+ // Fall back to the previously locked face (ref) when the live frame's face
1169
+ // was rejected — the torch is on during hologram capture, so faceImageToUse
1170
+ // is frequently null even though a valid face was locked during the front
1171
+ // scan. Without this the gate's face requirement could never be satisfied.
1172
+ const faceForCompletion = faceImageToUse ?? currentFaceImageRef.current;
1173
+ const stepComplete = (!!scannedData.hologramImage || maxRetriesReached && !isCollecting) && !!faceForCompletion; // Require face before completing
1086
1174
 
1087
1175
  if (stepComplete) {
1176
+ // Make sure the locked face rides along even if this frame's face was
1177
+ // rejected.
1178
+ if (!scannedData.faceImage && faceForCompletion) {
1179
+ scannedData.faceImage = faceForCompletion;
1180
+ }
1088
1181
  // Ensure preserved MRZ data is included (current frame may not have
1089
1182
  // readable MRZ due to flash/tilting during hologram capture)
1090
1183
  if (!scannedData.mrzText && lastValidMRZText.current) {
@@ -1196,6 +1289,15 @@ const IdentityDocumentCamera = ({
1196
1289
  brightnessHistory.current.shift();
1197
1290
  }
1198
1291
  const avgBrightness = brightnessHistory.current.reduce((a, b) => a + b, 0) / brightnessHistory.current.length;
1292
+
1293
+ // Accumulate camera diagnostics (frame count, brightness range, dims) for
1294
+ // the support diagnostic report. Cheap, side-effect-only.
1295
+ diagStats.current.frames += 1;
1296
+ diagStats.current.brightnessSum += frameBrightness;
1297
+ diagStats.current.minBrightness = Math.min(diagStats.current.minBrightness, frameBrightness);
1298
+ diagStats.current.maxBrightness = Math.max(diagStats.current.maxBrightness, frameBrightness);
1299
+ diagStats.current.frameW = frame.width ?? diagStats.current.frameW;
1300
+ diagStats.current.frameH = frame.height ?? diagStats.current.frameH;
1199
1301
  const isOverallBright = avgBrightness >= MIN_BRIGHTNESS_THRESHOLD;
1200
1302
 
1201
1303
  // Check brightness in center region (area of interest)
@@ -1206,6 +1308,7 @@ const IdentityDocumentCamera = ({
1206
1308
  isRegionBright = isOverallBright;
1207
1309
  }
1208
1310
  setIsBrightnessLow(!isRegionBright);
1311
+ if (!isRegionBright) diagStats.current.hadLowBrightness = true;
1209
1312
 
1210
1313
  // Check blur only in center region (area of interest) to avoid false positives
1211
1314
  // from iOS depth-of-field background blur
@@ -1226,10 +1329,30 @@ const IdentityDocumentCamera = ({
1226
1329
  );
1227
1330
  isNotBlurry = !isBlurry;
1228
1331
  setIsFrameBlurry(isBlurry);
1332
+ if (isBlurry) diagStats.current.hadBlurryFrames = true;
1229
1333
  } catch (error) {
1230
1334
  setIsFrameBlurry(false);
1231
1335
  }
1232
1336
 
1337
+ // Flush accumulated camera stats into the diagnostics collector.
1338
+ const ds = diagStats.current;
1339
+ diagnostics.setCamera({
1340
+ frames: ds.frames,
1341
+ frameW: ds.frameW,
1342
+ frameH: ds.frameH,
1343
+ avgBrightness: ds.frames > 0 ? Math.round(ds.brightnessSum / ds.frames) : 0,
1344
+ minBrightness: ds.minBrightness === 255 && ds.frames === 0 ? 0 : ds.minBrightness,
1345
+ maxBrightness: ds.maxBrightness,
1346
+ hadLowBrightness: ds.hadLowBrightness,
1347
+ hadBlurryFrames: ds.hadBlurryFrames,
1348
+ // The actual gates the measured values are judged against — lets support
1349
+ // read "measured 45 vs floor 45" instead of just a pass/fail flag.
1350
+ thresholds: {
1351
+ minBrightness: MIN_BRIGHTNESS_THRESHOLD,
1352
+ blur: 50
1353
+ }
1354
+ });
1355
+
1233
1356
  // Only proceed if image quality is acceptable
1234
1357
  const hasAcceptableQuality = isRegionBright && isNotBlurry;
1235
1358
 
@@ -1274,6 +1397,7 @@ const IdentityDocumentCamera = ({
1274
1397
  resultText,
1275
1398
  mrzOnlyText: undefined,
1276
1399
  // Will be set below if MRZ blocks detected
1400
+ textConfidence: frame.textConfidence,
1277
1401
  blocks: textBlocks.map(block => ({
1278
1402
  blockText: block.text || '',
1279
1403
  blockFrame: block.blockFrame ?? {
@@ -5,7 +5,6 @@ import AppContext from "../Contexts/AppContext.js";
5
5
  import { CommonActions, useNavigation, usePreventRemove } from '@react-navigation/native';
6
6
  import { View, StyleSheet, Alert } from 'react-native';
7
7
  import { useTranslation } from 'react-i18next';
8
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
8
  import i18n from "../../Translation/index.js";
10
9
  import StyledButton from "./StyledButton.js";
11
10
  import { analyticsService } from "../Services/AnalyticsService.js";
@@ -26,7 +25,6 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
26
25
  const {
27
26
  t
28
27
  } = useTranslation();
29
- const insets = useSafeAreaInsets();
30
28
  const routes = {
31
29
  VERIFICATION_SESSION_CHECK: 'VerificationSessionCheckScreen',
32
30
  DYNAMIC_ROUTES: {
@@ -185,9 +183,7 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
185
183
  allowSkipStep: appContext.currentWorkflowStep?.required
186
184
  }));
187
185
  return appContext.currentWorkflowStep && (!appContext.currentWorkflowStep?.required || canSkipStep) && /*#__PURE__*/_jsx(View, {
188
- style: [styles.container, {
189
- paddingBottom: insets.bottom
190
- }],
186
+ style: styles.container,
191
187
  children: /*#__PURE__*/_jsx(StyledButton, {
192
188
  mode: "text",
193
189
  onPress: goToNextRouteWithAlert,
@@ -196,11 +192,13 @@ const NavigationManager = /*#__PURE__*/forwardRef(({
196
192
  });
197
193
  });
198
194
  const styles = StyleSheet.create({
195
+ // Hug the text button: centered, minimal padding, no full-width opaque bar so
196
+ // it occupies the least area and never overlays sibling content. The screen's
197
+ // footer wrapper already applies the bottom safe-area inset.
199
198
  container: {
200
- justifyContent: 'center',
201
- alignItems: 'center',
202
- padding: 20,
203
- backgroundColor: 'white'
199
+ alignSelf: 'center',
200
+ paddingHorizontal: 16,
201
+ paddingVertical: 8
204
202
  }
205
203
  });
206
204
  export default NavigationManager;