@trustchex/react-native-sdk 1.374.0 → 1.409.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 (137) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +1 -21
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -1
  3. package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +636 -301
  4. package/ios/Camera/TrustchexCameraView.swift +9 -20
  5. package/ios/MLKit/MLKitModule.swift +1 -1
  6. package/ios/OpenCV/OpenCVHelper.h +0 -7
  7. package/ios/OpenCV/OpenCVHelper.mm +0 -60
  8. package/ios/OpenCV/OpenCVModule.h +0 -4
  9. package/ios/OpenCV/OpenCVModule.mm +440 -358
  10. package/lib/module/Screens/Debug/BarcodeTestScreen.js +308 -0
  11. package/lib/module/Screens/Debug/MRZTestScreen.js +105 -13
  12. package/lib/module/Screens/Dynamic/ContractAcceptanceScreen.js +49 -29
  13. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +5 -0
  14. package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +5 -0
  15. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +26 -6
  16. package/lib/module/Screens/Dynamic/VideoCallScreen.js +676 -0
  17. package/lib/module/Screens/Static/OTPVerificationScreen.js +6 -0
  18. package/lib/module/Screens/Static/QrCodeScanningScreen.js +7 -1
  19. package/lib/module/Screens/Static/ResultScreen.js +27 -13
  20. package/lib/module/Screens/Static/VerificationSessionCheckScreen.js +51 -51
  21. package/lib/module/Shared/Animations/video-call.json +1 -0
  22. package/lib/module/Shared/Components/DebugNavigationPanel.js +180 -14
  23. package/lib/module/Shared/Components/DebugOverlay.js +541 -0
  24. package/lib/module/Shared/Components/EIDScanner.js +1 -4
  25. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +44 -0
  26. package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +270 -0
  27. package/lib/module/Shared/Components/IdentityDocumentCamera.js +702 -1703
  28. package/lib/module/Shared/Components/IdentityDocumentCamera.types.js +3 -0
  29. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +273 -0
  30. package/lib/module/Shared/Components/NavigationManager.js +15 -3
  31. package/lib/module/Shared/Contexts/AppContext.js +1 -0
  32. package/lib/module/Shared/Libs/SignalingClient.js +128 -0
  33. package/lib/module/Shared/Libs/analytics.utils.js +4 -0
  34. package/lib/module/Shared/Libs/deeplink.utils.js +9 -1
  35. package/lib/module/Shared/Libs/http-client.js +9 -0
  36. package/lib/module/Shared/Libs/promise.utils.js +16 -2
  37. package/lib/module/Shared/Libs/status-bar.utils.js +21 -0
  38. package/lib/module/Shared/Services/DataUploadService.js +294 -0
  39. package/lib/module/Shared/Services/VideoSessionService.js +156 -0
  40. package/lib/module/Shared/Services/WebRTCService.js +510 -0
  41. package/lib/module/Shared/Types/analytics.types.js +2 -0
  42. package/lib/module/Translation/Resources/en.js +20 -0
  43. package/lib/module/Translation/Resources/tr.js +20 -0
  44. package/lib/module/Trustchex.js +10 -0
  45. package/lib/module/version.js +1 -1
  46. package/lib/typescript/src/Screens/Debug/BarcodeTestScreen.d.ts +3 -0
  47. package/lib/typescript/src/Screens/Debug/BarcodeTestScreen.d.ts.map +1 -0
  48. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  49. package/lib/typescript/src/Screens/Dynamic/ContractAcceptanceScreen.d.ts.map +1 -1
  50. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  51. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  52. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  53. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts +3 -0
  54. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -0
  55. package/lib/typescript/src/Screens/Static/OTPVerificationScreen.d.ts.map +1 -1
  56. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  57. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  58. package/lib/typescript/src/Screens/Static/VerificationSessionCheckScreen.d.ts.map +1 -1
  59. package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
  60. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts +30 -0
  61. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -0
  62. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts +35 -0
  64. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -0
  65. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts +3 -56
  66. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  67. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +88 -0
  68. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -0
  69. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +116 -0
  70. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -0
  71. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +93 -0
  72. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -0
  73. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  74. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts +1 -0
  75. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts.map +1 -1
  76. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts +24 -0
  77. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -0
  78. package/lib/typescript/src/Shared/Libs/analytics.utils.d.ts.map +1 -1
  79. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  80. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  81. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  82. package/lib/typescript/src/Shared/Libs/status-bar.utils.d.ts +9 -0
  83. package/lib/typescript/src/Shared/Libs/status-bar.utils.d.ts.map +1 -0
  84. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts +25 -0
  85. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -0
  86. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts +33 -0
  87. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -0
  88. package/lib/typescript/src/Shared/Services/WebRTCService.d.ts +58 -0
  89. package/lib/typescript/src/Shared/Services/WebRTCService.d.ts.map +1 -0
  90. package/lib/typescript/src/Shared/Types/analytics.types.d.ts +2 -0
  91. package/lib/typescript/src/Shared/Types/analytics.types.d.ts.map +1 -1
  92. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +4 -1
  93. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
  94. package/lib/typescript/src/Translation/Resources/en.d.ts +20 -0
  95. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  96. package/lib/typescript/src/Translation/Resources/tr.d.ts +20 -0
  97. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  98. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  99. package/lib/typescript/src/version.d.ts +1 -1
  100. package/package.json +29 -2
  101. package/src/Screens/Debug/BarcodeTestScreen.tsx +317 -0
  102. package/src/Screens/Debug/MRZTestScreen.tsx +107 -13
  103. package/src/Screens/Dynamic/ContractAcceptanceScreen.tsx +59 -33
  104. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +6 -0
  105. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +6 -0
  106. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +34 -6
  107. package/src/Screens/Dynamic/VideoCallScreen.tsx +764 -0
  108. package/src/Screens/Static/OTPVerificationScreen.tsx +6 -0
  109. package/src/Screens/Static/QrCodeScanningScreen.tsx +7 -1
  110. package/src/Screens/Static/ResultScreen.tsx +58 -23
  111. package/src/Screens/Static/VerificationSessionCheckScreen.tsx +58 -72
  112. package/src/Shared/Animations/video-call.json +1 -0
  113. package/src/Shared/Components/DebugNavigationPanel.tsx +185 -9
  114. package/src/Shared/Components/DebugOverlay.tsx +656 -0
  115. package/src/Shared/Components/EIDScanner.tsx +1 -5
  116. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +44 -0
  117. package/src/Shared/Components/IdentityDocumentCamera.flows.ts +342 -0
  118. package/src/Shared/Components/IdentityDocumentCamera.tsx +1089 -2465
  119. package/src/Shared/Components/IdentityDocumentCamera.types.ts +136 -0
  120. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +364 -0
  121. package/src/Shared/Components/NavigationManager.tsx +14 -1
  122. package/src/Shared/Contexts/AppContext.ts +2 -0
  123. package/src/Shared/Libs/SignalingClient.ts +189 -0
  124. package/src/Shared/Libs/analytics.utils.ts +4 -0
  125. package/src/Shared/Libs/deeplink.utils.ts +12 -1
  126. package/src/Shared/Libs/http-client.ts +10 -0
  127. package/src/Shared/Libs/promise.utils.ts +16 -2
  128. package/src/Shared/Libs/status-bar.utils.ts +19 -0
  129. package/src/Shared/Services/DataUploadService.ts +395 -0
  130. package/src/Shared/Services/VideoSessionService.ts +190 -0
  131. package/src/Shared/Services/WebRTCService.ts +636 -0
  132. package/src/Shared/Types/analytics.types.ts +2 -0
  133. package/src/Shared/Types/identificationInfo.ts +5 -1
  134. package/src/Translation/Resources/en.ts +25 -0
  135. package/src/Translation/Resources/tr.ts +27 -0
  136. package/src/Trustchex.tsx +12 -2
  137. package/src/version.ts +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  /* eslint-disable react-native/no-inline-styles */
4
4
  import React, { useEffect, useState, useRef, useCallback } from 'react';
5
- import { View, StyleSheet, Text as TextView, Platform, StatusBar, Vibration, Linking, Image, ActivityIndicator, PermissionsAndroid, Dimensions, ScrollView, Animated } from 'react-native';
5
+ import { View, StyleSheet, Text as TextView, Platform, StatusBar, Vibration, Linking, ActivityIndicator, PermissionsAndroid, Animated } from 'react-native';
6
6
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
7
  import { TrustchexCamera } from "./TrustchexCamera.js";
8
8
  import { NativeModules } from 'react-native';
@@ -17,16 +17,17 @@ import { SafeAreaView } from 'react-native-safe-area-context';
17
17
  import { speak, resetLastMessage } from "../Libs/tts.utils.js";
18
18
  import AppContext from "../Contexts/AppContext.js";
19
19
  import { useTheme } from "../Contexts/ThemeContext.js";
20
+ import DebugOverlay, { TestModePanel } from "./DebugOverlay.js";
21
+ import { getStatusMessage, getFrameToScreenTransform, transformBoundsToScreen, getScanAreaBounds, angleBetweenPoints, detectDocumentType, determineDocumentTypeToSet, areMRZFieldsEqual, hasRequiredMRZFields, validateFacePosition } from "./IdentityDocumentCamera.utils.js";
22
+ import { handlePassportFlow, handleIDFrontFlow, handleIDBackFlow, getNextStepAfterHologram } from "./IdentityDocumentCamera.flows.js";
23
+ import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_MRZ_READS, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, PASSPORT_MRZ_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
24
+
25
+ // Re-export types for backward compatibility
20
26
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
21
27
  const {
22
28
  OpenCVModule
23
29
  } = NativeModules;
24
30
  const AnimatedText = Animated.createAnimatedComponent(TextView);
