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