25
- const HOLOGRAM_IMAGE_COUNT = 12;
26
- const HOLOGRAM_DETECTION_THRESHOLD = 1000; // Lowered for better sensitivity while still filtering noise
27
- const HOLOGRAM_DETECTION_RETRY_COUNT = 3; // More attempts but faster each (~1s per attempt)
28
- const MIN_BRIGHTNESS_THRESHOLD = 45;
29
- const MAX_CONSECUTIVE_QUALITY_FAILURES = 30;
30
31
  const IdentityDocumentCamera = ({
31
32
  onlyMRZScan,
32
33
  onIdentityDocumentScanned,
@@ -47,7 +48,6 @@ const IdentityDocumentCamera = ({
47
48
  isTorchOnRef.current = val;
48
49
  _setIsTorchOn(val);
49
50
  }, []);
50
- const [_exposure, _setExposure] = useState(0);
51
51
  const isCameraInitialized = useRef(false);
52
52
  const [currentFaceImage, setCurrentFaceImage] = useState(undefined);
53
53
  const [_currentHologramMaskImage, setCurrentHologramMaskImage] = useState(undefined);
@@ -58,7 +58,7 @@ const IdentityDocumentCamera = ({
58
58
  const [hasGuideShown, setHasGuideShown] = useState(false);
59
59
  const [status, setStatus] = useState('SEARCHING');
60
60
  const [nextStep, setNextStep] = useState('SCAN_ID_FRONT_OR_PASSPORT');
61
- const [completedStep, setCompletedStep] = useState(null);
61
+ const [_completedStep, setCompletedStep] = useState(null);
62
62
  const [detectedDocumentType, setDetectedDocumentType] = useState('UNKNOWN');
63
63
  const hologramDetectionCurrentRetryCount = useRef(0);
64
64
  const secondaryFaceDetectionCurrentRetryCount = useRef(0);
@@ -69,12 +69,10 @@ const IdentityDocumentCamera = ({
69
69
  const lastValidMRZText = useRef(null);
70
70
  const lastValidMRZFields = useRef(null);
71
71
  const validMRZConsecutiveCount = useRef(0);
72
- const REQUIRED_CONSISTENT_MRZ_READS = 2; // Require 2 consistent valid reads
73
72
 
74
73
  // Document type stability tracking - require consistent detections from good quality frames
75
74
  const lastDetectedDocType = useRef('UNKNOWN');
76
75
  const consistentDocTypeCount = useRef(0);
77
- const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3; // Require 3 consistent detections from quality frames
78
76
 
79
77
  // Frame quality tracking - persist across callbacks
80
78
  const lastFrameQuality = useRef({
@@ -91,68 +89,12 @@ const IdentityDocumentCamera = ({
91
89
 
92
90
  // Test mode tracking
93
91
  const [testModeData, setTestModeData] = useState(null);
94
-
95
- // Helper to compare MRZ field values (ignore raw text variations)
96
- const areMRZFieldsEqual = useCallback((fields1, fields2) => {
97
- if (!fields1 || !fields2) return false;
98
- // Compare critical fields that define document identity
99
- return fields1.documentNumber === fields2.documentNumber && fields1.birthDate === fields2.birthDate && fields1.expirationDate === fields2.expirationDate && fields1.firstName === fields2.firstName && fields1.lastName === fields2.lastName && fields1.issuingState === fields2.issuingState;
100
- }, []);
101
-
102
- // Helper functions to reduce duplication
103
-
104
- /**
105
- * Check if all required MRZ fields are present
106
- */
107
- const hasRequiredMRZFields = useCallback(fields => !!fields?.firstName && !!fields?.lastName && !!fields?.documentNumber && !!fields?.birthDate, []);
108
-
109
- /**
110
- * Log detailed MRZ information for debugging and verification
111
- */
112
- const logMRZDetails = useCallback((stepName, fields, mrzText, consecutiveReads, isDebugMode) => {
113
- if (isDebugMode) {
114
- debugLog('IdentityDocumentCamera', `[${stepName}] MRZ validated successfully (consistent reads: ${consecutiveReads})`);
115
- debugLog('IdentityDocumentCamera', `[${stepName}] Final MRZ Fields:`, {
116
- documentNumber: fields?.documentNumber,
117
- name: `${fields?.lastName} ${fields?.firstName}`,
118
- birthDate: fields?.birthDate,
119
- expirationDate: fields?.expirationDate,
120
- nationality: fields?.nationality || fields?.issuingState,
121
- sex: fields?.sex,
122
- personalId: fields?.optional1
123
- });
124
- if (mrzText) {
125
- const mrzLines = mrzText.split('\n').map(l => l.replace(/\s/g, '')).filter(l => l.length >= 20 && /^[A-Z0-9<]+$/.test(l));
126
- debugLog('IdentityDocumentCamera', `[${stepName}] MRZ lines (${mrzLines.length}):`);
127
- mrzLines.forEach((line, idx) => {
128
- debugLog('IdentityDocumentCamera', ` ${idx + 1}: ${line}`);
129
- });
130
- }
131
- }
132
- }, []);
133
-
134
- /**
135
- * Log MRZ validation failure details for debugging
136
- */
137
- const logMRZValidationFailure = useCallback((stepName, hasRequiredFields, parsedData, retryCount, isDebugMode) => {
138
- if (isDebugMode) {
139
- const debugInfo = {
140
- hasRequiredFields,
141
- isValid: parsedData?.valid,
142
- retryCount
143
- };
144
- if (parsedData?.valid) {
145
- debugInfo.consistentReads = validMRZConsecutiveCount.current;
146
- debugInfo.requiredReads = REQUIRED_CONSISTENT_MRZ_READS;
147
- debugInfo.fieldsMatch = areMRZFieldsEqual(lastValidMRZFields.current, parsedData?.fields);
148
- }
149
- debugLog('IdentityDocumentCamera', `[${stepName}] MRZ detected but validation failed - retrying`, debugInfo);
150
- }
151
- }, [areMRZFieldsEqual]);
152
92
  const lastHologramCaptureTime = useRef(0);
153
- const HOLOGRAM_CAPTURE_INTERVAL = 250; // ms between captures - allows flash to fully stabilize for accurate diff
154
93
  const hologramFramesWithoutFace = useRef(0); // Track consecutive frames without face during hologram collection
155
- const HOLOGRAM_MAX_FRAMES_WITHOUT_FACE = 30; // ~1 second at 30fps - safety timeout
94
+ const isHologramDetectionInProgress = useRef(false); // Prevent concurrent hologram detection calls
95
+ const isCompletionCallbackInvoked = useRef(false); // Prevent multiple callback invocations when COMPLETED
96
+ const isStepTransitionInProgress = useRef(false); // Prevent duplicate step transitions from rapid frames
97
+ const isCompletionAnnouncementSpoken = useRef(false); // Prevent duplicate "scan completed" voice guidance
156
98
 
157
99
  const faceDetectionErrorCount = useRef(0);
158
100
  const brightnessHistory = useRef([]);
@@ -160,7 +102,6 @@ const IdentityDocumentCamera = ({
160
102
  const faceImages = useRef([]);
161
103
  const hologramImageCountRef = useRef(0);
162
104
  const [hologramImageCount, setHologramImageCount] = useState(0);
163
- const lastVoiceGuidanceMessage = useRef('');
164
105
  const [latestHologramFaceImage, setLatestHologramFaceImage] = useState(undefined);
165
106
  const lastFacePosition = useRef(null);
166
107
  const [documentPlaneBounds, setDocumentPlaneBounds] = useState(null);
@@ -192,6 +133,9 @@ const IdentityDocumentCamera = ({
192
133
  useEffect(() => {
193
134
  if (isFocused && hasPermission && hasGuideShown) {
194
135
  setIsActive(true);
136
+ isCompletionCallbackInvoked.current = false; // Reset callback flag when starting new scan
137
+ isStepTransitionInProgress.current = false;
138
+ isCompletionAnnouncementSpoken.current = false;
195
139
  } else {
196
140
  setIsActive(false);
197
141
  faceImages.current = [];
@@ -204,11 +148,14 @@ const IdentityDocumentCamera = ({
204
148
  lastValidMRZText.current = null;
205
149
  lastValidMRZFields.current = null;
206
150
  validMRZConsecutiveCount.current = 0;
207
- lastValidMRZText.current = null;
208
- lastValidMRZFields.current = null;
209
- validMRZConsecutiveCount.current = 0;
210
- cachedBarcode.current = null; // Clear cached barcode on new scan
211
- lastVoiceGuidanceMessage.current = '';
151
+ cachedBarcode.current = null;
152
+ isCompletionCallbackInvoked.current = false;
153
+ isStepTransitionInProgress.current = false;
154
+ isCompletionAnnouncementSpoken.current = false;
155
+ // Clear all captured image states from previous scan
156
+ setCurrentFaceImage(undefined);
157
+ setCurrentHologramImage(undefined);
158
+ setCurrentSecondaryFaceImage(undefined);
212
159
  resetLastMessage();
213
160
  }
214
161
  return () => {
@@ -217,37 +164,32 @@ const IdentityDocumentCamera = ({
217
164
  hologramImageCountRef.current = 0;
218
165
  setHologramImageCount(0);
219
166
  setLatestHologramFaceImage(undefined);
220
- lastVoiceGuidanceMessage.current = '';
167
+ isCompletionCallbackInvoked.current = false; // Reset callback flag on unmount
168
+ isStepTransitionInProgress.current = false;
169
+ isCompletionAnnouncementSpoken.current = false;
170
+ // Clear all captured image states on unmount
171
+ setCurrentFaceImage(undefined);
172
+ setCurrentHologramImage(undefined);
173
+ setCurrentSecondaryFaceImage(undefined);
221
174
  resetLastMessage();
222
175
  };
223
176
  }, [isFocused, hasPermission, hasGuideShown]);
224
177
  useEffect(() => {
225
- if (hasGuideShown) {
226
- // Generate message - match UI display logic exactly for consistency
227
- let message = '';
178
+ if (!hasGuideShown || !appContext.currentWorkflowStep?.data?.voiceGuidanceActive) return;
179
+ const message = getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t);
180
+ if (message) {
228
181
  if (nextStep === 'COMPLETED') {
229
- message = t('identityDocumentCamera.scanCompleted');
230
- } else if (status === 'INCORRECT') {
231
- message = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? t('identityDocumentCamera.alignIDFront') : nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.alignIDBack') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignIDFront') : t('identityDocumentCamera.alignPhotoSide');
232
- } else if (isBrightnessLow) {
233
- message = t('identityDocumentCamera.lowBrightness');
234
- } else if (isFrameBlurry) {
235
- message = t('identityDocumentCamera.avoidBlur');
236
- } else if (status === 'SCANNING' && allElementsDetected && elementsOutsideScanArea.length === 0) {
237
- message = nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.idCardBackDetected') : detectedDocumentType === 'PASSPORT' ? t('identityDocumentCamera.passportDetected') : detectedDocumentType === 'ID_FRONT' ? t('identityDocumentCamera.idCardFrontDetected') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : t('identityDocumentCamera.readingDocument');
238
- } else if (elementsOutsideScanArea.length > 0) {
239
- message = t('identityDocumentCamera.centerDocument');
240
- } else if ((status === 'SCANNING' || status === 'SEARCHING') && !allElementsDetected) {
241
- message = nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.alignIDBack') : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? detectedDocumentType === 'PASSPORT' ? t('identityDocumentCamera.alignPassport') : detectedDocumentType === 'ID_FRONT' ? t('identityDocumentCamera.alignIDFront') : t('identityDocumentCamera.alignPhotoSide') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : t('identityDocumentCamera.readingDocument');
182
+ if (isCompletionAnnouncementSpoken.current) {
183
+ return;
184
+ }
185
+ isCompletionAnnouncementSpoken.current = true;
242
186
  } else {
243
- message = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? status === 'SCANNING' ? t('identityDocumentCamera.readingDocument') : t('identityDocumentCamera.alignPhotoSide') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : nextStep === 'SCAN_ID_BACK' ? status === 'SCANNING' ? t('identityDocumentCamera.readingDocument') : t('identityDocumentCamera.alignIDBackSide') : nextStep === 'COMPLETED' ? t('identityDocumentCamera.scanCompleted') : '';
244
- }
245
- if (appContext.currentWorkflowStep?.data?.voiceGuidanceActive && message && message !== lastVoiceGuidanceMessage.current) {
246
- lastVoiceGuidanceMessage.current = message;
247
- speak(message, true);
187
+ isCompletionAnnouncementSpoken.current = false;
248
188
  }
189
+ const shouldBypassInterval = nextStep !== 'COMPLETED';
190
+ speak(message, shouldBypassInterval);
249
191
  }
250
- }, [appContext.currentWorkflowStep?.data?.voiceGuidanceActive, hasGuideShown, isBrightnessLow, isFrameBlurry, nextStep, status, completedStep, currentFaceImage, detectedDocumentType, allElementsDetected, elementsOutsideScanArea, t]);
192
+ }, [appContext.currentWorkflowStep?.data?.voiceGuidanceActive, hasGuideShown, isBrightnessLow, isFrameBlurry, nextStep, status, detectedDocumentType, allElementsDetected, elementsOutsideScanArea, t]);
251
193
  useEffect(() => {
252
194
  if (status === 'INCORRECT') {
253
195
  const timeout = setTimeout(() => {
@@ -266,6 +208,26 @@ const IdentityDocumentCamera = ({
266
208
  }
267
209
  }, [nextStep]);
268
210
 
211
+ // Reset completion callback flag when transitioning away from COMPLETED
212
+ useEffect(() => {
213
+ if (nextStep !== 'COMPLETED') {
214
+ isCompletionCallbackInvoked.current = false;
215
+ }
216
+ isStepTransitionInProgress.current = false;
217
+ }, [nextStep]);
218
+
219
+ // Update status bar based on guide visibility
220
+ useEffect(() => {
221
+ if (hasGuideShown) {
222
+ // Scanning camera view - use light icons on dark background
223
+ StatusBar.setBarStyle('light-content', true);
224
+ } else {
225
+ // Guide screen with white background - use dark icons
226
+ StatusBar.setBarStyle('dark-content', true);
227
+ StatusBar.setBackgroundColor('#ffffff', true);
228
+ }
229
+ }, [hasGuideShown]);
230
+
269
231
  // Error flash animation - flash red text when wrong side detected
270
232
  useEffect(() => {
271
233
  if (status === 'INCORRECT') {
@@ -300,19 +262,21 @@ const IdentityDocumentCamera = ({
300
262
  return [];
301
263
  }, []);
302
264
 
303
- // Native OpenCV: compare two images for similarity
304
- const areImagesSimilarNative = async (image1, image2, threshold = 20000 // Relaxed threshold for better tolerance of lighting/angle variations
305
- ) => {
265
+ // Native OpenCV: compare face visual similarity (device-side validation before backend FaceNet)
266
+ const compareFaceVisualSimilarityNative = async (faceImage1, faceImage2) => {
306
267
  try {
307
- if (!image1 || !image2) return false;
308
- return await OpenCVModule.areImagesSimilar(image1, image2, threshold);
268
+ if (!faceImage1 || !faceImage2) return null;
269
+ return await OpenCVModule.compareFaceVisualSimilarity(faceImage1, faceImage2);
309
270
  } catch (error) {
310
- return false;
271
+ if (isDebugEnabled()) {
272
+ logError('[Face Visual Similarity] Comparison error:', error);
273
+ }
274
+ return null;
311
275
  }
312
276
  };
313
277
 
314
278
  // Native OpenCV: crop face images from full frame
315
- const getFaceImages = async (facesToDetect, image, width, height) => {
279
+ const getFaceImages = async (facesToDetect, image, width, height, widerRightPadding = false) => {
316
280
  if (!facesToDetect.length || !image || width <= 0 || height <= 0) {
317
281
  return [];
318
282
  }
@@ -323,13 +287,46 @@ const IdentityDocumentCamera = ({
323
287
  width: f.bounds.width,
324
288
  height: f.bounds.height
325
289
  }));
326
- const croppedFaces = await OpenCVModule.cropFaceImages(image, faceBounds, width, height);
290
+ const croppedFaces = await OpenCVModule.cropFaceImages(image, faceBounds, width, height, widerRightPadding);
327
291
  return croppedFaces ?? [];
328
292
  } catch (error) {
329
293
  logError('[getFaceImages] Native face crop failed:', error);
330
294
  return [];
331
295
  }
332
296
  };
297
+
298
+ // Check if face image has glare (brightness exceeds threshold)
299
+ const checkFaceGlare = async faceImage => {
300
+ try {
301
+ // Check entire face region for glare
302
+ const hasGlare = await OpenCVModule.isRectangularRegionBright(faceImage, 0, 0, 100,
303
+ // Full face width
304
+ 100,
305
+ // Full face height
306
+ MAX_BRIGHTNESS_THRESHOLD);
307
+ return hasGlare;
308
+ } catch (error) {
309
+ return false; // Assume no glare on error
310
+ }
311
+ };
312
+
313
+ // Check if face is fully visible (not cropped at edges)
314
+ const isFaceFullyVisible = (face, frameWidth, frameHeight) => {
315
+ const margin = FACE_EDGE_MARGIN_PERCENT;
316
+ const bounds = face.bounds;
317
+ return bounds.x >= frameWidth * margin && bounds.y >= frameHeight * margin && bounds.x + bounds.width <= frameWidth * (1 - margin) && bounds.y + bounds.height <= frameHeight * (1 - margin);
318
+ };
319
+
320
+ // Check if document image has glare
321
+ const checkDocumentGlare = async (image, width, height) => {
322
+ try {
323
+ // Check center 80% region for glare (document area)
324
+ const hasGlare = await OpenCVModule.isRectangularRegionBright(image, Math.round(width * 0.1), Math.round(height * 0.1), Math.round(width * 0.8), Math.round(height * 0.8), MAX_BRIGHTNESS_THRESHOLD);
325
+ return hasGlare;
326
+ } catch (error) {
327
+ return false;
328
+ }
329
+ };
333
330
  const setNextStepAndVibrate = useCallback((nextStepType, fromStep) => {
334
331
  if (fromStep) {
335
332
  setCompletedStep(fromStep);
@@ -337,15 +334,20 @@ const IdentityDocumentCamera = ({
337
334
 
338
335
  // Turn flash on and reset hologram counters when entering SCAN_HOLOGRAM
339
336
  if (nextStepType === 'SCAN_HOLOGRAM' && fromStep !== 'SCAN_HOLOGRAM') {
340
- setIsTorchOn(true);
341
- // Reset hologram detection counters for fresh start
342
- hologramDetectionCurrentRetryCount.current = 0;
343
- secondaryFaceDetectionCurrentRetryCount.current = 0;
344
- hologramFramesWithoutFace.current = 0;
345
- faceImages.current = [];
346
- hologramImageCountRef.current = 0;
347
- setHologramImageCount(0);
348
- setLatestHologramFaceImage(undefined);
337
+ const isMidCollection = faceImages.current.length > 0 && faceImages.current.length < HOLOGRAM_IMAGE_COUNT;
338
+ if (!isMidCollection) {
339
+ setIsTorchOn(true);
340
+ hologramDetectionCurrentRetryCount.current = 0;
341
+ secondaryFaceDetectionCurrentRetryCount.current = 0;
342
+ hologramFramesWithoutFace.current = 0;
343
+ faceImages.current = [];
344
+ hologramImageCountRef.current = 0;
345
+ setHologramImageCount(0);
346
+ setLatestHologramFaceImage(undefined);
347
+ // Clear previous hologram state to prevent premature completion
348
+ setCurrentHologramImage(undefined);
349
+ setCurrentHologramMaskImage(undefined);
350
+ }
349
351
  }
350
352
 
351
353
  // Clean up flash and hologram state when leaving SCAN_HOLOGRAM step
@@ -355,20 +357,23 @@ const IdentityDocumentCamera = ({
355
357
  hologramImageCountRef.current = 0;
356
358
  setHologramImageCount(0);
357
359
  setLatestHologramFaceImage(undefined);
358
- lastFacePosition.current = null; // Reset document plane reference
359
- cachedBarcode.current = null; // Clear cached barcode
360
- setDocumentPlaneBounds(null); // Clear visual overlay
361
- setSecondaryFaceBounds(null); // Clear secondary face overlay
362
- if (isDebugEnabled()) {
363
- console.log('[Flash] Turning off flash and clearing hologram images when leaving step');
364
- }
360
+ isHologramDetectionInProgress.current = false;
361
+ lastFacePosition.current = null;
362
+ cachedBarcode.current = null;
363
+ setDocumentPlaneBounds(null);
364
+ setSecondaryFaceBounds(null);
365
365
  }
366
366
  setNextStep(nextStepType);
367
367
  Vibration.vibrate(100);
368
368
 
369
369
  // Reset MRZ retry counter for each new step so retries start fresh
370
370
  mrzDetectionCurrentRetryCount.current = 0;
371
- lastValidMRZText.current = null;
371
+ // Only clear MRZ text when entering SCAN_ID_BACK (new MRZ expected).
372
+ // Preserve across SCAN_HOLOGRAM so passport completion has MRZ data.
373
+ if (nextStepType === 'SCAN_ID_BACK') {
374
+ lastValidMRZText.current = null;
375
+ lastValidMRZFields.current = null;
376
+ }
372
377
  validMRZConsecutiveCount.current = 0;
373
378
  cachedBarcode.current = null; // Clear cached barcode on step change
374
379
 
@@ -379,59 +384,90 @@ const IdentityDocumentCamera = ({
379
384
  }, 1000);
380
385
  }
381
386
  }, [setIsTorchOn]);
382
- const handleFaceAndText = useCallback(async (text, faces, frameWidth, frameHeight, barcode, image, elementsOutside, scannedText) => {
383
- const detectDocumentType = (facesParam, ocrText, mrzFields, frameWidthParam, mrzTextParam) => {
384
- // Relaxed signature detection: matches signature/imza variants and OCR errors
385
- const hasSignatureMatch = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i.test(ocrText);
386
- if (isDebugEnabled()) {
387
- console.log('[DocType] faces:', facesParam.length, 'mrzFields:', !!mrzFields, 'mrzText:', !!mrzTextParam, 'textLen:', ocrText?.length, 'hasSignature:', hasSignatureMatch);
388
- }
389
-
390
- // ID Back: no face + ID MRZ
391
- if (facesParam.length === 0 && mrzFields?.documentCode === 'I') {
392
- return 'ID_BACK';
393
- }
394
-
395
- // Passport: face + passport MRZ
396
- if (facesParam.length > 0 && mrzFields?.documentCode === 'P') {
397
- return 'PASSPORT';
398
- }
387
+ const transitionStepWithCallback = useCallback((nextStepType, fromStep, scannedData) => {
388
+ if (isStepTransitionInProgress.current) {
389
+ return;
390
+ }
391
+ isStepTransitionInProgress.current = true;
399
392
 
400
- // ID Front: face detected with signature text
401
- if (facesParam.length > 0 && ocrText?.length >= 5) {
402
- const hasSignature = hasSignatureMatch;
403
- // Only turn off torch during initial scan step, not during SCAN_HOLOGRAM
404
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
405
- setIsTorchOn(false);
406
- }
393
+ // Torch only needed during SCAN_HOLOGRAM - turn off for all other transitions
394
+ if (nextStepType !== 'SCAN_HOLOGRAM') {
395
+ setIsTorchOn(false);
396
+ }
397
+ setNextStepAndVibrate(nextStepType, fromStep);
407
398
 
408
- // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
409
- const cardSizedFaces = frameWidthParam ? facesParam.filter(face => face.bounds.width >= frameWidthParam * 0.05 && face.bounds.height >= frameWidthParam * 0.05) : facesParam;
399
+ // Prevent the COMPLETED handler from firing a duplicate callback with
400
+ // potentially wrong detectedDocumentType. This transition already sends
401
+ // the correct scannedData below.
402
+ if (nextStepType === 'COMPLETED') {
403
+ isCompletionCallbackInvoked.current = true;
404
+ }
410
405
 
411
- // CRITICAL: If passport MRZ pattern is detected but not parsed yet,
412
- // return UNKNOWN instead of ID_FRONT to avoid misclassifying passports
413
- // Passports always have MRZ visible on front starting with P<TUR or similar
414
- if (cardSizedFaces.length > 0 && !mrzFields?.documentCode && hasSignature) {
415
- if (mrzTextParam && mrzTextParam.length > 20 && /P<[A-Z]{3}/.test(mrzTextParam)) {
416
- // Passport MRZ pattern detected (P<TUR, P<USA, etc.) but not parsed
417
- // Could be passport with OCR errors - wait for proper parsing
418
- if (isDebugEnabled()) {
419
- console.log('[DocType] Passport MRZ pattern (P<XXX) detected but not parsed - returning UNKNOWN to wait for proper classification');
420
- }
421
- return 'UNKNOWN';
422
- }
423
- return 'ID_FRONT';
424
- }
425
- // Also ensure flash is off when scan is completed
426
- if (nextStep === 'COMPLETED' && isTorchOn) {
427
- setIsTorchOn(false);
428
- }
429
- }
430
- return 'UNKNOWN';
431
- };
406
+ // Only notify parent for step completions, not intermediate transitions.
407
+ // The COMPLETED handler constructs final data from accumulated state.
408
+ // For ID cards, front/back data must be sent incrementally since parent stores them separately.
409
+ const isIntermediatePassportStep = scannedData.documentType === 'PASSPORT' && nextStepType !== 'COMPLETED';
410
+ if (!isIntermediatePassportStep) {
411
+ setTimeout(() => {
412
+ onIdentityDocumentScanned(scannedData);
413
+ }, 1000);
414
+ }
415
+ }, [onIdentityDocumentScanned, setNextStepAndVibrate, setIsTorchOn]);
416
+ const handleFaceAndText = useCallback(async (text, faces, frameWidth, frameHeight, barcode, image, elementsOutside, scannedText) => {
417
+ // Classify faces: Primary (>= 5% of frame) vs Secondary (< 5%)
418
+ const primaryFaces = faces.filter(face => face.bounds.width >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT && face.bounds.height >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT);
419
+ const secondaryFaces = faces.filter(face => face.bounds.width < frameWidth * MIN_CARD_FACE_SIZE_PERCENT || face.bounds.height < frameWidth * MIN_CARD_FACE_SIZE_PERCENT);
420
+
421
+ // All faces for processing
422
+ const allDetectedFaces = faces;
423
+
424
+ // Validate primary face meets ICAO standards (face height 70-80% of image, aspect ratio ~1:1.25)
425
+ let primaryFaceICAOCompliant = false;
426
+ if (primaryFaces.length > 0) {
427
+ const face = primaryFaces[0];
428
+ const faceHeightPercent = face.bounds.height / frameHeight * 100;
429
+ const aspectRatio = face.bounds.width / face.bounds.height;
430
+
431
+ // ICAO: face height 70-80% of image, width:height ratio between 0.75 and 0.85
432
+ primaryFaceICAOCompliant = faceHeightPercent >= 70 && faceHeightPercent <= 80 && aspectRatio >= 0.75 && aspectRatio <= 0.85;
433
+ }
434
+ if (isDebugEnabled() && nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && faces.length > 0) {
435
+ debugLog('IdentityDocumentCamera', '[FACE DETECTION] All faces', {
436
+ totalFaces: faces.length,
437
+ primaryFacesCount: primaryFaces.length,
438
+ secondaryFacesCount: secondaryFaces.length,
439
+ frameWidth,
440
+ frameHeight,
441
+ minPrimarySize: frameWidth * MIN_CARD_FACE_SIZE_PERCENT,
442
+ primaryFaceICAOCompliant,
443
+ faceDetails: faces.map((f, i) => ({
444
+ index: i,
445
+ width: Math.round(f.bounds.width),
446
+ height: Math.round(f.bounds.height),
447
+ x: Math.round(f.bounds.x),
448
+ y: Math.round(f.bounds.y),
449
+ widthPercent: (f.bounds.width / frameWidth * 100).toFixed(1) + '%',
450
+ heightPercent: (f.bounds.height / frameHeight * 100).toFixed(1) + '%',
451
+ aspectRatio: (f.bounds.width / f.bounds.height).toFixed(2),
452
+ category: f.bounds.width >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT && f.bounds.height >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT ? 'primary' : 'secondary'
453
+ }))
454
+ });
455
+ }
432
456
 
433
- // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
434
- const cardSizedFaces = faces.filter(face => face.bounds.width >= frameWidth * 0.05 && face.bounds.height >= frameWidth * 0.05);
457
+ // Get scan area bounds for face filtering
458
+ const {
459
+ isInsideScan
460
+ } = getScanAreaBounds(frameWidth, frameHeight);
461
+
462
+ // Filter to only faces inside scan area (for hologram, exclude passport secondary faces)
463
+ const facesInsideScanArea = primaryFaces.filter(face => isInsideScan(face.bounds.x, face.bounds.y, face.bounds.width, face.bounds.height));
464
+ if (isDebugEnabled() && nextStep === 'SCAN_HOLOGRAM' && primaryFaces.length > facesInsideScanArea.length) {
465
+ debugLog('IdentityDocumentCamera', '[HOLOGRAM] Filtered out faces outside scan area', {
466
+ totalFaces: primaryFaces.length,
467
+ facesInside: facesInsideScanArea.length,
468
+ filtered: primaryFaces.length - facesInsideScanArea.length
469
+ });
470
+ }
435
471
 
436
472
  // Cache barcode when detected, use cached value if current frame has no barcode
437
473
  // This handles inconsistent barcode detection across frames
@@ -451,87 +487,99 @@ const IdentityDocumentCamera = ({
451
487
  setIsTorchOn(false);
452
488
  }
453
489
  if (nextStep === 'COMPLETED') {
490
+ // Prevent multiple callback invocations from repeated frames
491
+ if (isCompletionCallbackInvoked.current) {
492
+ return;
493
+ }
494
+ isCompletionCallbackInvoked.current = true;
454
495
  setStatus('SCANNED');
496
+ // Construct scanned data from available state and invoke callback
497
+ // Use MRZ document code as ultimate authority for document type —
498
+ // detectedDocumentType may be wrong if locked from early noisy frames
499
+ const completedDocType = lastValidMRZFields.current?.documentCode === 'P' ? 'PASSPORT' : detectedDocumentType;
500
+ const scannedData = {
501
+ documentType: completedDocType,
502
+ image: image ?? '',
503
+ faceImage: currentFaceImage,
504
+ secondaryFaceImage: currentSecondaryFaceImage,
505
+ hologramImage: currentHologramImage,
506
+ mrzText: lastValidMRZText.current ?? undefined,
507
+ mrzFields: lastValidMRZFields.current ?? undefined,
508
+ barcodeValue: barcodeToUse?.rawValue ?? undefined
509
+ };
510
+ if (isDebugEnabled()) {
511
+ debugLog('IdentityDocumentCamera', '[COMPLETED] Final scanned data', {
512
+ documentType: completedDocType,
513
+ hasFaceImage: !!scannedData.faceImage,
514
+ hasSecondaryFace: !!scannedData.secondaryFaceImage,
515
+ secondaryFaceImageLength: scannedData.secondaryFaceImage?.length || 0,
516
+ currentSecondaryFaceLength: currentSecondaryFaceImage?.length || 0,
517
+ hasHologramImage: !!scannedData.hologramImage,
518
+ hasMRZ: !!scannedData.mrzFields,
519
+ hasBarcode: !!scannedData.barcodeValue
520
+ });
521
+ }
522
+ setTimeout(() => {
523
+ onIdentityDocumentScanned(scannedData);
524
+ }, 500);
455
525
  return;
456
526
  }
457
- if (elementsOutside) {
458
- return;
459
- }
460
- if (nextStep === 'SCAN_ID_BACK' && cardSizedFaces.length > 0) {
461
- setStatus('INCORRECT');
527
+
528
+ // Skip elementsOutside check during SCAN_HOLOGRAM - allow document tilting for hologram capture
529
+ if (elementsOutside && nextStep !== 'SCAN_HOLOGRAM') {
462
530
  return;
463
531
  }
464
532
 
465
- // Only crop and lock face when ID_FRONT or PASSPORT is confirmed
466
- const shouldCropFaces = detectedDocumentType === 'ID_FRONT' || detectedDocumentType === 'PASSPORT' || nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
467
- const croppedFaces = shouldCropFaces ? await getFaceImages(cardSizedFaces, image ?? '', frameWidth, frameHeight) : [];
533
+ // Parse MRZ early to use for document type detection
534
+ // Use JavaScript MRZ validation with corrections
535
+ // Prefer MRZ-only text if available (from detected MRZ blocks),
536
+ // otherwise fall back to all text (for backward compatibility)
537
+ const textForValidation = scannedText?.mrzOnlyText || text;
538
+ const mrzValidationResult = mrzUtils.validateMRZWithCorrections(textForValidation);
539
+ const parsedMRZData = {
540
+ valid: mrzValidationResult.valid,
541
+ fields: mrzValidationResult.fields || null
542
+ };
543
+ // Extract raw MRZ lines from text if validation succeeded
544
+ const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
545
+
546
+ // CRITICAL: Only detect document type during initial scan step
547
+ // For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
548
+ // interruptions when user tilts document (MRZ may not be visible)
549
+ // However, if MRZ code 'P' is detected, always use PASSPORT — the lock may
550
+ // be wrong (ID_FRONT locked before passport MRZ became readable)
551
+ const documentType = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? detectDocumentType(primaryFaces, text, parsedMRZData?.fields, frameWidth, mrzText) : parsedMRZData?.fields?.documentCode === 'P' ? 'PASSPORT' : detectedDocumentType;
552
+
553
+ // Crop faces once document type is confirmed or we're past the initial step
554
+ const shouldCropFaces = documentType === 'ID_FRONT' || documentType === 'PASSPORT' || nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
555
+ const croppedFaces = shouldCropFaces ? await getFaceImages(primaryFaces, image ?? '', frameWidth, frameHeight) : [];
468
556
 
469
557
  // Validate document plane consistency across all captures
470
558
  let facePositionValid = true;
471
- if (cardSizedFaces.length > 0 && cardSizedFaces[0]) {
472
- const currentFaceBounds = cardSizedFaces[0].bounds;
559
+ if (primaryFaces.length > 0 && primaryFaces[0]) {
560
+ const currentFaceBounds = primaryFaces[0].bounds;
473
561
  if (lastFacePosition.current) {
474
- // Check if face position is within acceptable range
475
- // Use looser tolerance during hologram step since flash toggling causes position jitter
476
- const xDiff = Math.abs(currentFaceBounds.x - lastFacePosition.current.x);
477
- const yDiff = Math.abs(currentFaceBounds.y - lastFacePosition.current.y);
478
- const widthDiff = Math.abs(currentFaceBounds.width - lastFacePosition.current.width);
479
- const heightDiff = Math.abs(currentFaceBounds.height - lastFacePosition.current.height);
480
- const tolerance = nextStep === 'SCAN_HOLOGRAM' ? 0.5 : 0.2;
481
- const xTolerance = lastFacePosition.current.width * tolerance;
482
- const yTolerance = lastFacePosition.current.height * tolerance;
483
- const sizeTolerance = lastFacePosition.current.width * tolerance;
484
- facePositionValid = xDiff <= xTolerance && yDiff <= yTolerance && widthDiff <= sizeTolerance && heightDiff <= sizeTolerance;
485
- if (!facePositionValid) {
486
- if (isDebugEnabled()) {
487
- console.log(`[DocPlane] Face position changed - document moved (xDiff=${xDiff.toFixed(1)}, yDiff=${yDiff.toFixed(1)}) - rejecting frame`);
488
- }
489
- }
490
-
491
- // Update reference position to follow gradual movement (sliding window)
492
- lastFacePosition.current = {
493
- x: currentFaceBounds.x,
494
- y: currentFaceBounds.y,
495
- width: currentFaceBounds.width,
496
- height: currentFaceBounds.height
497
- };
498
- } else {
499
- // First capture - store reference position
500
- lastFacePosition.current = {
501
- x: currentFaceBounds.x,
502
- y: currentFaceBounds.y,
503
- width: currentFaceBounds.width,
504
- height: currentFaceBounds.height
505
- };
506
- console.log('[DocPlane] Stored reference face position for document plane validation');
562
+ facePositionValid = validateFacePosition(currentFaceBounds, lastFacePosition.current, nextStep === 'SCAN_HOLOGRAM');
507
563
  }
508
564
 
509
- // Update visual bounds for debug overlay
510
- // Transform face bounds from image coordinates to screen coordinates
565
+ // Update reference position (sliding window)
566
+ lastFacePosition.current = {
567
+ x: currentFaceBounds.x,
568
+ y: currentFaceBounds.y,
569
+ width: currentFaceBounds.width,
570
+ height: currentFaceBounds.height
571
+ };
572
+
573
+ // Update debug overlay bounds
511
574
  if (facePositionValid && frameDimensions) {
512
- const screen = Dimensions.get('window');
513
-
514
- // Camera uses FILL_CENTER: scale to fill screen while maintaining aspect ratio
515
- const frameAspect = frameDimensions.width / frameDimensions.height;
516
- const screenAspect = screen.width / screen.height;
517
- let scale;
518
- let offsetX = 0;
519
- let offsetY = 0;
520
- if (frameAspect > screenAspect) {
521
- // Frame is wider - scale by height, crop width
522
- scale = screen.height / frameDimensions.height;
523
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
524
- } else {
525
- // Frame is taller - scale by width, crop height
526
- scale = screen.width / frameDimensions.width;
527
- offsetY = (frameDimensions.height * scale - screen.height) / 2;
528
- }
575
+ const {
576
+ scale,
577
+ offsetX,
578
+ offsetY
579
+ } = getFrameToScreenTransform(frameDimensions.width, frameDimensions.height);
529
580
  const cropPadding = Math.max(currentFaceBounds.width * 0.15, currentFaceBounds.height * 0.15);
530
581
  setDocumentPlaneBounds({
531
- x: currentFaceBounds.x * scale - offsetX,
532
- y: currentFaceBounds.y * scale - offsetY,
533
- width: currentFaceBounds.width * scale,
534
- height: currentFaceBounds.height * scale,
582
+ ...transformBoundsToScreen(currentFaceBounds, scale, offsetX, offsetY),
535
583
  cropPadding: cropPadding * scale
536
584
  });
537
585
  }
@@ -540,31 +588,35 @@ const IdentityDocumentCamera = ({
540
588
  // Capture and persist face only after document type is confirmed
541
589
  // This prevents locking a face before we know what document we're scanning
542
590
  let faceImageToUse = currentFaceImage;
543
- if (shouldCropFaces && croppedFaces.length > 0 && croppedFaces[0] && facePositionValid) {
544
- if (!currentFaceImage) {
545
- // First face detection after doc type confirmed - lock it for all subsequent steps
546
- faceImageToUse = croppedFaces[0];
547
- setCurrentFaceImage(croppedFaces[0]);
591
+ if (shouldCropFaces && croppedFaces.length > 0 && croppedFaces[0] && facePositionValid && !currentFaceImage) {
592
+ // Validate face quality before accepting
593
+ const faceFullyVisible = primaryFaces[0] && isFaceFullyVisible(primaryFaces[0], frameWidth, frameHeight);
594
+ const hasGlare = await checkFaceGlare(croppedFaces[0]);
595
+ if (!faceFullyVisible || hasGlare) {
596
+ // Reject face with glare or partially visible
548
597
  if (isDebugEnabled()) {
549
- console.log('[DocPlane] Locked primary face from validated document plane (docType: ' + detectedDocumentType + ')');
598
+ debugLog('IdentityDocumentCamera', '[FACE] Rejected', {
599
+ fullyVisible: faceFullyVisible,
600
+ hasGlare
601
+ });
550
602
  }
603
+ // Continue scanning without locking this face
604
+ } else {
605
+ faceImageToUse = croppedFaces[0];
606
+ setCurrentFaceImage(croppedFaces[0]);
551
607
  }
552
608
  }
609
+
610
+ // Skip OCR text checks during SCAN_HOLOGRAM - flash and tilting make text unreadable
611
+ // but we only need face detection for hologram collection
553
612
  if (!text || text.length < 5 || !image) {
554
- setStatus('SEARCHING');
555
- return;
613
+ if (nextStep !== 'SCAN_HOLOGRAM') {
614
+ setStatus('SEARCHING');
615
+ return;
616
+ }
617
+ // During SCAN_HOLOGRAM, allow processing even if text is not readable
556
618
  }
557
619
 
558
- // Use JavaScript MRZ validation with corrections
559
- // Prefer MRZ-only text if available (from detected MRZ blocks),
560
- // otherwise fall back to all text (for backward compatibility)
561
- const textForValidation = scannedText?.mrzOnlyText || text;
562
- const mrzValidationResult = mrzUtils.validateMRZWithCorrections(textForValidation);
563
- const parsedMRZData = {
564
- valid: mrzValidationResult.valid,
565
- fields: mrzValidationResult.fields || null
566
- };
567
-
568
620
  // Capture test mode data
569
621
  if (testMode && text && text.includes('<')) {
570
622
  const mrzOnlyText = scannedText?.mrzOnlyText || text;
@@ -574,239 +626,124 @@ const IdentityDocumentCamera = ({
574
626
  });
575
627
  }
576
628
 
577
- // Log MRZ validation details for debugging
578
- if (isDebugEnabled() && text && text.includes('<')) {
579
- const mrzLines = text.split('\n').filter(line => line.includes('<') && line.length > 20);
580
- if (mrzLines.length >= 2) {
581
- console.log('[MRZ Debug] Raw OCR text lines:', mrzLines.map(l => `"${l}"`));
582
- console.log('[MRZ Debug] Validation result:', {
583
- valid: mrzValidationResult.valid,
584
- format: mrzValidationResult.format,
585
- documentCode: mrzValidationResult.fields?.documentCode,
586
- documentNumber: mrzValidationResult.fields?.documentNumber,
587
- optional1: mrzValidationResult.fields?.optional1,
588
- error: mrzValidationResult.error
589
- });
590
- }
591
- }
592
-
593
- // Extract raw MRZ lines from text if validation succeeded
594
- const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
595
-
596
- // MRZ stability check - require consistent valid reads to avoid OCR noise
597
- // Compare parsed field values instead of raw text to handle OCR variations in filler characters
598
- // Only proceed with MRZ if it's actually valid and has all required fields
629
+ // MRZ stability: require consistent valid reads across frames
630
+ // Skip during SCAN_HOLOGRAM - document type already locked
599
631
  const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
600
- if (mrzText && parsedMRZData?.valid === true && parsedMRZData?.fields && mrzHasRequiredFields) {
632
+ if (nextStep !== 'SCAN_HOLOGRAM' && mrzText && parsedMRZData?.valid === true && parsedMRZData?.fields && mrzHasRequiredFields) {
601
633
  const currentFields = parsedMRZData.fields;
602
634
  if (areMRZFieldsEqual(lastValidMRZFields.current, currentFields)) {
603
- // Same MRZ data detected again - increment counter
604
635
  validMRZConsecutiveCount.current++;
605
636
  } else {
606
- // Different MRZ data - reset counter and store new data
607
- if (isDebugEnabled()) {
608
- console.log(`[MRZ Stability] New MRZ detected, resetting counter (was ${validMRZConsecutiveCount.current}, doc: ${currentFields.documentNumber?.slice(-4)})`);
609
- }
610
637
  lastValidMRZFields.current = currentFields;
611
638
  lastValidMRZText.current = mrzText;
612
639
  validMRZConsecutiveCount.current = 1;
613
640
  }
614
- } else {
615
- // Invalid or no MRZ - don't reset completely, just skip this frame
616
- // This allows temporary OCR noise without losing progress
617
641
  }
642
+ // else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
618
643
 
619
644
  // Check if we have enough consistent valid reads
620
645
  const mrzStableAndValid = validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS && parsedMRZData?.valid === true && areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
621
646
 
622
- // During SCAN_ID_BACK, handle MRZ/barcode directly without full document type detection
623
- // This avoids the chicken-and-egg problem where detectDocumentType requires
624
- // mrzFields.documentCode === 'I' but MRZ parsing may return different codes
647
+ // ============================================================================
648
+ // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
649
+ // ============================================================================
625
650
  if (nextStep === 'SCAN_ID_BACK') {
626
- // CRITICAL: Always check if wrong side is shown (front or passport when back is expected)
627
- // ID_BACK should have NO faces and NO signature text
628
- // Multiple indicators for robust detection:
629
- const hasFaces = cardSizedFaces.length > 0;
630
- const hasSignature = /signature|imza|İmza/i.test(text);
631
- const hasPassportMRZ = parsedMRZData?.fields?.documentCode === 'P';
632
- const hasPassportMRZPattern = mrzText && /P<[A-Z]{3}/.test(mrzText);
633
- if (hasFaces || hasSignature || hasPassportMRZ || hasPassportMRZPattern) {
634
- if (isDebugEnabled()) {
635
- console.log(`[ID_BACK Scan] Wrong side detected - faces: ${hasFaces}, signature: ${hasSignature}, passport MRZ: ${hasPassportMRZ}, passport pattern: ${hasPassportMRZPattern}`);
651
+ const handleIdBackStep = async () => {
652
+ // Guard: wrong side detection (front or passport when back is expected)
653
+ const hasFaces = primaryFaces.length > 0;
654
+ const hasSignature = /signature|imza|İmza/i.test(text);
655
+ const hasPassportMRZ = parsedMRZData?.fields?.documentCode === 'P';
656
+ const hasPassportMRZPattern = mrzText && PASSPORT_MRZ_PATTERN.test(mrzText);
657
+ if (hasFaces || hasSignature || hasPassportMRZ || hasPassportMRZPattern) {
658
+ setStatus('INCORRECT');
659
+ return;
636
660
  }
637
- setStatus('INCORRECT');
638
- return;
639
- }
640
661
 
641
- // SAFETY CHECK: If we detect passport during ID_BACK scan via state, skip this step
642
- // This shouldn't happen but protects against edge cases
643
- if (detectedDocumentType === 'PASSPORT') {
644
- if (isDebugEnabled()) {
645
- console.log('[ID_BACK Scan] ERROR: Passport detected in ID_BACK step - skipping to COMPLETED');
646
- }
647
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
648
- setTimeout(() => {
649
- onIdentityDocumentScanned({
662
+ // Safety: passport somehow reached ID_BACK step
663
+ if (detectedDocumentType === 'PASSPORT') {
664
+ transitionStepWithCallback('COMPLETED', 'SCAN_ID_BACK', {
650
665
  image,
651
666
  documentType: 'PASSPORT',
652
667
  mrzText: mrzText ?? undefined,
653
668
  mrzFields: parsedMRZData?.fields
654
669
  });
655
- }, 1000);
656
- return;
657
- }
658
- const hasMRZ = !!mrzText;
659
- const hasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
660
- // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
661
- // AND ensure all required fields are present
662
- const mrzAccepted = parsedMRZData?.valid === true && hasRequiredFields && mrzStableAndValid;
663
-
664
- // For Turkish ID cards, barcode should match MRZ optional1 (serial number)
665
- // But some cards have encoding differences, so be lenient
666
- const barcodeMatchesMRZ = barcodeToUse?.rawValue?.trim() === parsedMRZData?.fields?.optional1?.trim();
667
-
668
- // If barcode doesn't match exactly, check if it contains the optional1 value
669
- const barcodeContainsMRZ = barcodeToUse?.rawValue?.includes(parsedMRZData?.fields?.optional1?.trim() || '') || parsedMRZData?.fields?.optional1?.includes(barcodeToUse?.rawValue?.trim() || '');
670
-
671
- // Require barcode for all documents (no special card fallback)
672
- // Accept if exact match OR if one contains the other (handles encoding differences)
673
- const barcodeAccepted = onlyMRZScan || barcodeMatchesMRZ || !!barcodeToUse?.rawValue && barcodeContainsMRZ;
674
-
675
- // CRITICAL: Require all document elements to be in frame before accepting
676
- // For ID_BACK: MRZ + barcode (check directly, not via state to avoid timing issues)
677
- const hasBarcode = !!barcodeToUse?.rawValue;
678
- const allRequiredElementsInFrame = hasMRZ && hasBarcode || onlyMRZScan;
679
-
680
- // Don't block based on bounds - just ensure elements are present
681
- setElementsOutsideScanArea([]);
682
- if (!allRequiredElementsInFrame && hasMRZ && mrzAccepted) {
670
+ return;
671
+ }
672
+ setElementsOutsideScanArea([]);
673
+ const flowResult = handleIDBackFlow(mrzText, parsedMRZData?.fields, parsedMRZData?.valid === true, mrzStableAndValid, hasRequiredMRZFields(parsedMRZData?.fields), barcodeToUse?.rawValue, onlyMRZScan);
674
+ if (!flowResult.shouldProceed) {
675
+ mrzDetectionCurrentRetryCount.current++;
676
+ setStatus(mrzText ? 'SCANNING' : 'SEARCHING');
677
+ return;
678
+ }
679
+
680
+ // Check for glare on ID back before accepting
681
+ const hasGlare = await checkDocumentGlare(image, frameWidth, frameHeight);
682
+ if (hasGlare) {
683
+ if (isDebugEnabled()) {
684
+ debugLog('IdentityDocumentCamera', '[ID_BACK] Rejected - glare detected');
685
+ }
686
+ setStatus('SCANNING');
687
+ return;
688
+ }
683
689
  if (isDebugEnabled()) {
684
- console.log('[ID_BACK Scan] MRZ valid but waiting for all elements in frame (MRZ + barcode)');
690
+ debugLog('IdentityDocumentCamera', '[ID_BACK] MRZ validated', {
691
+ documentNumber: parsedMRZData?.fields?.documentNumber,
692
+ reads: validMRZConsecutiveCount.current
693
+ });
685
694
  }
686
- setStatus('SCANNING');
687
- return;
688
- }
689
- if (hasMRZ && mrzAccepted && barcodeAccepted && allRequiredElementsInFrame) {
690
- logMRZDetails('ID_BACK Scan', parsedMRZData?.fields, mrzText, validMRZConsecutiveCount.current, isDebugEnabled());
691
- const scannedData = {
695
+ setDetectedDocumentType('ID_BACK');
696
+ setStatus('SCANNED');
697
+ transitionStepWithCallback('COMPLETED', 'SCAN_ID_BACK', {
692
698
  image,
693
699
  documentType: 'ID_BACK',
694
700
  mrzText: mrzText ?? undefined,
695
701
  mrzFields: parsedMRZData?.fields,
696
702
  barcodeValue: barcodeToUse?.rawValue ?? undefined
697
- };
698
- setDetectedDocumentType('ID_BACK');
699
- setStatus('SCANNED');
700
- setIsTorchOn(false);
701
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
702
- setTimeout(() => {
703
- onIdentityDocumentScanned(scannedData);
704
- }, 1000);
705
- } else {
706
- if (hasMRZ && !mrzAccepted) {
707
- logMRZValidationFailure('ID_BACK Scan', hasRequiredFields, parsedMRZData, mrzDetectionCurrentRetryCount.current, isDebugEnabled());
708
- } else if (hasMRZ && mrzAccepted && !barcodeAccepted) {
709
- if (isDebugEnabled()) {
710
- console.log('[ID_BACK Scan] MRZ valid but barcode check failed - retrying', {
711
- onlyMRZScan,
712
- hasBarcodeValue: !!barcodeToUse?.rawValue,
713
- barcodeMatchesMRZ,
714
- barcodeContainsMRZ,
715
- mrzOptional1: parsedMRZData?.fields?.optional1,
716
- barcodeValue: barcodeToUse?.rawValue,
717
- barcodeValueTrimmed: barcodeToUse?.rawValue?.trim(),
718
- optional1Trimmed: parsedMRZData?.fields?.optional1?.trim(),
719
- barcodeSource: barcodeToUse === cachedBarcode.current ? 'cached' : 'current'
720
- });
721
- }
722
- }
723
- mrzDetectionCurrentRetryCount.current++;
724
- setStatus(hasMRZ ? 'SCANNING' : 'SEARCHING');
725
- }
703
+ });
704
+ };
705
+ handleIdBackStep();
726
706
  return;
727
707
  }
728
- const documentType = detectDocumentType(cardSizedFaces, text, parsedMRZData?.fields, frameWidth, mrzText);
729
-
730
- // Update detected document type only during initial scan step
731
- // CRITICAL: Only set document type from non-blurry, stable frames
732
- // Once set to PASSPORT or definitively identified, preserve it to avoid incorrect flow transitions
733
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && detectedDocumentType === 'UNKNOWN') {
734
- // Determine the document type to set based on current frame analysis
735
- let docTypeToSet = documentType;
736
- if (documentType === 'PASSPORT') {
737
- // Passport detected definitively - candidate for locking in
738
- docTypeToSet = 'PASSPORT';
739
- } else if (documentType === 'UNKNOWN' && cardSizedFaces.length > 0 && parsedMRZData?.fields?.documentCode === 'P') {
740
- // Early passport detection: face detected + passport MRZ code (even if not fully valid yet)
741
- docTypeToSet = 'PASSPORT';
742
- } else if (documentType === 'ID_FRONT') {
743
- // Check if this is actually a passport based on MRZ code
744
- // Passports can be misdetected as ID_FRONT when signature-like text is visible
745
- if (parsedMRZData?.fields?.documentCode === 'P') {
746
- if (isDebugEnabled()) {
747
- console.log('[DocType] Correcting misdetection: ID_FRONT → PASSPORT (MRZ code P)');
748
- }
749
- docTypeToSet = 'PASSPORT';
750
- } else if (parsedMRZData?.fields?.documentCode === 'I') {
751
- // MRZ confirms it's an ID card
752
- docTypeToSet = 'ID_FRONT';
753
- } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
754
- // Passport MRZ pattern visible but not parsed yet - wait for proper classification
755
- if (isDebugEnabled()) {
756
- console.log('[DocType] Passport MRZ pattern detected but not parsed - waiting instead of locking as ID_FRONT');
757
- }
758
- docTypeToSet = 'UNKNOWN';
759
- } else {
760
- // No MRZ code and no passport pattern - safe to classify as ID_FRONT
761
- // ID cards typically don't have MRZ on front (only on back)
762
- docTypeToSet = 'ID_FRONT';
763
- }
764
- } else {
765
- docTypeToSet = 'UNKNOWN';
766
- }
767
708
 
768
- // Only update document type state if:
769
- // 1. Frame quality is acceptable (not blurry, good brightness)
770
- // 2. Document type has been detected consistently for multiple frames
771
- if (lastFrameQuality.current.hasAcceptableQuality && docTypeToSet !== 'UNKNOWN') {
772
- if (docTypeToSet === lastDetectedDocType.current) {
773
- consistentDocTypeCount.current++;
774
- if (isDebugEnabled()) {
775
- console.log(`[DocType Stability] Consistent detection: ${docTypeToSet} (${consistentDocTypeCount.current}/${REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS})`);
709
+ // Turn off torch when ID_FRONT detected during initial scan
710
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && (documentType === 'ID_FRONT' || documentType === 'PASSPORT') && isTorchOnRef.current) {
711
+ setIsTorchOn(false);
712
+ }
713
+
714
+ // Lock document type from stable quality frames during initial scan.
715
+ // Also allow correcting ID_FRONT → PASSPORT when passport MRZ appears later.
716
+ // Passport MRZ may not be visible in early frames (while signature is),
717
+ // causing premature ID_FRONT lock that must be correctable.
718
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && (detectedDocumentType === 'UNKNOWN' || detectedDocumentType === 'ID_FRONT')) {
719
+ const docTypeToSet = determineDocumentTypeToSet(documentType, primaryFaces, parsedMRZData?.fields, mrzText);
720
+
721
+ // If already locked as ID_FRONT, only allow correction to PASSPORT
722
+ if (detectedDocumentType === 'ID_FRONT' && docTypeToSet === 'PASSPORT') {
723
+ setDetectedDocumentType('PASSPORT');
724
+ consistentDocTypeCount.current = 0;
725
+ lastDetectedDocType.current = 'PASSPORT';
726
+ } else if (detectedDocumentType === 'UNKNOWN') {
727
+ if (lastFrameQuality.current.hasAcceptableQuality && docTypeToSet !== 'UNKNOWN') {
728
+ if (docTypeToSet === lastDetectedDocType.current) {
729
+ consistentDocTypeCount.current++;
730
+ } else {
731
+ lastDetectedDocType.current = docTypeToSet;
732
+ consistentDocTypeCount.current = 1;
776
733
  }
777
734
  if (consistentDocTypeCount.current >= REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS) {
778
- // Stable detection confirmed - lock it in
779
- if (isDebugEnabled()) {
780
- console.log(`[DocType] Locked document type: ${docTypeToSet} (stable for ${consistentDocTypeCount.current} quality frames)`);
781
- }
782
735
  setDetectedDocumentType(docTypeToSet);
783
736
  }
784
- } else {
785
- // Document type changed - reset counter
786
- if (isDebugEnabled()) {
787
- console.log(`[DocType Stability] Type changed: ${lastDetectedDocType.current} → ${docTypeToSet}, resetting counter`);
788
- }
789
- lastDetectedDocType.current = docTypeToSet;
790
- consistentDocTypeCount.current = 1;
791
- }
792
- } else if (!lastFrameQuality.current.hasAcceptableQuality && docTypeToSet !== 'UNKNOWN') {
793
- // Poor quality frame - don't use for document type detection
794
- if (isDebugEnabled()) {
795
- console.log(`[DocType Stability] Skipping poor quality frame (blurry: ${lastFrameQuality.current.isBlurry}, brightness: ${lastFrameQuality.current.brightness.toFixed(0)})`);
796
737
  }
797
738
  }
798
739
  }
799
- // Document type is now locked and won't be changed after initial scan
800
- // Hologram and subsequent steps use the preserved detectedDocumentType state
801
-
802
740
  const scannedData = {
803
741
  image,
804
742
  documentType,
805
743
  mrzText: mrzText ?? undefined,
806
744
  mrzFields: parsedMRZData?.fields
807
745
  };
808
- const isWrongSide = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK';
809
- if (isWrongSide) {
746
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK') {
810
747
  setStatus('INCORRECT');
811
748
  return;
812
749
  }
@@ -815,172 +752,241 @@ const IdentityDocumentCamera = ({
815
752
  if (faceImageToUse) {
816
753
  scannedData.faceImage = faceImageToUse;
817
754
  }
755
+ const continueScanning = (incrementMrzRetry = false) => {
756
+ if (incrementMrzRetry) {
757
+ mrzDetectionCurrentRetryCount.current++;
758
+ }
759
+ setStatus('SCANNING');
760
+ };
818
761
  if (!onlyMRZScan) {
819
- // Hologram detection during SCAN_HOLOGRAM step - ALWAYS use first/leftmost face ONLY
762
+ // Hologram detection during SCAN_HOLOGRAM step
820
763
  if (nextStep === 'SCAN_HOLOGRAM') {
821
- if (isDebugEnabled()) {
822
- console.log(`[Hologram] In SCAN_HOLOGRAM step - currentHologramImage: ${!!currentHologramImage}, collected: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT}`);
823
- }
824
-
825
- // Always crop to the same face region across all hologram frames so
826
- // OpenCV receives consistently-sized images for comparison.
827
- // Use current face bounds if available, otherwise fall back to last known position.
828
- const hologramFaceBounds = cardSizedFaces.length > 0 && cardSizedFaces[0] ? cardSizedFaces[0].bounds : lastFacePosition.current;
764
+ // CRITICAL: Only use faces inside scan area for hologram
765
+ // This prevents passport secondary faces (outside frame or on right side) from being used
766
+ const hologramFaceBounds = facesInsideScanArea.length > 0 && facesInsideScanArea[0] ? facesInsideScanArea[0].bounds : lastFacePosition.current;
829
767
  let primaryFaceOnly;
830
768
  if (hologramFaceBounds && image) {
831
- const hologramCropped = await getFaceImages([{
832
- bounds: hologramFaceBounds,
833
- rollAngle: 0,
834
- yawAngle: 0
835
- }], image, frameWidth, frameHeight);
836
- primaryFaceOnly = hologramCropped[0] ?? faceImageToUse;
769
+ // Verify face is fully visible before using for hologram
770
+ const faceFullyVisible = hologramFaceBounds ? hologramFaceBounds.x >= frameWidth * FACE_EDGE_MARGIN_PERCENT && hologramFaceBounds.y >= frameHeight * FACE_EDGE_MARGIN_PERCENT && hologramFaceBounds.x + hologramFaceBounds.width <= frameWidth * (1 - FACE_EDGE_MARGIN_PERCENT) && hologramFaceBounds.y + hologramFaceBounds.height <= frameHeight * (1 - FACE_EDGE_MARGIN_PERCENT) : false;
771
+ if (faceFullyVisible) {
772
+ const hologramCropped = await getFaceImages([{
773
+ bounds: hologramFaceBounds,
774
+ rollAngle: 0,
775
+ yawAngle: 0
776
+ }], image, frameWidth, frameHeight, true // widerRightPadding for hologram detection
777
+ );
778
+ primaryFaceOnly = hologramCropped[0] ?? faceImageToUse;
779
+ } else {
780
+ // Face not fully visible - skip this frame
781
+ if (isDebugEnabled()) {
782
+ debugLog('IdentityDocumentCamera', '[HOLOGRAM] Face not fully visible', {
783
+ faceX: hologramFaceBounds.x,
784
+ faceY: hologramFaceBounds.y,
785
+ faceWidth: hologramFaceBounds.width,
786
+ faceHeight: hologramFaceBounds.height,
787
+ frameWidth,
788
+ frameHeight
789
+ });
790
+ }
791
+ primaryFaceOnly = undefined;
792
+ }
837
793
  } else {
838
794
  primaryFaceOnly = faceImageToUse;
839
795
  }
840
796
 
841
797
  // Skip face position validation for hologram — flash toggling causes position jitter
842
798
  if (primaryFaceOnly) {
843
- // Reset consecutive no-face counter since we have a face
844
799
  hologramFramesWithoutFace.current = 0;
845
800
  if (currentHologramImage) {
846
801
  scannedData.hologramImage = currentHologramImage;
847
802
  } else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
848
- // Add timing control to space out captures for better variation
849
- const now = Date.now();
850
- const timeSinceLastCapture = now - lastHologramCaptureTime.current;
803
+ // Space out captures for better variation
804
+ const timeSinceLastCapture = Date.now() - lastHologramCaptureTime.current;
851
805
  if (faceImages.current.length === 0 || timeSinceLastCapture >= HOLOGRAM_CAPTURE_INTERVAL) {
852
- // Collect PRIMARY face image ONLY (always index 0) from same document plane
853
- faceImages.current.push(primaryFaceOnly);
854
- lastHologramCaptureTime.current = now;
855
- hologramImageCountRef.current = faceImages.current.length;
856
-
857
- // Only update state at first and last frame to minimize re-renders
858
- if (faceImages.current.length === 1 || faceImages.current.length === HOLOGRAM_IMAGE_COUNT) {
859
- setHologramImageCount(faceImages.current.length);
860
- setLatestHologramFaceImage(primaryFaceOnly);
861
- }
862
- if (isDebugEnabled()) {
863
- console.log(`[Hologram] Collected ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} face images`);
806
+ // Keep torch on during hologram scan for consistent lighting
807
+ if (!isTorchOnRef.current) {
808
+ setIsTorchOn(true);
864
809
  }
865
810
 
866
- // Keep flash on during processing - will turn off when step changes
811
+ // Check hologram face for glare
812
+ const hasGlare = await checkFaceGlare(primaryFaceOnly);
813
+ if (!hasGlare) {
814
+ faceImages.current.push(primaryFaceOnly);
815
+ lastHologramCaptureTime.current = Date.now();
816
+ hologramImageCountRef.current = faceImages.current.length;
817
+ if (isDebugEnabled()) {
818
+ debugLog('IdentityDocumentCamera', '[HOLOGRAM] Frame captured', {
819
+ frameIndex: faceImages.current.length - 1,
820
+ totalFrames: faceImages.current.length
821
+ });
822
+ }
823
+ } else if (isDebugEnabled()) {
824
+ debugLog('IdentityDocumentCamera', '[HOLOGRAM] Rejected glare frame', {
825
+ collectedCount: faceImages.current.length
826
+ });
827
+ }
828
+ if (faceImages.current.length > 0) {
829
+ // Update UI state at first and last frame only
830
+ if (faceImages.current.length === 1 || faceImages.current.length === HOLOGRAM_IMAGE_COUNT) {
831
+ setHologramImageCount(faceImages.current.length);
832
+ setLatestHologramFaceImage(primaryFaceOnly);
833
+ }
834
+ }
867
835
  }
868
836
  } else if (faceImages.current.length >= HOLOGRAM_IMAGE_COUNT) {
869
- // Process collected full document images
870
- if (isDebugEnabled()) {
871
- console.log(`[Hologram] Processing ${faceImages.current.length} full document images`);
837
+ // Guard: skip if already processing or max retries exhausted
838
+ if (isHologramDetectionInProgress.current) return;
839
+ if (hologramDetectionCurrentRetryCount.current >= HOLOGRAM_DETECTION_RETRY_COUNT) {
840
+ faceImages.current = [];
841
+ hologramImageCountRef.current = 0;
842
+ setHologramImageCount(0);
843
+ setLatestHologramFaceImage(undefined);
844
+ return;
872
845
  }
846
+
847
+ // Process collected face images for hologram detection
848
+ isHologramDetectionInProgress.current = true;
873
849
  try {
874
850
  const [hologramMask, hologram] = await detectHologramNative(faceImages.current);
851
+ if (isDebugEnabled()) {
852
+ debugLog('IdentityDocumentCamera', '[Hologram] Native result', {
853
+ hasHologram: !!hologram,
854
+ hasHologramMask: !!hologramMask,
855
+ hologramLength: hologram?.length || 0
856
+ });
857
+ }
875
858
  if (hologram) {
876
859
  setCurrentHologramMaskImage(hologramMask);
877
860
  scannedData.hologramImage = hologram;
878
861
  setCurrentHologramImage(hologram);
879
862
  if (isDebugEnabled()) {
880
- console.log('[Hologram] Detection successful');
863
+ debugLog('IdentityDocumentCamera', '[Hologram] Saved hologram image');
881
864
  }
882
865
  } else {
883
866
  if (isDebugEnabled()) {
884
- console.log('[Hologram] No hologram detected');
867
+ debugLog('IdentityDocumentCamera', '[Hologram] No hologram detected');
885
868
  }
886
869
  }
887
870
  } catch (error) {
888
871
  console.error('[Hologram] Processing error:', error);
889
872
  } finally {
890
- // Keep flash on - will turn off when step changes
891
873
  faceImages.current = [];
892
874
  hologramImageCountRef.current = 0;
893
875
  setHologramImageCount(0);
894
876
  setLatestHologramFaceImage(undefined);
895
877
  hologramDetectionCurrentRetryCount.current++;
896
- if (isDebugEnabled()) {
897
- console.log(`[Hologram] Retry ${hologramDetectionCurrentRetryCount.current}/${HOLOGRAM_DETECTION_RETRY_COUNT}`);
898
- }
878
+ isHologramDetectionInProgress.current = false;
879
+ // Turn off torch after detection completes
880
+ setIsTorchOn(false);
899
881
  }
900
882
  }
901
883
  } else {
902
- // No face detected for hologram collection
903
- // Track consecutive frames without face for safety timeout
904
884
  hologramFramesWithoutFace.current++;
905
- if (isDebugEnabled()) {
906
- console.log(`[Hologram] No face detected - frame ${hologramFramesWithoutFace.current}/${HOLOGRAM_MAX_FRAMES_WITHOUT_FACE}`);
907
- }
908
885
  }
909
886
  } else if (currentHologramImage) {
910
887
  scannedData.hologramImage = currentHologramImage;
911
- } else if (faceImages.current.length > 0) {
912
- // Safety cleanup: not in hologram step but have images collected
913
- faceImages.current = [];
914
- hologramImageCountRef.current = 0;
915
- setHologramImageCount(0);
916
- setLatestHologramFaceImage(undefined);
917
- if (isDebugEnabled()) {
918
- console.log('[Hologram] Defensive cleanup - cleared images outside hologram step');
919
- }
920
888
  }
921
889
 
922
- // SKIP secondary face detection during SCAN_HOLOGRAM - only focus on primary face
923
- // Secondary face was already captured during initial scan (SCAN_ID_FRONT_OR_PASSPORT)
924
- // During hologram, we only collect hologram images from primary face
925
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
926
- // Capture secondary face - must be similar to main face AND from same document plane
890
+ // Secondary face capture (continuous during initial scan and hologram detection)
891
+ // MLI (Multi Layer Image) is small secondary face on Turkish ID cards
892
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' || nextStep === 'SCAN_HOLOGRAM') {
893
+ if (isDebugEnabled() && allDetectedFaces.length > 1) {
894
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Entry conditions check', {
895
+ hasCurrentSecondary: !!currentSecondaryFaceImage,
896
+ hasPrimaryFace: !!scannedData.faceImage,
897
+ totalFaces: allDetectedFaces.length,
898
+ facePositionValid,
899
+ willAttemptDetection: !currentSecondaryFaceImage && !!scannedData.faceImage && allDetectedFaces.length > 1 && facePositionValid
900
+ });
901
+ }
927
902
  if (currentSecondaryFaceImage) {
928
903
  scannedData.secondaryFaceImage = currentSecondaryFaceImage;
929
- } else if (!!scannedData.faceImage && croppedFaces.length > 1 && !!croppedFaces[1] && facePositionValid) {
930
- // Always validate similarity to ensure it's the same person on the same document
931
- const isSimilar = await areImagesSimilarNative(scannedData.faceImage, croppedFaces[1], 15000 // Default threshold from main branch
932
- );
933
- if (isSimilar) {
934
- scannedData.secondaryFaceImage = croppedFaces[1];
935
- setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
936
-
937
- // Update secondary face bounds for debug overlay
938
- if (faces.length > 1 && faces[1] && frameDimensions) {
939
- const screen = Dimensions.get('window');
940
- const frameAspect = frameDimensions.width / frameDimensions.height;
941
- const screenAspect = screen.width / screen.height;
942
- let scale;
943
- let offsetX = 0;
944
- let offsetY = 0;
945
- if (frameAspect > screenAspect) {
946
- scale = screen.height / frameDimensions.height;
947
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
948
- } else {
949
- scale = screen.width / frameDimensions.width;
950
- offsetY = (frameDimensions.height * scale - screen.height) / 2;
904
+ } else if (scannedData.faceImage && allDetectedFaces.length > 1 && facePositionValid) {
905
+ // Detect MLI face (smaller than main face, to the right)
906
+ const primaryFace = primaryFaces[0];
907
+ if (isDebugEnabled() && primaryFace) {
908
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Starting detection', {
909
+ totalFaces: allDetectedFaces.length,
910
+ primaryFaceSize: `${Math.round(primaryFace.bounds.width)}x${Math.round(primaryFace.bounds.height)}`,
911
+ primaryFacePos: `x:${Math.round(primaryFace.bounds.x)} y:${Math.round(primaryFace.bounds.y)}`,
912
+ otherFaces: allDetectedFaces.filter(f => f !== primaryFace).map(f => ({
913
+ size: `${Math.round(f.bounds.width)}x${Math.round(f.bounds.height)}`,
914
+ pos: `x:${Math.round(f.bounds.x)} y:${Math.round(f.bounds.y)}`,
915
+ isRight: f.bounds.x > primaryFace.bounds.x,
916
+ verticalRange: `${Math.round(primaryFace.bounds.y - primaryFace.bounds.height * 0.3)}-${Math.round(primaryFace.bounds.y + primaryFace.bounds.height * 1.3)}`,
917
+ inVerticalRange: f.bounds.y >= primaryFace.bounds.y - primaryFace.bounds.height * 0.3 && f.bounds.y <= primaryFace.bounds.y + primaryFace.bounds.height * 1.3
918
+ }))
919
+ });
920
+ }
921
+ const potentialMLIFaces = allDetectedFaces.filter(f => f !== primaryFace && f.bounds.x > primaryFace.bounds.x &&
922
+ // MLI is to the right of main face
923
+ f.bounds.y >= primaryFace.bounds.y - primaryFace.bounds.height * 0.3 &&
924
+ // Same vertical level (±30%)
925
+ f.bounds.y <= primaryFace.bounds.y + primaryFace.bounds.height * 1.3);
926
+ if (isDebugEnabled()) {
927
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Position filter result', {
928
+ potentialMLICount: potentialMLIFaces.length
929
+ });
930
+ }
931
+ if (potentialMLIFaces.length > 0 && potentialMLIFaces[0]) {
932
+ const secondaryFace = potentialMLIFaces[0];
933
+
934
+ // Crop MLI face separately
935
+ const mliFaceCropped = await getFaceImages([primaryFace, secondaryFace], image ?? '', frameWidth, frameHeight);
936
+ if (mliFaceCropped.length > 1 && mliFaceCropped[1]) {
937
+ // Visual similarity check with lenient threshold
938
+ const visualResult = await compareFaceVisualSimilarityNative(mliFaceCropped[0], mliFaceCropped[1]);
939
+ const similarityScore = visualResult?.similarity || 0;
940
+ const isLikelySamePerson = similarityScore >= 0.2; // Very lenient: 20%
941
+
942
+ if (isDebugEnabled()) {
943
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Similarity check', {
944
+ visualSimilarity: similarityScore.toFixed(3),
945
+ isLikelySamePerson,
946
+ threshold: 0.2
947
+ });
951
948
  }
952
- const scanLeft = (screen.width * 0.05 + offsetX) / scale;
953
- const scanTop = (screen.height * 0.36 + offsetY) / scale;
954
- const scanRight = (screen.width * 0.95 + offsetX) / scale;
955
- const scanBottom = (screen.height * 0.64 + offsetY) / scale;
956
- const isInsideScan = (x, y, w, h) => x >= scanLeft && y >= scanTop && x + w <= scanRight && y + h <= scanBottom;
957
- const secondaryBounds = faces[1].bounds;
958
- if (isInsideScan(secondaryBounds.x, secondaryBounds.y, secondaryBounds.width, secondaryBounds.height)) {
959
- setSecondaryFaceBounds({
960
- x: secondaryBounds.x * scale - offsetX,
961
- y: secondaryBounds.y * scale - offsetY,
962
- width: secondaryBounds.width * scale,
963
- height: secondaryBounds.height * scale
949
+ if (isLikelySamePerson) {
950
+ // Skip glare check for MLI - it's a small printed photo with different reflective properties
951
+ // Backend will validate quality
952
+ scannedData.secondaryFaceImage = mliFaceCropped[1];
953
+ setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
954
+ if (isDebugEnabled()) {
955
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Captured successfully', {
956
+ similarity: similarityScore.toFixed(3),
957
+ imageLength: mliFaceCropped[1]?.length || 0,
958
+ imageSet: !!scannedData.secondaryFaceImage,
959
+ stateSet: !!currentSecondaryFaceImage,
960
+ primarySize: `${Math.round(primaryFace.bounds.width)}x${Math.round(primaryFace.bounds.height)}`,
961
+ secondarySize: `${Math.round(secondaryFace.bounds.width)}x${Math.round(secondaryFace.bounds.height)}`
962
+ });
963
+ }
964
+ if (frameDimensions) {
965
+ const {
966
+ scale,
967
+ offsetX,
968
+ offsetY
969
+ } = getFrameToScreenTransform(frameDimensions.width, frameDimensions.height);
970
+ const secondaryBounds = secondaryFace.bounds;
971
+ setSecondaryFaceBounds(transformBoundsToScreen(secondaryBounds, scale, offsetX, offsetY));
972
+ }
973
+ } else if (isDebugEnabled()) {
974
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Rejected - similarity too low', {
975
+ similarity: similarityScore.toFixed(3),
976
+ threshold: 0.2
964
977
  });
965
- } else {
966
- setSecondaryFaceBounds(null);
967
978
  }
968
979
  }
969
- if (isDebugEnabled()) {
970
- console.log('[SecondaryFace] ✓ Captured and validated as similar to main face (same document plane)');
971
- }
972
- } else {
973
- secondaryFaceDetectionCurrentRetryCount.current++;
974
- if (isDebugEnabled()) {
975
- console.log('[SecondaryFace] ✗ Rejected - not similar enough to main face');
976
- }
977
980
  }
978
981
  } else {
979
982
  secondaryFaceDetectionCurrentRetryCount.current++;
980
- if (!facePositionValid && croppedFaces.length > 1) {
981
- if (isDebugEnabled()) {
982
- console.log('[SecondaryFace] ✗ Rejected - document plane changed');
983
- }
983
+ if (isDebugEnabled() && allDetectedFaces.length > 1) {
984
+ debugLog('IdentityDocumentCamera', '[MLI FACE] Conditions not met', {
985
+ hasPrimaryFace: !!scannedData.faceImage,
986
+ primaryFacesCount: primaryFaces.length,
987
+ allDetectedFaces: allDetectedFaces.length,
988
+ facePositionValid
989
+ });
984
990
  }
985
991
  }
986
992
  } else if (currentSecondaryFaceImage) {
@@ -989,238 +995,126 @@ const IdentityDocumentCamera = ({
989
995
  }
990
996
  }
991
997
 
992
- // UNIFIED SCAN_HOLOGRAM completion - ONLY check primary face and hologram collection
993
- // Document type is already definitively determined before entering this step
998
+ // ============================================================================
999
+ // SCAN_HOLOGRAM STEP
1000
+ // ============================================================================
994
1001
  if (nextStep === 'SCAN_HOLOGRAM') {
995
- // CRITICAL: Verify correct side is shown - hologram is ALWAYS on the front side with photo
996
- // If wrong side detected, warn user immediately
997
- const hasFaces = cardSizedFaces.length > 0;
998
- const hasBarcode = !!barcode?.rawValue; // ID back has barcode, front doesn't
999
-
1000
- // For passport: back side has no photo and different text pattern
1001
- // For ID card: back side has no photo, has barcode
1002
- const isWrongSideForHologram = !hasFaces || hasBarcode;
1003
- if (isWrongSideForHologram) {
1004
- if (isDebugEnabled()) {
1005
- console.log(`[SCAN_HOLOGRAM] Wrong side detected - no faces: ${!hasFaces}, has barcode: ${hasBarcode} - expecting front side with photo`);
1006
- }
1002
+ // Guard: barcode visible = back side shown
1003
+ if (barcode?.rawValue) {
1007
1004
  setStatus('INCORRECT');
1008
1005
  return;
1009
1006
  }
1007
+ const isCollecting = faceImages.current.length > 0;
1008
+ const maxRetriesReached = hologramDetectionCurrentRetryCount.current >= HOLOGRAM_DETECTION_RETRY_COUNT;
1009
+
1010
+ // Wait for face if not yet started collecting
1011
+ if (!isCollecting && primaryFaces.length === 0) {
1012
+ hologramFramesWithoutFace.current++;
1013
+ if (hologramFramesWithoutFace.current >= HOLOGRAM_MAX_FRAMES_WITHOUT_FACE) {
1014
+ setStatus('INCORRECT');
1015
+ return;
1016
+ }
1017
+ continueScanning();
1018
+ return;
1019
+ }
1010
1020
 
1011
- // Safety timeout: if we can't detect face for too many consecutive frames, give up
1012
- const faceDetectionTimeout = hologramFramesWithoutFace.current >= HOLOGRAM_MAX_FRAMES_WITHOUT_FACE;
1013
-
1014
- // Don't skip if actively collecting images
1015
- const isActivelyCollecting = faceImages.current.length > 0 && faceImages.current.length < HOLOGRAM_IMAGE_COUNT;
1016
- const hologramConditionMet = !!scannedData.hologramImage || hologramDetectionCurrentRetryCount.current >= HOLOGRAM_DETECTION_RETRY_COUNT && !isActivelyCollecting ||
1017
- // Don't skip if mid-collection
1018
- faceDetectionTimeout && !isActivelyCollecting; // Don't timeout if mid-collection
1019
-
1020
- // During hologram scan, we ONLY care about hologram collection - no other checks
1021
- // Secondary face, MRZ, document type checks are all skipped
1022
- // Document type was already definitively determined in the initial scan phase
1021
+ // Complete when hologram captured OR retries exhausted (not mid-collection)
1022
+ const stepComplete = (!!scannedData.hologramImage || maxRetriesReached && !isCollecting) && !!faceImageToUse; // Require face before completing
1023
1023
 
1024
- // Log detailed state for debugging
1025
- if (isActivelyCollecting && isDebugEnabled()) {
1026
- console.log(`[SCAN_HOLOGRAM] Actively collecting: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} - continuing collection`);
1027
- }
1028
- if (hologramConditionMet) {
1029
- if (faceDetectionTimeout && isDebugEnabled()) {
1030
- console.log('[SCAN_HOLOGRAM] Face detection timeout - proceeding without hologram');
1024
+ if (stepComplete) {
1025
+ // Ensure preserved MRZ data is included (current frame may not have
1026
+ // readable MRZ due to flash/tilting during hologram capture)
1027
+ if (!scannedData.mrzText && lastValidMRZText.current) {
1028
+ scannedData.mrzText = lastValidMRZText.current;
1031
1029
  }
1032
- setStatus('SCANNED');
1033
- if (nextStep !== 'SCAN_HOLOGRAM') {
1034
- setIsTorchOn(false);
1035
- }
1036
- // Route based on PRESERVED detectedDocumentType state (set during initial scan)
1037
- // Also check current frame's documentType and MRZ code as fallback
1038
- // Passport has no back side - go directly to COMPLETED
1039
- const isPassport = detectedDocumentType === 'PASSPORT' || documentType === 'PASSPORT' || parsedMRZData?.fields?.documentCode === 'P';
1040
- if (isDebugEnabled()) {
1041
- console.log('[SCAN_HOLOGRAM] Document type check:', {
1042
- detectedDocumentType,
1043
- documentType,
1044
- mrzCode: parsedMRZData?.fields?.documentCode,
1045
- isPassport
1046
- });
1030
+ if (!scannedData.mrzFields && lastValidMRZFields.current) {
1031
+ scannedData.mrzFields = lastValidMRZFields.current;
1047
1032
  }
1048
- if (isPassport) {
1049
- if (isDebugEnabled()) {
1050
- console.log('[SCAN_HOLOGRAM] Passport detected - completing scan (no back side)');
1051
- }
1052
- setNextStepAndVibrate('COMPLETED', 'SCAN_HOLOGRAM');
1053
- } else {
1054
- if (isDebugEnabled()) {
1055
- console.log('[SCAN_HOLOGRAM] ID card detected - proceeding to back scan');
1056
- }
1057
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_HOLOGRAM');
1058
- }
1059
- setTimeout(() => {
1060
- onIdentityDocumentScanned(scannedData);
1061
- }, 1000);
1033
+ setStatus('SCANNED');
1034
+
1035
+ // Use scannedData.mrzFields which we just ensured has preserved MRZ
1036
+ const mrzDocCode = scannedData.mrzFields?.documentCode;
1037
+ const nextStepAfterHologram = getNextStepAfterHologram(detectedDocumentType, documentType, mrzDocCode);
1038
+ transitionStepWithCallback(nextStepAfterHologram, 'SCAN_HOLOGRAM', scannedData);
1062
1039
  return;
1063
1040
  }
1064
- // Still collecting or conditions not met - stay in SCAN_HOLOGRAM
1065
- // Don't fall through to document type branching
1066
- setStatus('SCANNING');
1041
+ continueScanning();
1067
1042
  return;
1068
1043
  }
1069
- if (documentType === 'ID_FRONT') {
1070
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1071
- // CRITICAL: Verify this is actually an ID card, not a passport misdetected as ID_FRONT
1072
- // Passports can show signature-like text and be temporarily classified as ID_FRONT
1073
- if (parsedMRZData?.fields?.documentCode === 'P') {
1044
+
1045
+ // ============================================================================
1046
+ // INITIAL SCAN STEP - Detect document type and validate
1047
+ // ============================================================================
1048
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1049
+ // Determine which flow handler to use.
1050
+ // Current-frame passport detection always takes precedence over a locked
1051
+ // ID_FRONT — passport MRZ may not appear until later frames.
1052
+ const flowDocumentType = documentType === 'PASSPORT' ? 'PASSPORT' : detectedDocumentType !== 'UNKNOWN' ? detectedDocumentType : documentType;
1053
+ const handlePassportInitialStep = async () => {
1054
+ const flowResult = handlePassportFlow(primaryFaces, mrzText, parsedMRZData?.fields, mrzStableAndValid, onlyMRZScan, hasRequiredMRZFields(parsedMRZData?.fields), !!faceImageToUse);
1055
+ if (!flowResult.shouldProceed) {
1056
+ continueScanning(true);
1057
+ return;
1058
+ }
1059
+
1060
+ // Check for glare on passport before accepting
1061
+ const hasGlare = await checkDocumentGlare(image, frameWidth, frameHeight);
1062
+ if (hasGlare) {
1074
1063
  if (isDebugEnabled()) {
1075
- console.log('[ID_FRONT Scan] Detected as ID_FRONT but MRZ shows passport (code P) - waiting for passport branch');
1064
+ debugLog('IdentityDocumentCamera', '[PASSPORT] Rejected - glare detected');
1076
1065
  }
1077
- setStatus('SCANNING');
1066
+ continueScanning(false);
1078
1067
  return;
1079
1068
  }
1080
- const hasFace = cardSizedFaces.length > 0;
1081
- const hasSignature = /signature|imza|İmza/i.test(text);
1082
- const retryThreshold = 60;
1083
- const allowFaceOnly = mrzDetectionCurrentRetryCount.current > retryThreshold;
1084
- const allRequiredElementsInFrame = hasFace && (hasSignature || allowFaceOnly);
1085
- setElementsOutsideScanArea([]);
1086
- if (!allRequiredElementsInFrame) {
1087
- console.log('[ID_FRONT Scan] Valid but waiting for all elements in frame (face + signature)');
1088
- mrzDetectionCurrentRetryCount.current++;
1089
- setStatus('SCANNING');
1069
+ setDetectedDocumentType('PASSPORT');
1070
+ setStatus('SCANNED');
1071
+ const nextPassportStep = flowResult.nextAction === 'PROCEED_TO_COMPLETED' ? 'COMPLETED' : 'SCAN_HOLOGRAM';
1072
+ transitionStepWithCallback(nextPassportStep, 'SCAN_ID_FRONT_OR_PASSPORT', scannedData);
1073
+ };
1074
+ const handleIdFrontInitialStep = async () => {
1075
+ const flowResult = handleIDFrontFlow(primaryFaces, text, mrzText, parsedMRZData?.fields, mrzDetectionCurrentRetryCount.current);
1076
+ if (!flowResult.shouldProceed) {
1077
+ if (flowResult.nextAction === 'REJECT_AS_PASSPORT') {
1078
+ setDetectedDocumentType('UNKNOWN');
1079
+ consistentDocTypeCount.current = 0;
1080
+ lastDetectedDocType.current = 'UNKNOWN';
1081
+ }
1082
+ continueScanning(flowResult.nextAction !== 'REJECT_AS_PASSPORT');
1090
1083
  return;
1091
1084
  }
1092
1085
 
1093
- // CRITICAL: Final verification that this is definitively an ID card before proceeding
1094
- // Check if we have MRZ and if it indicates ID card (not passport)
1095
- if (parsedMRZData?.fields?.documentCode) {
1096
- if (parsedMRZData.fields.documentCode === 'I') {
1097
- if (isDebugEnabled()) {
1098
- console.log('[ID_FRONT Scan] MRZ confirms ID card (code I)');
1099
- }
1100
- } else if (parsedMRZData.fields.documentCode === 'P') {
1101
- if (isDebugEnabled()) {
1102
- console.log('[ID_FRONT Scan] MRZ shows passport (code P) - rejecting as ID_FRONT');
1103
- }
1104
- setStatus('SCANNING');
1105
- return;
1106
- }
1107
- } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
1108
- // No parsed MRZ BUT passport MRZ pattern visible (P<TUR, P<USA, etc.)
1109
- // This is likely a passport with OCR errors - wait for proper parsing
1086
+ // Check for glare on ID front before accepting
1087
+ const hasGlare = await checkDocumentGlare(image, frameWidth, frameHeight);
1088
+ if (hasGlare) {
1110
1089
  if (isDebugEnabled()) {
1111
- console.log('[ID_FRONT Scan] Passport MRZ pattern (P<XXX) visible but not parsed - waiting for passport classification');
1090
+ debugLog('IdentityDocumentCamera', '[ID_FRONT] Rejected - glare detected');
1112
1091
  }
1113
- mrzDetectionCurrentRetryCount.current++;
1114
- setStatus('SCANNING');
1092
+ continueScanning(false);
1115
1093
  return;
1116
1094
  }
1117
- // No MRZ or no passport pattern - proceed as ID card
1118
- // ID cards typically don't have MRZ on front side (only on back)
1119
-
1120
- // CRITICAL: Lock document type state to ID_FRONT before proceeding
1121
- // This ensures hologram completion knows it's an ID card (needs ID_BACK step)
1122
1095
  setDetectedDocumentType('ID_FRONT');
1123
1096
  setStatus('SCANNED');
1124
- setIsTorchOn(false);
1125
- if (onlyMRZScan) {
1126
- // Passport has no back side - go directly to COMPLETED
1127
- // At this point detectedDocumentType is definitively set
1128
- if (detectedDocumentType === 'PASSPORT') {
1129
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
1130
- } else {
1131
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_ID_FRONT_OR_PASSPORT');
1132
- }
1133
- setTimeout(() => {
1134
- onIdentityDocumentScanned(scannedData);
1135
- }, 1000);
1136
- } else {
1137
- if (isDebugEnabled()) {
1138
- console.log('[ID_FRONT Scan] Confirmed as ID card - proceeding to hologram');
1139
- }
1140
- setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1141
- setTimeout(() => {
1142
- onIdentityDocumentScanned(scannedData);
1143
- }, 1000);
1144
- }
1145
- }
1146
- // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
1147
- } else if (documentType === 'PASSPORT') {
1148
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && !scannedData.hologramImage) {
1149
- if (onlyMRZScan) {
1150
- const hasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
1151
- // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
1152
- if (!!scannedData.mrzText && hasRequiredFields && mrzStableAndValid) {
1153
- const hasFace = cardSizedFaces.length > 0;
1154
- const hasMRZ = !!mrzText;
1155
- const allRequiredElementsInFrame = hasFace && hasMRZ;
1156
- setElementsOutsideScanArea([]);
1157
- if (!allRequiredElementsInFrame) {
1158
- console.log('[Passport Scan] MRZ valid but waiting for all elements in frame (face + MRZ)');
1159
- setStatus('SCANNING');
1160
- return;
1161
- }
1162
- logMRZDetails('Passport Scan', parsedMRZData?.fields, mrzText, validMRZConsecutiveCount.current, isDebugEnabled());
1163
- setDetectedDocumentType('PASSPORT');
1164
- setStatus('SCANNED');
1165
- setIsTorchOn(false);
1166
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
1167
- setTimeout(() => {
1168
- onIdentityDocumentScanned(scannedData);
1169
- }, 1000);
1170
- return; // CRITICAL: Exit after MRZ-only scan completes to prevent fall-through
1171
- } else {
1172
- if (!!scannedData.mrzText && !mrzStableAndValid) {
1173
- logMRZValidationFailure('Passport Scan', hasRequiredFields, parsedMRZData, mrzDetectionCurrentRetryCount.current, isDebugEnabled());
1174
- }
1175
- mrzDetectionCurrentRetryCount.current++;
1176
- setStatus('SCANNING');
1177
- return; // Don't fall through to else-if
1178
- }
1179
- } else {
1180
- // Normal passport scan (with hologram) - require MRZ to be detected before proceeding
1181
- const hasFace = cardSizedFaces.length > 0;
1182
- const hasMRZ = !!mrzText;
1183
- const allRequiredElementsInFrame = hasFace && hasMRZ;
1184
- setElementsOutsideScanArea([]);
1185
- if (!allRequiredElementsInFrame) {
1186
- console.log('[Passport Scan] Valid but waiting for all elements in frame (face + MRZ)');
1187
- setStatus('SCANNING');
1188
- return;
1189
- }
1097
+ const nextIdFrontStep = onlyMRZScan ? 'SCAN_ID_BACK' : 'SCAN_HOLOGRAM';
1098
+ transitionStepWithCallback(nextIdFrontStep, 'SCAN_ID_FRONT_OR_PASSPORT', scannedData);
1099
+ };
1190
1100
 
1191
- // CRITICAL: Final verification - ensure MRZ definitively identifies this as passport
1192
- // This must pass before we can proceed to hologram
1193
- if (!parsedMRZData?.fields?.documentCode || parsedMRZData.fields.documentCode !== 'P') {
1194
- console.log('[Passport Scan] MRZ detected but not confirmed as passport (code:', parsedMRZData?.fields?.documentCode || 'none', ') - waiting for valid passport MRZ');
1195
- setStatus('SCANNING');
1196
- return;
1197
- }
1198
- console.log('[Passport Scan] MRZ confirmed passport (code P) - definitively identified, proceeding to hologram');
1199
- // CRITICAL: Lock document type state to PASSPORT before proceeding to hologram
1200
- // This ensures hologram completion knows it's a passport (no ID_BACK step)
1201
- setDetectedDocumentType('PASSPORT');
1202
- setStatus('SCANNED');
1203
- setIsTorchOn(false);
1204
- setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1205
- setTimeout(() => {
1206
- onIdentityDocumentScanned(scannedData);
1207
- }, 1000);
1208
- }
1101
+ // PASSPORT FLOW: Face + MRZ with code 'P'
1102
+ if (flowDocumentType === 'PASSPORT') {
1103
+ handlePassportInitialStep();
1104
+ return;
1209
1105
  }
1210
- // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
1211
- } else if (documentType === 'ID_BACK') {
1212
- // ID_BACK is now handled in the early-return path above (SCAN_ID_BACK)
1213
- // This branch only triggers if somehow ID_BACK is detected during a non-back-scan step
1214
- mrzDetectionCurrentRetryCount.current++;
1215
- setStatus('SCANNING');
1216
- } else {
1217
- // Document type UNKNOWN - continue scanning until we can classify it
1218
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1219
- console.log('[Document Scan] Type UNKNOWN - waiting for clearer detection (faces:', cardSizedFaces.length, 'mrzCode:', parsedMRZData?.fields?.documentCode || 'none', 'text length:', text.length, ')');
1106
+
1107
+ // ID CARD FLOW: Face + No passport MRZ pattern
1108
+ if (flowDocumentType === 'ID_FRONT') {
1109
+ handleIdFrontInitialStep();
1110
+ return;
1220
1111
  }
1221
- setStatus('SCANNING');
1112
+
1113
+ // UNKNOWN - Continue scanning
1114
+ continueScanning();
1115
+ return;
1222
1116
  }
1223
- }, [nextStep, frameDimensions, currentHologramImage, detectedDocumentType, currentFaceImage, testMode, hasRequiredMRZFields, areMRZFieldsEqual, onlyMRZScan, isTorchOn, setIsTorchOn, setNextStepAndVibrate, onIdentityDocumentScanned, logMRZDetails, logMRZValidationFailure, currentSecondaryFaceImage, detectHologramNative]);
1117
+ }, [nextStep, frameDimensions, currentHologramImage, detectedDocumentType, currentFaceImage, testMode, onlyMRZScan, setIsTorchOn, transitionStepWithCallback, onIdentityDocumentScanned, currentSecondaryFaceImage, detectHologramNative]);
1224
1118
  const handleFrame = useCallback(async event => {
1225
1119
  if (!isCameraInitialized.current) {
1226
1120
  return;
@@ -1348,47 +1242,18 @@ const IdentityDocumentCamera = ({
1348
1242
  },
1349
1243
  cornerPoints: b.cornerPoints ?? []
1350
1244
  }));
1351
-
1352
- // Log barcode detection for debugging (only when scanning ID back)
1353
- if (barcodes.length > 0 && nextStep === 'SCAN_ID_BACK' && isDebugEnabled()) {
1354
- console.log(`[Barcode JS] Detected ${barcodes.length} barcode(s):`);
1355
- barcodes.forEach((b, idx) => {
1356
- const formatNames = {
1357
- 5: 'PDF417',
1358
- 64: 'QR_CODE',
1359
- 1: 'CODE_128',
1360
- 2: 'CODE_39',
1361
- 13: 'EAN_13',
1362
- 8: 'EAN_8',
1363
- 4096: 'AZTEC',
1364
- 16: 'DATA_MATRIX'
1365
- };
1366
- const formatName = formatNames[b.format] || `UNKNOWN(${b.format})`;
1367
- console.log(` [${idx + 1}] ${formatName}: ${b.rawValue.substring(0, 50)}`);
1368
- });
1369
- }
1370
1245
  }
1371
1246
 
1372
1247
  // Update all debug overlay bounds continuously when debug mode is enabled
1373
1248
  if (isDebugEnabled() && frameDimensions) {
1374
- const screen = Dimensions.get('window');
1375
- const frameAspect = frameDimensions.width / frameDimensions.height;
1376
- const screenAspect = screen.width / screen.height;
1377
- let scale;
1378
- let offsetX = 0;
1379
- let offsetY = 0;
1380
- if (frameAspect > screenAspect) {
1381
- scale = screen.height / frameDimensions.height;
1382
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
1383
- } else {
1384
- scale = screen.width / frameDimensions.width;
1385
- offsetY = (frameDimensions.height * scale - screen.height) / 2;
1386
- }
1387
- const scanLeft = (screen.width * 0.05 + offsetX) / scale;
1388
- const scanTop = (screen.height * 0.36 + offsetY) / scale;
1389
- const scanRight = (screen.width * 0.95 + offsetX) / scale;
1390
- const scanBottom = (screen.height * 0.64 + offsetY) / scale;
1391
- const isInsideScan = (x, y, w, h) => x >= scanLeft && y >= scanTop && x + w <= scanRight && y + h <= scanBottom;
1249
+ const {
1250
+ scale,
1251
+ offsetX,
1252
+ offsetY
1253
+ } = getFrameToScreenTransform(frameDimensions.width, frameDimensions.height);
1254
+ const {
1255
+ isInsideScan
1256
+ } = getScanAreaBounds(frameDimensions.width, frameDimensions.height);
1392
1257
 
1393
1258
  // Update barcode bounds
1394
1259
  if (barcodes.length > 0 && barcodes[0]) {
@@ -1402,16 +1267,7 @@ const IdentityDocumentCamera = ({
1402
1267
  x: c.x * scale - offsetX,
1403
1268
  y: c.y * scale - offsetY
1404
1269
  }));
1405
- // Calculate angle from first two corners (bottom edge)
1406
- const dx = transformedCorners[1].x - transformedCorners[0].x;
1407
- const dy = transformedCorners[1].y - transformedCorners[0].y;
1408
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
1409
- }
1410
- if (isDebugEnabled()) {
1411
- console.log('[Debug] Barcode detected:', {
1412
- bbox,
1413
- angle
1414
- });
1270
+ angle = angleBetweenPoints(transformedCorners[0], transformedCorners[1]);
1415
1271
  }
1416
1272
  setBarcodeBounds({
1417
1273
  x: bbox.left * scale - offsetX,
@@ -1472,51 +1328,20 @@ const IdentityDocumentCamera = ({
1472
1328
 
1473
1329
  // Detect MRZ and signature text areas continuously
1474
1330
  if (textBlocks.length > 0) {
1475
- console.log('[Debug] Text blocks count:', textBlocks.length);
1476
- // Find MRZ-like text blocks (bottom area, contains MRZ-like characters)
1477
- // More strict pattern: look for blocks with 8+ consecutive uppercase/numbers/< characters AND
1478
- // must contain at least one '<' character (true MRZ characteristic)
1479
- const mrzPattern = /[A-Z0-9<]{8,}.*</i;
1480
- const bottomHalf = frame.height * 0.5; // Increased from 0.67 to catch more MRZ blocks
1481
-
1482
- // Log bottom area blocks for debugging
1483
- const bottomBlocks = textBlocks.filter(block => block.blockFrame && block.blockFrame.y > bottomHalf);
1484
- if (bottomBlocks.length > 0) {
1485
- console.log('[Debug] Bottom area blocks:', bottomBlocks.map(b => b.text.substring(0, 30)));
1486
- }
1487
- const mrzBlocks = textBlocks.filter(block => block.blockFrame && block.blockFrame.y > bottomHalf && mrzPattern.test(block.text));
1488
- console.log('[Debug] MRZ blocks found:', mrzBlocks.length);
1331
+ const bottomHalf = frame.height * 0.5;
1332
+ const mrzBlocks = textBlocks.filter(block => block.blockFrame && block.blockFrame.y > bottomHalf && MRZ_BLOCK_PATTERN.test(block.text));
1489
1333
  if (mrzBlocks.length > 0) {
1490
- // Extract MRZ-only text from detected blocks (sorted by Y position for correct line order)
1491
1334
  const sortedMrzBlocks = [...mrzBlocks].sort((a, b) => (a.blockFrame?.y || 0) - (b.blockFrame?.y || 0));
1492
1335
  scannedText.mrzOnlyText = sortedMrzBlocks.map(b => b.text).join('\n');
1493
- if (isDebugEnabled()) {
1494
- console.log('[MRZ Extraction] Using only MRZ blocks:', scannedText.mrzOnlyText.substring(0, 100));
1495
- }
1496
1336
  const minX = Math.min(...mrzBlocks.map(b => b.blockFrame.x));
1497
1337
  const minY = Math.min(...mrzBlocks.map(b => b.blockFrame.y));
1498
1338
  const maxX = Math.max(...mrzBlocks.map(b => b.blockFrame.x + b.blockFrame.width));
1499
1339
  const maxY = Math.max(...mrzBlocks.map(b => b.blockFrame.y + b.blockFrame.height));
1500
-
1501
- // Collect all corner points from MRZ blocks
1502
1340
  const allCornerPoints = mrzBlocks.flatMap(b => b.cornerPoints || []).map(c => ({
1503
1341
  x: c.x * scale - offsetX,
1504
1342
  y: c.y * scale - offsetY
1505
1343
  }));
1506
- let angle = 0;
1507
- if (allCornerPoints.length >= 2) {
1508
- // Calculate angle from first two points
1509
- const dx = allCornerPoints[1].x - allCornerPoints[0].x;
1510
- const dy = allCornerPoints[1].y - allCornerPoints[0].y;
1511
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
1512
- }
1513
- console.log('[Debug] MRZ bounds:', {
1514
- minX,
1515
- minY,
1516
- maxX,
1517
- maxY,
1518
- angle
1519
- });
1344
+ const angle = allCornerPoints.length >= 2 ? angleBetweenPoints(allCornerPoints[0], allCornerPoints[1]) : 0;
1520
1345
  setMrzBounds({
1521
1346
  x: minX * scale - offsetX,
1522
1347
  y: minY * scale - offsetY,
@@ -1528,31 +1353,17 @@ const IdentityDocumentCamera = ({
1528
1353
  } else {
1529
1354
  setMrzBounds(null);
1530
1355
  }
1531
-
1532
- // Detect signature area
1533
- const signaturePattern = /signature|imza|İmza/i;
1534
- const signatureBlocks = textBlocks.filter(block => block.blockFrame && signaturePattern.test(block.text));
1535
- if (textBlocks.length > 0 && signatureBlocks.length === 0) {
1536
- console.log(`[Signature Debug] No signature blocks found. All blocks (${textBlocks.length}):`, textBlocks.map(b => b.text).join(' | '));
1537
- }
1356
+ const signatureBlocks = textBlocks.filter(block => block.blockFrame && SIGNATURE_TEXT_REGEX.test(block.text));
1538
1357
  if (signatureBlocks.length > 0) {
1539
1358
  const minX = Math.min(...signatureBlocks.map(b => b.blockFrame.x));
1540
1359
  const minY = Math.min(...signatureBlocks.map(b => b.blockFrame.y));
1541
1360
  const maxX = Math.max(...signatureBlocks.map(b => b.blockFrame.x + b.blockFrame.width));
1542
1361
  const maxY = Math.max(...signatureBlocks.map(b => b.blockFrame.y + b.blockFrame.height));
1543
-
1544
- // Collect all corner points from signature blocks
1545
1362
  const allCornerPoints = signatureBlocks.flatMap(b => b.cornerPoints || []).map(c => ({
1546
1363
  x: c.x * scale - offsetX,
1547
1364
  y: c.y * scale - offsetY
1548
1365
  }));
1549
- let angle = 0;
1550
- if (allCornerPoints.length >= 2) {
1551
- // Calculate angle from first two points
1552
- const dx = allCornerPoints[1].x - allCornerPoints[0].x;
1553
- const dy = allCornerPoints[1].y - allCornerPoints[0].y;
1554
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
1555
- }
1366
+ const angle = allCornerPoints.length >= 2 ? angleBetweenPoints(allCornerPoints[0], allCornerPoints[1]) : 0;
1556
1367
  setSignatureBounds({
1557
1368
  x: minX * scale - offsetX,
1558
1369
  y: minY * scale - offsetY,
@@ -1564,67 +1375,9 @@ const IdentityDocumentCamera = ({
1564
1375
  } else {
1565
1376
  setSignatureBounds(null);
1566
1377
  }
1567
-
1568
- // Check if all required elements are detected based on document type
1569
- if (nextStep === 'SCAN_ID_BACK') {
1570
- // ID Back: MRZ + barcode (barcode optional but preferred)
1571
- const hasMRZ = mrzBlocks.length > 0;
1572
- const hasBarcode = barcodes.length > 0 || cachedBarcode.current !== null;
1573
- const allPresent = hasMRZ && hasBarcode;
1574
- setAllElementsDetected(allPresent);
1575
-
1576
- // Don't block based on bounds - allow elements even if slightly outside
1577
- setElementsOutsideScanArea([]);
1578
- if (!allPresent) {
1579
- const missing = [];
1580
- if (!hasMRZ) missing.push('MRZ');
1581
- if (!hasBarcode) missing.push('Barcode');
1582
- console.log(`[Frame Check] Missing elements: ${missing.join(', ')}`);
1583
- } else {
1584
- console.log('[Frame Check] ✓ All elements detected in frame');
1585
- }
1586
- } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1587
- // Check if it's passport (has MRZ) or ID front (no MRZ)
1588
- const hasMRZ = mrzBlocks.length > 0;
1589
- const hasFace = detectedFaces.length > 0;
1590
- const hasSignature = signatureBlocks.length > 0;
1591
-
1592
- // Don't block based on bounds - allow elements even if slightly outside
1593
- setElementsOutsideScanArea([]);
1594
- let allPresent = false;
1595
- if (hasMRZ) {
1596
- // Passport: face + MRZ
1597
- allPresent = hasFace && hasMRZ;
1598
- if (!allPresent) {
1599
- const missing = [];
1600
- if (!hasFace) missing.push('Face');
1601
- if (!hasMRZ) missing.push('MRZ');
1602
- console.log(`[Frame Check] Passport - Missing elements: ${missing.join(', ')}`);
1603
- } else {
1604
- console.log('[Frame Check] ✓ Passport - All elements detected (face + MRZ)');
1605
- }
1606
- } else {
1607
- // ID Front: face + signature
1608
- allPresent = hasFace && hasSignature;
1609
- if (!allPresent) {
1610
- const missing = [];
1611
- if (!hasFace) missing.push('Face');
1612
- if (!hasSignature) missing.push('Signature');
1613
- console.log(`[Frame Check] ID Front - Missing elements: ${missing.join(', ')}`);
1614
- } else {
1615
- console.log('[Frame Check] ✓ ID Front - All elements detected (face + signature)');
1616
- }
1617
- }
1618
- setAllElementsDetected(allPresent);
1619
- } else {
1620
- setAllElementsDetected(false);
1621
- setElementsOutsideScanArea([]);
1622
- }
1623
1378
  } else {
1624
1379
  setMrzBounds(null);
1625
1380
  setSignatureBounds(null);
1626
- setAllElementsDetected(false);
1627
- setElementsOutsideScanArea([]);
1628
1381
  }
1629
1382
  } else if (!isDebugEnabled()) {
1630
1383
  // Clear all bounds when debug mode is disabled
@@ -1635,39 +1388,28 @@ const IdentityDocumentCamera = ({
1635
1388
  setSignatureBounds(null);
1636
1389
  }
1637
1390
 
1638
- // Update allElementsDetected for status text display (regardless of debug mode)
1391
+ // Update allElementsDetected for status text display
1639
1392
  if (nextStep === 'SCAN_ID_BACK') {
1640
- const hasMRZ = textBlocks.some(b => /[A-Z0-9<]{8,}.*</i.test(b.text));
1393
+ const hasMRZ = textBlocks.some(b => MRZ_BLOCK_PATTERN.test(b.text));
1641
1394
  const hasBarcode = barcodes.length > 0 || cachedBarcode.current !== null;
1642
1395
  setAllElementsDetected(hasMRZ && hasBarcode);
1643
1396
  } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1644
- const hasMRZ = textBlocks.some(b => /[A-Z0-9<]{8,}.*</i.test(b.text));
1397
+ const hasMRZ = textBlocks.some(b => MRZ_BLOCK_PATTERN.test(b.text));
1645
1398
  const hasFace = detectedFaces.length > 0;
1646
- const hasSignature = textBlocks.some(b => /signature|imza|İmza/i.test(b.text));
1399
+ const hasSignature = textBlocks.some(b => SIGNATURE_TEXT_REGEX.test(b.text));
1647
1400
  setAllElementsDetected(hasMRZ ? hasFace && hasMRZ : hasFace && hasSignature);
1648
1401
  } else {
1649
1402
  setAllElementsDetected(false);
1650
1403
  }
1651
1404
 
1652
1405
  // Check if detected elements are inside the scan area
1653
- const scanScreen = Dimensions.get('window');
1654
- const scanFrameAspect = frame.width / frame.height;
1655
- const scanScreenAspect = scanScreen.width / scanScreen.height;
1656
- let scanScale;
1657
- let scanOffsetX = 0;
1658
- let scanOffsetY = 0;
1659
- if (scanFrameAspect > scanScreenAspect) {
1660
- scanScale = scanScreen.height / frame.height;
1661
- scanOffsetX = (frame.width * scanScale - scanScreen.width) / 2;
1662
- } else {
1663
- scanScale = scanScreen.width / frame.width;
1664
- scanOffsetY = (frame.height * scanScale - scanScreen.height) / 2;
1665
- }
1666
- const scanLeft = (scanScreen.width * 0.05 + scanOffsetX) / scanScale;
1667
- const scanTop = (scanScreen.height * 0.36 + scanOffsetY) / scanScale;
1668
- const scanRight = (scanScreen.width * 0.95 + scanOffsetX) / scanScale;
1669
- const scanBottom = (scanScreen.height * 0.64 + scanOffsetY) / scanScale;
1670
- const isInsideScan = (x, y, w, h) => x >= scanLeft && y >= scanTop && x + w <= scanRight && y + h <= scanBottom;
1406
+ const {
1407
+ scanLeft,
1408
+ scanTop,
1409
+ scanRight,
1410
+ scanBottom,
1411
+ isInsideScan
1412
+ } = getScanAreaBounds(frame.width, frame.height);
1671
1413
  const outsideElements = [];
1672
1414
 
1673
1415
  // Collect all detected element bounds
@@ -1697,8 +1439,8 @@ const IdentityDocumentCamera = ({
1697
1439
  y2: bf.y + bf.height
1698
1440
  });
1699
1441
  }
1700
- const isMRZ = /[A-Z0-9<]{8,}.*</i.test(block.text);
1701
- const isSignature = /signature|imza|İmza/i.test(block.text);
1442
+ const isMRZ = MRZ_BLOCK_PATTERN.test(block.text);
1443
+ const isSignature = SIGNATURE_TEXT_REGEX.test(block.text);
1702
1444
  if ((isMRZ || isSignature) && !isInsideScan(bf.x, bf.y, bf.width, bf.height)) {
1703
1445
  outsideElements.push('text');
1704
1446
  }
@@ -1772,8 +1514,8 @@ const IdentityDocumentCamera = ({
1772
1514
  return /*#__PURE__*/_jsxs(View, {
1773
1515
  style: StyleSheet.absoluteFill,
1774
1516
  children: [/*#__PURE__*/_jsx(StatusBar, {
1775
- barStyle: "light-content",
1776
- backgroundColor: "transparent",
1517
+ barStyle: hasGuideShown ? 'light-content' : 'dark-content',
1518
+ backgroundColor: hasGuideShown ? 'transparent' : '#ffffff',
1777
1519
  translucent: true
1778
1520
  }), !hasGuideShown ? /*#__PURE__*/_jsxs(SafeAreaView, {
1779
1521
  style: styles.guide,
@@ -1822,345 +1564,6 @@ const IdentityDocumentCamera = ({
1822
1564
  onFrameAvailable: handleFrame,
1823
1565
  onCameraReady: handleCameraReady,
1824
1566
  onCameraError: handleCameraError
1825
- }), isDebugEnabled() && documentPlaneBounds && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsxs(_Fragment, {
1826
- children: [!!documentPlaneBounds.cropPadding && /*#__PURE__*/_jsx(View, {
1827
- style: {
1828
- position: 'absolute',
1829
- left: documentPlaneBounds.x - documentPlaneBounds.cropPadding,
1830
- top: documentPlaneBounds.y - documentPlaneBounds.cropPadding,
1831
- width: documentPlaneBounds.width + 2 * documentPlaneBounds.cropPadding,
1832
- height: documentPlaneBounds.height + 2 * documentPlaneBounds.cropPadding,
1833
- borderWidth: 2,
1834
- borderColor: 'rgba(76, 175, 80, 0.5)',
1835
- borderStyle: 'dashed',
1836
- borderRadius: 8,
1837
- backgroundColor: 'transparent',
1838
- transform: [{
1839
- rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg`
1840
- }],
1841
- transformOrigin: 'center'
1842
- }
1843
- }), /*#__PURE__*/_jsx(View, {
1844
- style: {
1845
- position: 'absolute',
1846
- left: documentPlaneBounds.x,
1847
- top: documentPlaneBounds.y,
1848
- width: documentPlaneBounds.width,
1849
- height: documentPlaneBounds.height,
1850
- borderWidth: 3,
1851
- borderColor: '#4CAF50',
1852
- borderRadius: 8,
1853
- backgroundColor: 'transparent',
1854
- transform: [{
1855
- rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg`
1856
- }],
1857
- transformOrigin: 'center'
1858
- },
1859
- children: !!documentPlaneBounds.rollAngle && Math.abs(documentPlaneBounds.rollAngle) > 5 && /*#__PURE__*/_jsxs(TextView, {
1860
- style: {
1861
- position: 'absolute',
1862
- top: -20,
1863
- left: 0,
1864
- color: '#4CAF50',
1865
- fontSize: 10,
1866
- fontWeight: 'bold',
1867
- backgroundColor: 'rgba(0,0,0,0.7)',
1868
- paddingHorizontal: 4,
1869
- paddingVertical: 2,
1870
- borderRadius: 2
1871
- },
1872
- children: [documentPlaneBounds.rollAngle.toFixed(1), "\xB0"]
1873
- })
1874
- })]
1875
- }), isDebugEnabled() && secondaryFaceBounds && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsxs(_Fragment, {
1876
- children: [!!secondaryFaceBounds.cropPadding && /*#__PURE__*/_jsx(View, {
1877
- style: {
1878
- position: 'absolute',
1879
- left: secondaryFaceBounds.x - secondaryFaceBounds.cropPadding,
1880
- top: secondaryFaceBounds.y - secondaryFaceBounds.cropPadding,
1881
- width: secondaryFaceBounds.width + 2 * secondaryFaceBounds.cropPadding,
1882
- height: secondaryFaceBounds.height + 2 * secondaryFaceBounds.cropPadding,
1883
- borderWidth: 2,
1884
- borderColor: 'rgba(33, 150, 243, 0.5)',
1885
- borderStyle: 'dashed',
1886
- borderRadius: 8,
1887
- backgroundColor: 'transparent',
1888
- transform: [{
1889
- rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg`
1890
- }],
1891
- transformOrigin: 'center'
1892
- }
1893
- }), /*#__PURE__*/_jsx(View, {
1894
- style: {
1895
- position: 'absolute',
1896
- left: secondaryFaceBounds.x,
1897
- top: secondaryFaceBounds.y,
1898
- width: secondaryFaceBounds.width,
1899
- height: secondaryFaceBounds.height,
1900
- borderWidth: 3,
1901
- borderColor: '#2196F3',
1902
- borderRadius: 8,
1903
- backgroundColor: 'transparent',
1904
- transform: [{
1905
- rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg`
1906
- }],
1907
- transformOrigin: 'center'
1908
- },
1909
- children: !!secondaryFaceBounds.rollAngle && Math.abs(secondaryFaceBounds.rollAngle) > 5 && /*#__PURE__*/_jsxs(TextView, {
1910
- style: {
1911
- position: 'absolute',
1912
- top: -20,
1913
- left: 0,
1914
- color: '#2196F3',
1915
- fontSize: 10,
1916
- fontWeight: 'bold',
1917
- backgroundColor: 'rgba(0,0,0,0.7)',
1918
- paddingHorizontal: 4,
1919
- paddingVertical: 2,
1920
- borderRadius: 2
1921
- },
1922
- children: [secondaryFaceBounds.rollAngle.toFixed(1), "\xB0"]
1923
- })
1924
- })]
1925
- }), isDebugEnabled() && barcodeBounds && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsx(_Fragment, {
1926
- children: barcodeBounds.corners && barcodeBounds.corners.length >= 4 ?
1927
- /*#__PURE__*/
1928
- // Render using corner points for precise rotated border
1929
- _jsxs(_Fragment, {
1930
- children: [[0, 1, 2, 3].map(i => {
1931
- const start = barcodeBounds.corners[i];
1932
- const end = barcodeBounds.corners[(i + 1) % 4];
1933
- const dx = end.x - start.x;
1934
- const dy = end.y - start.y;
1935
- const length = Math.sqrt(dx * dx + dy * dy);
1936
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
1937
- return /*#__PURE__*/_jsx(View, {
1938
- style: {
1939
- position: 'absolute',
1940
- left: start.x,
1941
- top: start.y,
1942
- width: length,
1943
- height: 3,
1944
- backgroundColor: '#FF9800',
1945
- transform: [{
1946
- rotate: `${angle}deg`
1947
- }],
1948
- transformOrigin: 'top left'
1949
- }
1950
- }, i);
1951
- }), barcodeBounds.corners.map((corner, idx) => /*#__PURE__*/_jsx(View, {
1952
- style: {
1953
- position: 'absolute',
1954
- left: corner.x - 4,
1955
- top: corner.y - 4,
1956
- width: 8,
1957
- height: 8,
1958
- borderRadius: 4,
1959
- backgroundColor: '#FF9800'
1960
- }
1961
- }, `corner-${idx}`)), !!barcodeBounds.angle && Math.abs(barcodeBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
1962
- style: {
1963
- position: 'absolute',
1964
- left: barcodeBounds.x,
1965
- top: barcodeBounds.y - 20,
1966
- color: '#FF9800',
1967
- fontSize: 10,
1968
- fontWeight: 'bold',
1969
- backgroundColor: 'rgba(0,0,0,0.7)',
1970
- paddingHorizontal: 4,
1971
- paddingVertical: 2,
1972
- borderRadius: 2
1973
- },
1974
- children: [barcodeBounds.angle.toFixed(1), "\xB0"]
1975
- })]
1976
- }) :
1977
- /*#__PURE__*/
1978
- // Fallback to rotated rectangle if corners not available
1979
- _jsx(View, {
1980
- style: {
1981
- position: 'absolute',
1982
- left: barcodeBounds.x + barcodeBounds.width / 2,
1983
- top: barcodeBounds.y + barcodeBounds.height / 2,
1984
- width: barcodeBounds.width,
1985
- height: barcodeBounds.height,
1986
- marginLeft: -barcodeBounds.width / 2,
1987
- marginTop: -barcodeBounds.height / 2,
1988
- borderWidth: 3,
1989
- borderColor: '#FF9800',
1990
- borderRadius: 8,
1991
- backgroundColor: 'transparent',
1992
- transform: [{
1993
- rotate: `${barcodeBounds.angle || 0}deg`
1994
- }]
1995
- },
1996
- children: !!barcodeBounds.angle && Math.abs(barcodeBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
1997
- style: {
1998
- position: 'absolute',
1999
- top: -20,
2000
- left: 0,
2001
- color: '#FF9800',
2002
- fontSize: 10,
2003
- fontWeight: 'bold',
2004
- backgroundColor: 'rgba(0,0,0,0.7)',
2005
- paddingHorizontal: 4,
2006
- paddingVertical: 2,
2007
- borderRadius: 2
2008
- },
2009
- children: [barcodeBounds.angle.toFixed(1), "\xB0"]
2010
- })
2011
- })
2012
- }), isDebugEnabled() && mrzBounds && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsx(_Fragment, {
2013
- children: mrzBounds.corners && mrzBounds.corners.length >= 2 ?
2014
- /*#__PURE__*/
2015
- // Render using corner points for precise rotated border
2016
- _jsxs(_Fragment, {
2017
- children: [mrzBounds.corners.map((corner, idx) => {
2018
- const nextCorner = mrzBounds.corners[(idx + 1) % mrzBounds.corners.length];
2019
- const dx = nextCorner.x - corner.x;
2020
- const dy = nextCorner.y - corner.y;
2021
- const length = Math.sqrt(dx * dx + dy * dy);
2022
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2023
- return /*#__PURE__*/_jsx(View, {
2024
- style: {
2025
- position: 'absolute',
2026
- left: corner.x,
2027
- top: corner.y,
2028
- width: length,
2029
- height: 3,
2030
- backgroundColor: '#9C27B0',
2031
- transform: [{
2032
- rotate: `${angle}deg`
2033
- }],
2034
- transformOrigin: 'top left'
2035
- }
2036
- }, idx);
2037
- }), !!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
2038
- style: {
2039
- position: 'absolute',
2040
- left: mrzBounds.x,
2041
- top: mrzBounds.y - 20,
2042
- color: '#9C27B0',
2043
- fontSize: 10,
2044
- fontWeight: 'bold',
2045
- backgroundColor: 'rgba(0,0,0,0.7)',
2046
- paddingHorizontal: 4,
2047
- paddingVertical: 2,
2048
- borderRadius: 2
2049
- },
2050
- children: [mrzBounds.angle.toFixed(1), "\xB0"]
2051
- })]
2052
- }) :
2053
- /*#__PURE__*/
2054
- // Fallback to rotated rectangle if corners not available
2055
- _jsx(View, {
2056
- style: {
2057
- position: 'absolute',
2058
- left: mrzBounds.x + mrzBounds.width / 2,
2059
- top: mrzBounds.y + mrzBounds.height / 2,
2060
- width: mrzBounds.width,
2061
- height: mrzBounds.height,
2062
- marginLeft: -mrzBounds.width / 2,
2063
- marginTop: -mrzBounds.height / 2,
2064
- borderWidth: 3,
2065
- borderColor: '#9C27B0',
2066
- borderRadius: 8,
2067
- backgroundColor: 'transparent',
2068
- transform: [{
2069
- rotate: `${mrzBounds.angle || 0}deg`
2070
- }]
2071
- },
2072
- children: !!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
2073
- style: {
2074
- position: 'absolute',
2075
- top: -20,
2076
- left: 0,
2077
- color: '#9C27B0',
2078
- fontSize: 10,
2079
- fontWeight: 'bold',
2080
- backgroundColor: 'rgba(0,0,0,0.7)',
2081
- paddingHorizontal: 4,
2082
- paddingVertical: 2,
2083
- borderRadius: 2
2084
- },
2085
- children: [mrzBounds.angle.toFixed(1), "\xB0"]
2086
- })
2087
- })
2088
- }), isDebugEnabled() && signatureBounds && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsx(_Fragment, {
2089
- children: signatureBounds.corners && signatureBounds.corners.length >= 2 ?
2090
- /*#__PURE__*/
2091
- // Render using corner points for precise rotated border
2092
- _jsxs(_Fragment, {
2093
- children: [signatureBounds.corners.map((corner, idx) => {
2094
- const nextCorner = signatureBounds.corners[(idx + 1) % signatureBounds.corners.length];
2095
- const dx = nextCorner.x - corner.x;
2096
- const dy = nextCorner.y - corner.y;
2097
- const length = Math.sqrt(dx * dx + dy * dy);
2098
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2099
- return /*#__PURE__*/_jsx(View, {
2100
- style: {
2101
- position: 'absolute',
2102
- left: corner.x,
2103
- top: corner.y,
2104
- width: length,
2105
- height: 3,
2106
- backgroundColor: '#00BCD4',
2107
- transform: [{
2108
- rotate: `${angle}deg`
2109
- }],
2110
- transformOrigin: 'top left'
2111
- }
2112
- }, idx);
2113
- }), !!signatureBounds.angle && Math.abs(signatureBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
2114
- style: {
2115
- position: 'absolute',
2116
- left: signatureBounds.x,
2117
- top: signatureBounds.y - 20,
2118
- color: '#00BCD4',
2119
- fontSize: 10,
2120
- fontWeight: 'bold',
2121
- backgroundColor: 'rgba(0,0,0,0.7)',
2122
- paddingHorizontal: 4,
2123
- paddingVertical: 2,
2124
- borderRadius: 2
2125
- },
2126
- children: [signatureBounds.angle.toFixed(1), "\xB0"]
2127
- })]
2128
- }) :
2129
- /*#__PURE__*/
2130
- // Fallback to rotated rectangle if corners not available
2131
- _jsx(View, {
2132
- style: {
2133
- position: 'absolute',
2134
- left: signatureBounds.x + signatureBounds.width / 2,
2135
- top: signatureBounds.y + signatureBounds.height / 2,
2136
- width: signatureBounds.width,
2137
- height: signatureBounds.height,
2138
- marginLeft: -signatureBounds.width / 2,
2139
- marginTop: -signatureBounds.height / 2,
2140
- borderWidth: 3,
2141
- borderColor: '#00BCD4',
2142
- borderRadius: 8,
2143
- backgroundColor: 'transparent',
2144
- transform: [{
2145
- rotate: `${signatureBounds.angle || 0}deg`
2146
- }]
2147
- },
2148
- children: !!signatureBounds.angle && Math.abs(signatureBounds.angle) > 5 && /*#__PURE__*/_jsxs(TextView, {
2149
- style: {
2150
- position: 'absolute',
2151
- top: -20,
2152
- left: 0,
2153
- color: '#00BCD4',
2154
- fontSize: 10,
2155
- fontWeight: 'bold',
2156
- backgroundColor: 'rgba(0,0,0,0.7)',
2157
- paddingHorizontal: 4,
2158
- paddingVertical: 2,
2159
- borderRadius: 2
2160
- },
2161
- children: [signatureBounds.angle.toFixed(1), "\xB0"]
2162
- })
2163
- })
2164
1567
  }), /*#__PURE__*/_jsxs(View, {
2165
1568
  style: [styles.topZone, {
2166
1569
  paddingTop: insets.top
@@ -2192,7 +1595,7 @@ const IdentityDocumentCamera = ({
2192
1595
  status === 'SCANNING' && allElementsDetected && elementsOutsideScanArea.length === 0 && !isBrightnessLow && !isFrameBlurry && styles.topZoneTextScanning
2193
1596
  // 5. Default (white) - aligning (not all detected OR elements outside scan area)
2194
1597
  ],
2195
- children: nextStep === 'COMPLETED' ? t('identityDocumentCamera.scanCompleted') : status === 'INCORRECT' ? nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? t('identityDocumentCamera.alignIDFront') : nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.alignIDBack') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignIDFront') : t('identityDocumentCamera.alignPhotoSide') : isBrightnessLow ? t('identityDocumentCamera.lowBrightness') : isFrameBlurry ? t('identityDocumentCamera.avoidBlur') : status === 'SCANNING' && allElementsDetected && elementsOutsideScanArea.length === 0 ? nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.idCardBackDetected') : detectedDocumentType === 'PASSPORT' ? t('identityDocumentCamera.passportDetected') : detectedDocumentType === 'ID_FRONT' ? t('identityDocumentCamera.idCardFrontDetected') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : t('identityDocumentCamera.readingDocument') : elementsOutsideScanArea.length > 0 ? t('identityDocumentCamera.centerDocument') : (status === 'SCANNING' || status === 'SEARCHING') && !allElementsDetected ? nextStep === 'SCAN_ID_BACK' ? t('identityDocumentCamera.alignIDBack') : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? detectedDocumentType === 'PASSPORT' ? t('identityDocumentCamera.alignPassport') : detectedDocumentType === 'ID_FRONT' ? t('identityDocumentCamera.alignIDFront') : t('identityDocumentCamera.alignPhotoSide') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : t('identityDocumentCamera.readingDocument') : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? status === 'SCANNING' ? t('identityDocumentCamera.readingDocument') : t('identityDocumentCamera.alignPhotoSide') : nextStep === 'SCAN_HOLOGRAM' ? t('identityDocumentCamera.alignHologram') : nextStep === 'SCAN_ID_BACK' ? status === 'SCANNING' ? t('identityDocumentCamera.readingDocument') : t('identityDocumentCamera.alignIDBackSide') : nextStep === 'COMPLETED' ? t('identityDocumentCamera.scanCompleted') : ''
1598
+ children: getStatusMessage(nextStep, status, detectedDocumentType, isBrightnessLow, isFrameBlurry, allElementsDetected, elementsOutsideScanArea, t)
2196
1599
  })]
2197
1600
  }), /*#__PURE__*/_jsx(View, {
2198
1601
  style: styles.leftZone
@@ -2221,393 +1624,33 @@ const IdentityDocumentCamera = ({
2221
1624
  loop: true,
2222
1625
  autoPlay: true
2223
1626
  }) : null
2224
- }), isDebugEnabled() && /*#__PURE__*/_jsx(SafeAreaView, {
2225
- style: {
2226
- position: 'absolute',
2227
- top: 0,
2228
- left: 0,
2229
- right: 0,
2230
- alignItems: 'center',
2231
- pointerEvents: 'none'
2232
- },
2233
- children: /*#__PURE__*/_jsxs(View, {
2234
- style: {
2235
- marginTop: 8,
2236
- marginHorizontal: 8,
2237
- backgroundColor: 'rgba(0, 0, 0, 0.9)',
2238
- padding: 8,
2239
- borderRadius: 6,
2240
- borderWidth: 1,
2241
- borderColor: 'rgba(255, 82, 82, 0.5)',
2242
- minWidth: 220,
2243
- maxWidth: '92%'
2244
- },
2245
- children: [/*#__PURE__*/_jsxs(View, {
2246
- style: {
2247
- flexDirection: 'row',
2248
- justifyContent: 'space-between',
2249
- marginBottom: 4
2250
- },
2251
- children: [/*#__PURE__*/_jsx(TextView, {
2252
- style: {
2253
- color: '#88D8B0',
2254
- fontSize: 10,
2255
- fontWeight: 'bold'
2256
- },
2257
- children: `Status: ${status}`
2258
- }), /*#__PURE__*/_jsx(TextView, {
2259
- style: {
2260
- color: '#FFEB3B',
2261
- fontSize: 10,
2262
- fontWeight: 'bold'
2263
- },
2264
- children: `Step: ${nextStep}`
2265
- })]
2266
- }), /*#__PURE__*/_jsxs(View, {
2267
- style: {
2268
- flexDirection: 'row',
2269
- justifyContent: 'space-between',
2270
- marginBottom: 4,
2271
- gap: 3
2272
- },
2273
- children: [/*#__PURE__*/_jsx(View, {
2274
- style: {
2275
- backgroundColor: isBrightnessLow ? '#f44336' : '#4CAF50',
2276
- paddingHorizontal: 6,
2277
- paddingVertical: 1,
2278
- borderRadius: 3
2279
- },
2280
- children: /*#__PURE__*/_jsx(TextView, {
2281
- style: {
2282
- color: '#FFFFFF',
2283
- fontSize: 8,
2284
- fontWeight: 'bold'
2285
- },
2286
- children: isBrightnessLow ? 'Low Light' : 'Good Light'
2287
- })
2288
- }), /*#__PURE__*/_jsx(View, {
2289
- style: {
2290
- backgroundColor: isFrameBlurry ? '#f44336' : '#4CAF50',
2291
- paddingHorizontal: 6,
2292
- paddingVertical: 1,
2293
- borderRadius: 3
2294
- },
2295
- children: /*#__PURE__*/_jsx(TextView, {
2296
- style: {
2297
- color: '#FFFFFF',
2298
- fontSize: 8,
2299
- fontWeight: 'bold'
2300
- },
2301
- children: isFrameBlurry ? 'Blurry' : 'Sharp'
2302
- })
2303
- })]
2304
- }), /*#__PURE__*/_jsx(TextView, {
2305
- style: {
2306
- color: elementsOutsideScanArea.length === 0 ? '#4CAF50' : '#FFA500',
2307
- fontSize: 8,
2308
- marginBottom: 3
2309
- },
2310
- children: elementsOutsideScanArea.length === 0 ? 'All elements in frame' : `Outside: ${elementsOutsideScanArea.join(', ')}`
2311
- }), /*#__PURE__*/_jsx(TextView, {
2312
- style: {
2313
- color: 'rgba(255, 255, 255, 0.7)',
2314
- fontSize: 8,
2315
- marginBottom: 3
2316
- },
2317
- children: `Face: ${documentPlaneBounds ? '✓' : '✗'}, MRZ: ${mrzBounds ? '✓' : '✗'}, Sig: ${signatureBounds ? '✓' : '✗'}`
2318
- }), nextStep === 'SCAN_HOLOGRAM' && hologramImageCount > 0 && /*#__PURE__*/_jsx(TextView, {
2319
- style: {
2320
- color: '#9C27B0',
2321
- fontSize: 8,
2322
- fontWeight: 'bold',
2323
- marginBottom: 3
2324
- },
2325
- children: `Hologram: ${hologramImageCount}/${HOLOGRAM_IMAGE_COUNT}`
2326
- }), /*#__PURE__*/_jsxs(View, {
2327
- style: {
2328
- flexDirection: 'row',
2329
- gap: 8,
2330
- paddingTop: 3,
2331
- borderTopWidth: 1,
2332
- borderTopColor: 'rgba(255, 255, 255, 0.2)',
2333
- marginBottom: 4
2334
- },
2335
- children: [/*#__PURE__*/_jsx(TextView, {
2336
- style: {
2337
- color: 'rgba(255, 255, 255, 0.7)',
2338
- fontSize: 8,
2339
- marginTop: 3
2340
- },
2341
- children: `Doc: ${detectedDocumentType}`
2342
- }), /*#__PURE__*/_jsx(TextView, {
2343
- style: {
2344
- color: 'rgba(255, 255, 255, 0.7)',
2345
- fontSize: 8,
2346
- marginTop: 3
2347
- },
2348
- children: `Flash: ${isTorchOn ? '🔦' : '○'}`
2349
- })]
2350
- }), /*#__PURE__*/_jsxs(View, {
2351
- style: {
2352
- flexDirection: 'row',
2353
- flexWrap: 'wrap',
2354
- gap: 4,
2355
- justifyContent: 'center'
2356
- },
2357
- children: [/*#__PURE__*/_jsxs(View, {
2358
- style: {
2359
- alignItems: 'center'
2360
- },
2361
- children: [currentFaceImage ? /*#__PURE__*/_jsx(Image, {
2362
- source: {
2363
- uri: `data:image/jpeg;base64,${currentFaceImage}`
2364
- },
2365
- style: {
2366
- width: 40,
2367
- height: 48,
2368
- borderRadius: 2,
2369
- borderWidth: 1,
2370
- borderColor: '#4CAF50'
2371
- }
2372
- }) : /*#__PURE__*/_jsx(View, {
2373
- style: {
2374
- width: 40,
2375
- height: 48,
2376
- borderRadius: 2,
2377
- borderWidth: 1,
2378
- borderColor: '#666',
2379
- backgroundColor: '#222',
2380
- justifyContent: 'center',
2381
- alignItems: 'center'
2382
- },
2383
- children: /*#__PURE__*/_jsx(TextView, {
2384
- style: {
2385
- color: '#666',
2386
- fontSize: 7
2387
- },
2388
- children: "\u2014"
2389
- })
2390
- }), /*#__PURE__*/_jsx(TextView, {
2391
- style: {
2392
- color: currentFaceImage ? '#4CAF50' : '#999',
2393
- fontSize: 7,
2394
- marginTop: 1,
2395
- fontWeight: 'bold'
2396
- },
2397
- children: `${currentFaceImage ? '✓' : '○'} Face`
2398
- })]
2399
- }), /*#__PURE__*/_jsxs(View, {
2400
- style: {
2401
- alignItems: 'center'
2402
- },
2403
- children: [currentSecondaryFaceImage ? /*#__PURE__*/_jsx(Image, {
2404
- source: {
2405
- uri: `data:image/jpeg;base64,${currentSecondaryFaceImage}`
2406
- },
2407
- style: {
2408
- width: 40,
2409
- height: 48,
2410
- borderRadius: 2,
2411
- borderWidth: 1,
2412
- borderColor: '#2196F3'
2413
- }
2414
- }) : /*#__PURE__*/_jsx(View, {
2415
- style: {
2416
- width: 40,
2417
- height: 48,
2418
- borderRadius: 2,
2419
- borderWidth: 1,
2420
- borderColor: '#666',
2421
- backgroundColor: '#222',
2422
- justifyContent: 'center',
2423
- alignItems: 'center'
2424
- },
2425
- children: /*#__PURE__*/_jsx(TextView, {
2426
- style: {
2427
- color: '#666',
2428
- fontSize: 7
2429
- },
2430
- children: "\u2014"
2431
- })
2432
- }), /*#__PURE__*/_jsx(TextView, {
2433
- style: {
2434
- color: currentSecondaryFaceImage ? '#2196F3' : '#999',
2435
- fontSize: 7,
2436
- marginTop: 1,
2437
- fontWeight: 'bold'
2438
- },
2439
- children: `${currentSecondaryFaceImage ? '✓' : '○'} 2nd`
2440
- })]
2441
- }), /*#__PURE__*/_jsxs(View, {
2442
- style: {
2443
- alignItems: 'center'
2444
- },
2445
- children: [currentHologramImage ? /*#__PURE__*/_jsx(Image, {
2446
- source: {
2447
- uri: `data:image/jpeg;base64,${currentHologramImage}`
2448
- },
2449
- style: {
2450
- width: 40,
2451
- height: 48,
2452
- borderRadius: 2,
2453
- borderWidth: 1,
2454
- borderColor: '#9C27B0'
2455
- }
2456
- }) : latestHologramFaceImage && hologramImageCount > 0 ? /*#__PURE__*/_jsxs(View, {
2457
- style: {
2458
- position: 'relative'
2459
- },
2460
- children: [/*#__PURE__*/_jsx(Image, {
2461
- source: {
2462
- uri: `data:image/jpeg;base64,${latestHologramFaceImage}`
2463
- },
2464
- style: {
2465
- width: 40,
2466
- height: 48,
2467
- borderRadius: 2,
2468
- borderWidth: 1,
2469
- borderColor: '#FFA500',
2470
- opacity: 0.7
2471
- }
2472
- }), /*#__PURE__*/_jsx(View, {
2473
- style: {
2474
- position: 'absolute',
2475
- bottom: 1,
2476
- left: 1,
2477
- right: 1,
2478
- backgroundColor: 'rgba(0,0,0,0.8)',
2479
- paddingVertical: 0,
2480
- borderRadius: 1
2481
- },
2482
- children: /*#__PURE__*/_jsxs(TextView, {
2483
- style: {
2484
- color: '#FFA500',
2485
- fontSize: 6,
2486
- textAlign: 'center',
2487
- fontWeight: 'bold'
2488
- },
2489
- children: [hologramImageCount, "/", HOLOGRAM_IMAGE_COUNT]
2490
- })
2491
- })]
2492
- }) : /*#__PURE__*/_jsx(View, {
2493
- style: {
2494
- width: 40,
2495
- height: 48,
2496
- borderRadius: 2,
2497
- borderWidth: 1,
2498
- borderColor: '#666',
2499
- backgroundColor: '#222',
2500
- justifyContent: 'center',
2501
- alignItems: 'center'
2502
- },
2503
- children: /*#__PURE__*/_jsx(TextView, {
2504
- style: {
2505
- color: '#666',
2506
- fontSize: 7
2507
- },
2508
- children: "\u2014"
2509
- })
2510
- }), /*#__PURE__*/_jsx(TextView, {
2511
- style: {
2512
- color: currentHologramImage ? '#9C27B0' : latestHologramFaceImage ? '#FFA500' : '#999',
2513
- fontSize: 7,
2514
- marginTop: 1,
2515
- fontWeight: 'bold'
2516
- },
2517
- children: `${currentHologramImage ? '✓' : latestHologramFaceImage ? '⏳' : '○'} Holo`
2518
- })]
2519
- }), /*#__PURE__*/_jsxs(View, {
2520
- style: {
2521
- alignItems: 'center'
2522
- },
2523
- children: [_currentHologramMaskImage ? /*#__PURE__*/_jsx(Image, {
2524
- source: {
2525
- uri: `data:image/jpeg;base64,${_currentHologramMaskImage}`
2526
- },
2527
- style: {
2528
- width: 40,
2529
- height: 48,
2530
- borderRadius: 2,
2531
- borderWidth: 1,
2532
- borderColor: '#FF6B6B'
2533
- }
2534
- }) : /*#__PURE__*/_jsx(View, {
2535
- style: {
2536
- width: 40,
2537
- height: 48,
2538
- borderRadius: 2,
2539
- borderWidth: 1,
2540
- borderColor: '#666',
2541
- backgroundColor: '#222',
2542
- justifyContent: 'center',
2543
- alignItems: 'center'
2544
- },
2545
- children: /*#__PURE__*/_jsx(TextView, {
2546
- style: {
2547
- color: '#666',
2548
- fontSize: 7
2549
- },
2550
- children: "\u2014"
2551
- })
2552
- }), /*#__PURE__*/_jsx(TextView, {
2553
- style: {
2554
- color: _currentHologramMaskImage ? '#FF6B6B' : '#999',
2555
- fontSize: 7,
2556
- marginTop: 1,
2557
- fontWeight: 'bold'
2558
- },
2559
- children: `${_currentHologramMaskImage ? '✓' : '○'} Mask`
2560
- })]
2561
- })]
2562
- })]
2563
- })
2564
- }), testMode && testModeData && /*#__PURE__*/_jsx(SafeAreaView, {
2565
- style: {
2566
- position: 'absolute',
2567
- bottom: 0,
2568
- left: 0,
2569
- right: 0,
2570
- maxHeight: '40%',
2571
- backgroundColor: 'rgba(0, 0, 0, 0.95)',
2572
- borderTopWidth: 2,
2573
- borderTopColor: '#FFA500'
2574
- },
2575
- children: /*#__PURE__*/_jsx(ScrollView, {
2576
- style: {
2577
- flex: 1
2578
- },
2579
- children: /*#__PURE__*/_jsxs(View, {
2580
- style: {
2581
- padding: 10
2582
- },
2583
- children: [/*#__PURE__*/_jsx(TextView, {
2584
- style: {
2585
- color: '#FFA500',
2586
- fontSize: 12,
2587
- fontWeight: 'bold',
2588
- marginBottom: 8,
2589
- textAlign: 'center'
2590
- },
2591
- children: "MRZ Text Read"
2592
- }), /*#__PURE__*/_jsx(TextView, {
2593
- style: {
2594
- color: '#FFFFFF',
2595
- fontSize: 9,
2596
- fontFamily: 'monospace',
2597
- lineHeight: 16
2598
- },
2599
- children: testModeData.mrzText.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n')
2600
- })]
2601
- })
2602
- })
1627
+ }), isDebugEnabled() && /*#__PURE__*/_jsx(DebugOverlay, {
1628
+ nextStep: nextStep,
1629
+ status: status,
1630
+ detectedDocumentType: detectedDocumentType,
1631
+ isBrightnessLow: isBrightnessLow,
1632
+ isFrameBlurry: isFrameBlurry,
1633
+ isTorchOn: isTorchOn,
1634
+ documentPlaneBounds: documentPlaneBounds,
1635
+ secondaryFaceBounds: secondaryFaceBounds,
1636
+ barcodeBounds: barcodeBounds,
1637
+ mrzBounds: mrzBounds,
1638
+ signatureBounds: signatureBounds,
1639
+ currentFaceImage: currentFaceImage,
1640
+ currentSecondaryFaceImage: currentSecondaryFaceImage,
1641
+ currentHologramImage: currentHologramImage,
1642
+ currentHologramMaskImage: _currentHologramMaskImage,
1643
+ latestHologramFaceImage: latestHologramFaceImage,
1644
+ hologramImageCount: hologramImageCount,
1645
+ allElementsDetected: allElementsDetected,
1646
+ elementsOutsideScanArea: elementsOutsideScanArea
1647
+ }), testMode && testModeData && /*#__PURE__*/_jsx(TestModePanel, {
1648
+ mrzText: testModeData.mrzText
2603
1649
  })]
2604
1650
  })]
2605
1651
  });
2606
1652
  };
2607
1653
  const styles = StyleSheet.create({
2608
- container: {
2609
- flex: 1
2610
- },
2611
1654
  permissionContainer: {
2612
1655
  flex: 1,
2613
1656
  justifyContent: 'center',
@@ -2704,50 +1747,6 @@ const styles = StyleSheet.create({
2704
1747
  gap: 10,
2705
1748
  justifyContent: 'flex-start'
2706
1749
  },
2707
- debugImagesRow: {
2708
- display: 'flex',
2709
- flexDirection: 'row',
2710
- gap: 10,
2711
- justifyContent: 'center',
2712
- flexWrap: 'wrap'
2713
- },
2714
- cardDetectionRow: {
2715
- display: 'flex',
2716
- flexDirection: 'row',
2717
- justifyContent: 'center',
2718
- marginTop: 5
2719
- },
2720
- imageContainer: {
2721
- display: 'flex',
2722
- flexDirection: 'column',
2723
- alignItems: 'center'
2724
- },
2725
- imageContainerText: {
2726
- color: 'white',
2727
- fontSize: 8,
2728
- textAlign: 'center',
2729
- fontWeight: 'bold',
2730
- marginTop: 2
2731
- },
2732
- faceImage: {
2733
- width: 60,
2734
- height: 80,
2735
- borderRadius: 4,
2736
- borderWidth: 1,
2737
- borderColor: 'white'
2738
- },
2739
- cardDetectionImage: {
2740
- width: 160,
2741
- height: 120,
2742
- borderRadius: 8,
2743
- borderWidth: 2,
2744
- borderColor: '#FF9800'
2745
- },
2746
- cardDetectionContainer: {
2747
- display: 'flex',
2748
- flexDirection: 'column',
2749
- alignItems: 'center'
2750
- },
2751
1750
  guide: {
2752
1751
  flex: 1,
2753
1752
  display: 'flex',