@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
@@ -8,11 +8,9 @@ import {
8
8
  StatusBar,
9
9
  Vibration,
10
10
  Linking,
11
- Image,
12
11
  ActivityIndicator,
13
12
  PermissionsAndroid,
14
- Dimensions,
15
- ScrollView,
13
+ Animated,
16
14
  type NativeSyntheticEvent,
17
15
  type ViewStyle,
18
16
  } from 'react-native';
@@ -23,7 +21,6 @@ import {
23
21
  type Frame,
24
22
  } from './TrustchexCamera';
25
23
  import { NativeModules } from 'react-native';
26
- import type { MRZFields } from '../Types/mrzFields';
27
24
  import mrzUtils from '../Libs/mrz.utils';
28
25
  import { useKeepAwake } from '../Libs/native-keep-awake.utils';
29
26
  import { useIsFocused } from '@react-navigation/native';
@@ -35,99 +32,59 @@ import { SafeAreaView } from 'react-native-safe-area-context';
35
32
  import { speak, resetLastMessage } from '../Libs/tts.utils';
36
33
  import AppContext from '../Contexts/AppContext';
37
34
  import { useTheme } from '../Contexts/ThemeContext';
35
+ import DebugOverlay, { TestModePanel } from './DebugOverlay';
36
+ import {
37
+ getStatusMessage,
38
+ getFrameToScreenTransform,
39
+ transformBoundsToScreen,
40
+ getScanAreaBounds,
41
+ angleBetweenPoints,
42
+ detectDocumentType,
43
+ determineDocumentTypeToSet,
44
+ areMRZFieldsEqual,
45
+ hasRequiredMRZFields,
46
+ validateFacePosition,
47
+ } from './IdentityDocumentCamera.utils';
48
+ import {
49
+ handlePassportFlow,
50
+ handleIDFrontFlow,
51
+ handleIDBackFlow,
52
+ getNextStepAfterHologram,
53
+ } from './IdentityDocumentCamera.flows';
54
+ import {
55
+ HOLOGRAM_IMAGE_COUNT,
56
+ HOLOGRAM_DETECTION_THRESHOLD,
57
+ HOLOGRAM_DETECTION_RETRY_COUNT,
58
+ HOLOGRAM_CAPTURE_INTERVAL,
59
+ HOLOGRAM_MAX_FRAMES_WITHOUT_FACE,
60
+ MIN_BRIGHTNESS_THRESHOLD,
61
+ MAX_BRIGHTNESS_THRESHOLD,
62
+ FACE_EDGE_MARGIN_PERCENT,
63
+ MAX_CONSECUTIVE_QUALITY_FAILURES,
64
+ REQUIRED_CONSISTENT_MRZ_READS,
65
+ REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS,
66
+ SIGNATURE_TEXT_REGEX,
67
+ MRZ_BLOCK_PATTERN,
68
+ PASSPORT_MRZ_PATTERN,
69
+ MIN_CARD_FACE_SIZE_PERCENT,
70
+ } from './IdentityDocumentCamera.constants';
71
+ import type {
72
+ DocumentScannedData,
73
+ BlockText,
74
+ IdentityDocumentCameraProps,
75
+ Face,
76
+ Barcode,
77
+ CornerPointsType,
78
+ LinesData,
79
+ } from './IdentityDocumentCamera.types';
80
+
81
+ // Re-export types for backward compatibility
82
+ export type { DocumentScannedData, BlockText, IdentityDocumentCameraProps };
83
+ export type { PhotoOptions } from './IdentityDocumentCamera.types';
38
84
 
39
85
  const { OpenCVModule } = NativeModules;
40
86
 
41
- export type DocumentScannedData = {
42
- documentType: 'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN';
43
- image: string;
44
- faceImage?: string;
45
- secondaryFaceImage?: string;
46
- hologramImage?: string;
47
- barcodeValue?: string;
48
- mrzText?: string;
49
- mrzFields?: MRZFields;
50
- };
51
-
52
- export type BlockText = {
53
- blocks: BlocksData[];
54
- resultText: string;
55
- mrzOnlyText?: string; // MRZ-specific text from detected MRZ blocks only
56
- };
57
-
58
- type BlocksData = {
59
- blockFrame: FrameType;
60
- blockCornerPoints: CornerPointsType;
61
- lines: LinesData;
62
- blockLanguages: string[] | [];
63
- blockText: string;
64
- };
65
-
66
- type CornerPointsType = [{ x: number; y: number }];
67
-
68
- type FrameType = {
69
- boundingCenterX: number;
70
- boundingCenterY: number;
71
- height: number;
72
- width: number;
73
- x: number;
74
- y: number;
75
- };
76
-
77
- type LinesData = [
78
- lineCornerPoints: CornerPointsType,
79
- elements: ElementsData,
80
- lineFrame: FrameType,
81
- lineLanguages: string[] | [],
82
- lineText: string,
83
- ];
84
-
85
- type ElementsData = [
86
- elementCornerPoints: CornerPointsType,
87
- elementFrame: FrameType,
88
- elementText: string,
89
- ];
90
-
91
- export type PhotoOptions = {
92
- uri: string;
93
- orientation?:
94
- | 'landscapeRight'
95
- | 'portrait'
96
- | 'portraitUpsideDown'
97
- | 'landscapeLeft';
98
- };
99
-
100
- export interface IdentityDocumentCameraProps {
101
- onlyMRZScan: boolean;
102
- onIdentityDocumentScanned: (scannedData: DocumentScannedData) => void;
103
- testMode?: boolean;
104
- }
105
-
106
- interface Face {
107
- bounds: { x: number; y: number; width: number; height: number };
108
- rollAngle?: number;
109
- pitchAngle?: number;
110
- yawAngle?: number;
111
- leftEyeOpenProbability?: number;
112
- rightEyeOpenProbability?: number;
113
- smilingProbability?: number;
114
- }
115
-
116
- interface Barcode {
117
- rawValue: string;
118
- displayValue: string;
119
- format: number;
120
- boundingBox: { left: number; top: number; right: number; bottom: number };
121
- cornerPoints: Array<{ x: number; y: number }>;
122
- value?: string;
123
- }
124
-
125
- const HOLOGRAM_IMAGE_COUNT = 12;
126
- const HOLOGRAM_DETECTION_THRESHOLD = 1000; // Lowered for better sensitivity while still filtering noise
127
- const HOLOGRAM_DETECTION_RETRY_COUNT = 3; // More attempts but faster each (~1s per attempt)
128
- const SECOND_FACE_DETECTION_RETRY_COUNT = 10;
129
- const MIN_BRIGHTNESS_THRESHOLD = 45;
130
- const MAX_CONSECUTIVE_QUALITY_FAILURES = 30;
87
+ const AnimatedText = Animated.createAnimatedComponent(TextView);
131
88
 
132
89
  const IdentityDocumentCamera = ({
133
90
  onlyMRZScan,
@@ -149,7 +106,6 @@ const IdentityDocumentCamera = ({
149
106
  isTorchOnRef.current = val;
150
107
  _setIsTorchOn(val);
151
108
  }, []);
152
- const [_exposure, _setExposure] = useState(0);
153
109
  const isCameraInitialized = useRef(false);
154
110
  const [currentFaceImage, setCurrentFaceImage] = useState<string | undefined>(
155
111
  undefined
@@ -172,7 +128,7 @@ const IdentityDocumentCamera = ({
172
128
  const [nextStep, setNextStep] = useState<
173
129
  'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM' | 'COMPLETED'
174
130
  >('SCAN_ID_FRONT_OR_PASSPORT');
175
- const [completedStep, setCompletedStep] = useState<
131
+ const [_completedStep, setCompletedStep] = useState<
176
132
  'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM' | null
177
133
  >(null);
178
134
  const [detectedDocumentType, setDetectedDocumentType] = useState<
@@ -187,14 +143,12 @@ const IdentityDocumentCamera = ({
187
143
  const lastValidMRZText = useRef<string | null>(null);
188
144
  const lastValidMRZFields = useRef<any>(null);
189
145
  const validMRZConsecutiveCount = useRef(0);
190
- const REQUIRED_CONSISTENT_MRZ_READS = 2; // Require 2 consistent valid reads
191
146
 
192
147
  // Document type stability tracking - require consistent detections from good quality frames
193
148
  const lastDetectedDocType = useRef<
194
149
  'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN'
195
150
  >('UNKNOWN');
196
151
  const consistentDocTypeCount = useRef(0);
197
- const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3; // Require 3 consistent detections from quality frames
198
152
 
199
153
  // Frame quality tracking - persist across callbacks
200
154
  const lastFrameQuality = useRef({
@@ -206,127 +160,20 @@ const IdentityDocumentCamera = ({
206
160
  // Barcode caching - persist detected barcode across frames for reliability
207
161
  const cachedBarcode = useRef<Barcode | null>(null);
208
162
 
163
+ // Error message flash animation
164
+ const errorFlashAnim = useRef(new Animated.Value(1)).current;
165
+
209
166
  // Test mode tracking
210
167
  const [testModeData, setTestModeData] = useState<{
211
168
  mrzText: string;
212
169
  timestamp: number;
213
170
  } | null>(null);
214
171
 
215
- // Helper to compare MRZ field values (ignore raw text variations)
216
- const areMRZFieldsEqual = useCallback(
217
- (fields1: any, fields2: any): boolean => {
218
- if (!fields1 || !fields2) return false;
219
- // Compare critical fields that define document identity
220
- return (
221
- fields1.documentNumber === fields2.documentNumber &&
222
- fields1.birthDate === fields2.birthDate &&
223
- fields1.expirationDate === fields2.expirationDate &&
224
- fields1.firstName === fields2.firstName &&
225
- fields1.lastName === fields2.lastName &&
226
- fields1.issuingState === fields2.issuingState
227
- );
228
- },
229
- []
230
- );
231
-
232
- // Helper functions to reduce duplication
233
-
234
- /**
235
- * Check if all required MRZ fields are present
236
- */
237
- const hasRequiredMRZFields = useCallback(
238
- (fields: any): boolean =>
239
- !!fields?.firstName &&
240
- !!fields?.lastName &&
241
- !!fields?.documentNumber &&
242
- !!fields?.birthDate,
243
- []
244
- );
245
-
246
- /**
247
- * Log detailed MRZ information for debugging and verification
248
- */
249
- const logMRZDetails = useCallback(
250
- (
251
- stepName: string,
252
- fields: any,
253
- mrzText: string | null,
254
- consecutiveReads: number,
255
- isDebugMode: boolean
256
- ) => {
257
- if (isDebugMode) {
258
- debugLog(
259
- 'IdentityDocumentCamera',
260
- `[${stepName}] MRZ validated successfully (consistent reads: ${consecutiveReads})`
261
- );
262
- debugLog('IdentityDocumentCamera', `[${stepName}] Final MRZ Fields:`, {
263
- documentNumber: fields?.documentNumber,
264
- name: `${fields?.lastName} ${fields?.firstName}`,
265
- birthDate: fields?.birthDate,
266
- expirationDate: fields?.expirationDate,
267
- nationality: fields?.nationality || fields?.issuingState,
268
- sex: fields?.sex,
269
- personalId: fields?.optional1,
270
- });
271
- if (mrzText) {
272
- const mrzLines = mrzText
273
- .split('\n')
274
- .map((l) => l.replace(/\s/g, ''))
275
- .filter((l) => l.length >= 20 && /^[A-Z0-9<]+$/.test(l));
276
- debugLog(
277
- 'IdentityDocumentCamera',
278
- `[${stepName}] MRZ lines (${mrzLines.length}):`
279
- );
280
- mrzLines.forEach((line, idx) => {
281
- debugLog('IdentityDocumentCamera', ` ${idx + 1}: ${line}`);
282
- });
283
- }
284
- }
285
- },
286
- []
287
- );
288
-
289
- /**
290
- * Log MRZ validation failure details for debugging
291
- */
292
- const logMRZValidationFailure = useCallback(
293
- (
294
- stepName: string,
295
- hasRequiredFields: boolean,
296
- parsedData: any,
297
- retryCount: number,
298
- isDebugMode: boolean
299
- ) => {
300
- if (isDebugMode) {
301
- const debugInfo: any = {
302
- hasRequiredFields,
303
- isValid: parsedData?.valid,
304
- retryCount,
305
- };
306
-
307
- if (parsedData?.valid) {
308
- debugInfo.consistentReads = validMRZConsecutiveCount.current;
309
- debugInfo.requiredReads = REQUIRED_CONSISTENT_MRZ_READS;
310
- debugInfo.fieldsMatch = areMRZFieldsEqual(
311
- lastValidMRZFields.current,
312
- parsedData?.fields
313
- );
314
- }
315
-
316
- debugLog(
317
- 'IdentityDocumentCamera',
318
- `[${stepName}] MRZ detected but validation failed - retrying`,
319
- debugInfo
320
- );
321
- }
322
- },
323
- [areMRZFieldsEqual]
324
- );
325
-
326
172
  const lastHologramCaptureTime = useRef(0);
327
- const HOLOGRAM_CAPTURE_INTERVAL = 250; // ms between captures - allows flash to fully stabilize for accurate diff
328
173
  const hologramFramesWithoutFace = useRef(0); // Track consecutive frames without face during hologram collection
329
- const HOLOGRAM_MAX_FRAMES_WITHOUT_FACE = 30; // ~1 second at 30fps - safety timeout
174
+ const isHologramDetectionInProgress = useRef(false); // Prevent concurrent hologram detection calls
175
+ const isCompletionCallbackInvoked = useRef(false); // Prevent multiple callback invocations when COMPLETED
176
+ const isStepTransitionInProgress = useRef(false); // Prevent duplicate step transitions from rapid frames
330
177
 
331
178
  const faceDetectionErrorCount = useRef(0);
332
179
  const brightnessHistory = useRef<number[]>([]);
@@ -415,6 +262,8 @@ const IdentityDocumentCamera = ({
415
262
  useEffect(() => {
416
263
  if (isFocused && hasPermission && hasGuideShown) {
417
264
  setIsActive(true);
265
+ isCompletionCallbackInvoked.current = false; // Reset callback flag when starting new scan
266
+ isStepTransitionInProgress.current = false;
418
267
  } else {
419
268
  setIsActive(false);
420
269
  faceImages.current = [];
@@ -427,11 +276,14 @@ const IdentityDocumentCamera = ({
427
276
  lastValidMRZText.current = null;
428
277
  lastValidMRZFields.current = null;
429
278
  validMRZConsecutiveCount.current = 0;
430
- lastValidMRZText.current = null;
431
- lastValidMRZFields.current = null;
432
- validMRZConsecutiveCount.current = 0;
433
- cachedBarcode.current = null; // Clear cached barcode on new scan
279
+ cachedBarcode.current = null;
434
280
  lastVoiceGuidanceMessage.current = '';
281
+ isCompletionCallbackInvoked.current = false;
282
+ isStepTransitionInProgress.current = false;
283
+ // Clear all captured image states from previous scan
284
+ setCurrentFaceImage(undefined);
285
+ setCurrentHologramImage(undefined);
286
+ setCurrentSecondaryFaceImage(undefined);
435
287
  resetLastMessage();
436
288
  }
437
289
 
@@ -442,97 +294,37 @@ const IdentityDocumentCamera = ({
442
294
  setHologramImageCount(0);
443
295
  setLatestHologramFaceImage(undefined);
444
296
  lastVoiceGuidanceMessage.current = '';
297
+ isCompletionCallbackInvoked.current = false; // Reset callback flag on unmount
298
+ isStepTransitionInProgress.current = false;
299
+ // Clear all captured image states on unmount
300
+ setCurrentFaceImage(undefined);
301
+ setCurrentHologramImage(undefined);
302
+ setCurrentSecondaryFaceImage(undefined);
445
303
  resetLastMessage();
446
304
  };
447
305
  }, [isFocused, hasPermission, hasGuideShown]);
448
306
 
449
307
  useEffect(() => {
450
- if (hasGuideShown) {
451
- // Generate message - match UI display logic exactly for consistency
452
- let message = '';
453
-
454
- if (status === 'SCANNED') {
455
- message =
456
- completedStep === 'SCAN_ID_FRONT_OR_PASSPORT'
457
- ? detectedDocumentType === 'PASSPORT'
458
- ? t('identityDocumentCamera.passportScanned')
459
- : t('identityDocumentCamera.frontSideScanned')
460
- : completedStep === 'SCAN_ID_BACK'
461
- ? t('identityDocumentCamera.backSideScanned')
462
- : completedStep === 'SCAN_HOLOGRAM'
463
- ? t('identityDocumentCamera.hologramVerified')
464
- : t('identityDocumentCamera.scanCompleted');
465
- } else if (status === 'INCORRECT') {
466
- message =
467
- nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
468
- ? t('identityDocumentCamera.wrongSideFront')
469
- : nextStep === 'SCAN_ID_BACK'
470
- ? t('identityDocumentCamera.wrongSideBack')
471
- : nextStep === 'SCAN_HOLOGRAM'
472
- ? t('identityDocumentCamera.wrongSideFront')
473
- : t('identityDocumentCamera.alignPhotoSide');
474
- } else if (isBrightnessLow) {
475
- message = t('identityDocumentCamera.lowBrightness');
476
- } else if (isFrameBlurry) {
477
- message = t('identityDocumentCamera.avoidBlur');
478
- } else if (
479
- status === 'SCANNING' &&
480
- allElementsDetected &&
481
- elementsOutsideScanArea.length === 0
482
- ) {
483
- message =
484
- nextStep === 'SCAN_ID_BACK'
485
- ? t('identityDocumentCamera.idCardBackDetected')
486
- : detectedDocumentType === 'PASSPORT'
487
- ? t('identityDocumentCamera.passportDetected')
488
- : detectedDocumentType === 'ID_FRONT'
489
- ? t('identityDocumentCamera.idCardFrontDetected')
490
- : nextStep === 'SCAN_HOLOGRAM'
491
- ? t('identityDocumentCamera.alignHologram')
492
- : t('identityDocumentCamera.readingDocument');
493
- } else if (elementsOutsideScanArea.length > 0) {
494
- message = t('identityDocumentCamera.centerDocument');
495
- } else if (
496
- (status === 'SCANNING' || status === 'SEARCHING') &&
497
- !allElementsDetected
498
- ) {
499
- message =
500
- nextStep === 'SCAN_ID_BACK'
501
- ? t('identityDocumentCamera.alignIDBack')
502
- : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
503
- ? detectedDocumentType === 'PASSPORT'
504
- ? t('identityDocumentCamera.alignPassport')
505
- : detectedDocumentType === 'ID_FRONT'
506
- ? t('identityDocumentCamera.alignIDFront')
507
- : t('identityDocumentCamera.alignPhotoSide')
508
- : nextStep === 'SCAN_HOLOGRAM'
509
- ? t('identityDocumentCamera.alignHologram')
510
- : t('identityDocumentCamera.readingDocument');
511
- } else {
512
- message =
513
- nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
514
- ? status === 'SCANNING'
515
- ? t('identityDocumentCamera.readingDocument')
516
- : t('identityDocumentCamera.alignPhotoSide')
517
- : nextStep === 'SCAN_HOLOGRAM'
518
- ? t('identityDocumentCamera.alignHologram')
519
- : nextStep === 'SCAN_ID_BACK'
520
- ? status === 'SCANNING'
521
- ? t('identityDocumentCamera.readingDocument')
522
- : t('identityDocumentCamera.alignIDBackSide')
523
- : nextStep === 'COMPLETED'
524
- ? t('identityDocumentCamera.scanCompleted')
525
- : '';
526
- }
308
+ if (
309
+ !hasGuideShown ||
310
+ !appContext.currentWorkflowStep?.data?.voiceGuidanceActive
311
+ )
312
+ return;
527
313
 
528
- if (
529
- appContext.currentWorkflowStep?.data?.voiceGuidanceActive &&
530
- message &&
531
- message !== lastVoiceGuidanceMessage.current
532
- ) {
533
- lastVoiceGuidanceMessage.current = message;
534
- speak(message, true);
535
- }
314
+ const message = getStatusMessage(
315
+ nextStep,
316
+ status,
317
+ detectedDocumentType,
318
+ isBrightnessLow,
319
+ isFrameBlurry,
320
+ allElementsDetected,
321
+ elementsOutsideScanArea,
322
+ t
323
+ );
324
+
325
+ if (message && message !== lastVoiceGuidanceMessage.current) {
326
+ lastVoiceGuidanceMessage.current = message;
327
+ speak(message, true);
536
328
  }
537
329
  }, [
538
330
  appContext.currentWorkflowStep?.data?.voiceGuidanceActive,
@@ -541,8 +333,6 @@ const IdentityDocumentCamera = ({
541
333
  isFrameBlurry,
542
334
  nextStep,
543
335
  status,
544
- completedStep,
545
- currentFaceImage,
546
336
  detectedDocumentType,
547
337
  allElementsDetected,
548
338
  elementsOutsideScanArea,
@@ -567,6 +357,35 @@ const IdentityDocumentCamera = ({
567
357
  }
568
358
  }, [nextStep]);
569
359
 
360
+ // Reset completion callback flag when transitioning away from COMPLETED
361
+ useEffect(() => {
362
+ if (nextStep !== 'COMPLETED') {
363
+ isCompletionCallbackInvoked.current = false;
364
+ }
365
+ isStepTransitionInProgress.current = false;
366
+ }, [nextStep]);
367
+
368
+ // Error flash animation - flash red text when wrong side detected
369
+ useEffect(() => {
370
+ if (status === 'INCORRECT') {
371
+ errorFlashAnim.setValue(1);
372
+ Animated.loop(
373
+ Animated.sequence([
374
+ Animated.timing(errorFlashAnim, {
375
+ toValue: 0.3,
376
+ duration: 300,
377
+ useNativeDriver: false,
378
+ }),
379
+ Animated.timing(errorFlashAnim, {
380
+ toValue: 1,
381
+ duration: 300,
382
+ useNativeDriver: false,
383
+ }),
384
+ ])
385
+ ).start();
386
+ }
387
+ }, [status, errorFlashAnim]);
388
+
570
389
  // Native OpenCV: detect hologram from sequence of face images
571
390
  const detectHologramNative = useCallback(
572
391
  async (images: string[]): Promise<[string, string] | []> => {
@@ -594,17 +413,22 @@ const IdentityDocumentCamera = ({
594
413
  []
595
414
  );
596
415
 
597
- // Native OpenCV: compare two images for similarity
598
- const areImagesSimilarNative = async (
599
- image1: string,
600
- image2: string,
601
- threshold = 20000 // Relaxed threshold for better tolerance of lighting/angle variations
602
- ): Promise<boolean> => {
416
+ // Native OpenCV: compare face visual similarity (device-side validation before backend FaceNet)
417
+ const compareFaceVisualSimilarityNative = async (
418
+ faceImage1: string,
419
+ faceImage2: string
420
+ ): Promise<{ similarity: number } | null> => {
603
421
  try {
604
- if (!image1 || !image2) return false;
605
- return await OpenCVModule.areImagesSimilar(image1, image2, threshold);
422
+ if (!faceImage1 || !faceImage2) return null;
423
+ return await OpenCVModule.compareFaceVisualSimilarity(
424
+ faceImage1,
425
+ faceImage2
426
+ );
606
427
  } catch (error) {
607
- return false;
428
+ if (isDebugEnabled()) {
429
+ logError('[Face Visual Similarity] Comparison error:', error);
430
+ }
431
+ return null;
608
432
  }
609
433
  };
610
434
 
@@ -613,7 +437,8 @@ const IdentityDocumentCamera = ({
613
437
  facesToDetect: Face[],
614
438
  image: string,
615
439
  width: number,
616
- height: number
440
+ height: number,
441
+ widerRightPadding = false
617
442
  ): Promise<string[]> => {
618
443
  if (!facesToDetect.length || !image || width <= 0 || height <= 0) {
619
444
  return [];
@@ -629,7 +454,8 @@ const IdentityDocumentCamera = ({
629
454
  image,
630
455
  faceBounds,
631
456
  width,
632
- height
457
+ height,
458
+ widerRightPadding
633
459
  );
634
460
  return croppedFaces ?? [];
635
461
  } catch (error) {
@@ -638,6 +464,62 @@ const IdentityDocumentCamera = ({
638
464
  }
639
465
  };
640
466
 
467
+ // Check if face image has glare (brightness exceeds threshold)
468
+ const checkFaceGlare = async (faceImage: string): Promise<boolean> => {
469
+ try {
470
+ // Check entire face region for glare
471
+ const hasGlare = await OpenCVModule.isRectangularRegionBright(
472
+ faceImage,
473
+ 0,
474
+ 0,
475
+ 100, // Full face width
476
+ 100, // Full face height
477
+ MAX_BRIGHTNESS_THRESHOLD
478
+ );
479
+ return hasGlare;
480
+ } catch (error) {
481
+ return false; // Assume no glare on error
482
+ }
483
+ };
484
+
485
+ // Check if face is fully visible (not cropped at edges)
486
+ const isFaceFullyVisible = (
487
+ face: Face,
488
+ frameWidth: number,
489
+ frameHeight: number
490
+ ): boolean => {
491
+ const margin = FACE_EDGE_MARGIN_PERCENT;
492
+ const bounds = face.bounds;
493
+ return (
494
+ bounds.x >= frameWidth * margin &&
495
+ bounds.y >= frameHeight * margin &&
496
+ bounds.x + bounds.width <= frameWidth * (1 - margin) &&
497
+ bounds.y + bounds.height <= frameHeight * (1 - margin)
498
+ );
499
+ };
500
+
501
+ // Check if document image has glare
502
+ const checkDocumentGlare = async (
503
+ image: string,
504
+ width: number,
505
+ height: number
506
+ ): Promise<boolean> => {
507
+ try {
508
+ // Check center 80% region for glare (document area)
509
+ const hasGlare = await OpenCVModule.isRectangularRegionBright(
510
+ image,
511
+ Math.round(width * 0.1),
512
+ Math.round(height * 0.1),
513
+ Math.round(width * 0.8),
514
+ Math.round(height * 0.8),
515
+ MAX_BRIGHTNESS_THRESHOLD
516
+ );
517
+ return hasGlare;
518
+ } catch (error) {
519
+ return false;
520
+ }
521
+ };
522
+
641
523
  const setNextStepAndVibrate = useCallback(
642
524
  (
643
525
  nextStepType:
@@ -653,15 +535,23 @@ const IdentityDocumentCamera = ({
653
535
 
654
536
  // Turn flash on and reset hologram counters when entering SCAN_HOLOGRAM
655
537
  if (nextStepType === 'SCAN_HOLOGRAM' && fromStep !== 'SCAN_HOLOGRAM') {
656
- setIsTorchOn(true);
657
- // Reset hologram detection counters for fresh start
658
- hologramDetectionCurrentRetryCount.current = 0;
659
- secondaryFaceDetectionCurrentRetryCount.current = 0;
660
- hologramFramesWithoutFace.current = 0;
661
- faceImages.current = [];
662
- hologramImageCountRef.current = 0;
663
- setHologramImageCount(0);
664
- setLatestHologramFaceImage(undefined);
538
+ const isMidCollection =
539
+ faceImages.current.length > 0 &&
540
+ faceImages.current.length < HOLOGRAM_IMAGE_COUNT;
541
+
542
+ if (!isMidCollection) {
543
+ setIsTorchOn(true);
544
+ hologramDetectionCurrentRetryCount.current = 0;
545
+ secondaryFaceDetectionCurrentRetryCount.current = 0;
546
+ hologramFramesWithoutFace.current = 0;
547
+ faceImages.current = [];
548
+ hologramImageCountRef.current = 0;
549
+ setHologramImageCount(0);
550
+ setLatestHologramFaceImage(undefined);
551
+ // Clear previous hologram state to prevent premature completion
552
+ setCurrentHologramImage(undefined);
553
+ setCurrentHologramMaskImage(undefined);
554
+ }
665
555
  }
666
556
 
667
557
  // Clean up flash and hologram state when leaving SCAN_HOLOGRAM step
@@ -671,15 +561,11 @@ const IdentityDocumentCamera = ({
671
561
  hologramImageCountRef.current = 0;
672
562
  setHologramImageCount(0);
673
563
  setLatestHologramFaceImage(undefined);
674
- lastFacePosition.current = null; // Reset document plane reference
675
- cachedBarcode.current = null; // Clear cached barcode
676
- setDocumentPlaneBounds(null); // Clear visual overlay
677
- setSecondaryFaceBounds(null); // Clear secondary face overlay
678
- if (isDebugEnabled()) {
679
- console.log(
680
- '[Flash] Turning off flash and clearing hologram images when leaving step'
681
- );
682
- }
564
+ isHologramDetectionInProgress.current = false;
565
+ lastFacePosition.current = null;
566
+ cachedBarcode.current = null;
567
+ setDocumentPlaneBounds(null);
568
+ setSecondaryFaceBounds(null);
683
569
  }
684
570
 
685
571
  setNextStep(nextStepType);
@@ -687,7 +573,12 @@ const IdentityDocumentCamera = ({
687
573
 
688
574
  // Reset MRZ retry counter for each new step so retries start fresh
689
575
  mrzDetectionCurrentRetryCount.current = 0;
690
- lastValidMRZText.current = null;
576
+ // Only clear MRZ text when entering SCAN_ID_BACK (new MRZ expected).
577
+ // Preserve across SCAN_HOLOGRAM so passport completion has MRZ data.
578
+ if (nextStepType === 'SCAN_ID_BACK') {
579
+ lastValidMRZText.current = null;
580
+ lastValidMRZFields.current = null;
581
+ }
691
582
  validMRZConsecutiveCount.current = 0;
692
583
  cachedBarcode.current = null; // Clear cached barcode on step change
693
584
 
@@ -701,6 +592,50 @@ const IdentityDocumentCamera = ({
701
592
  [setIsTorchOn]
702
593
  );
703
594
 
595
+ const transitionStepWithCallback = useCallback(
596
+ (
597
+ nextStepType:
598
+ | 'SCAN_ID_FRONT_OR_PASSPORT'
599
+ | 'SCAN_ID_BACK'
600
+ | 'SCAN_HOLOGRAM'
601
+ | 'COMPLETED',
602
+ fromStep: 'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM',
603
+ scannedData: DocumentScannedData
604
+ ) => {
605
+ if (isStepTransitionInProgress.current) {
606
+ return;
607
+ }
608
+
609
+ isStepTransitionInProgress.current = true;
610
+
611
+ // Torch only needed during SCAN_HOLOGRAM - turn off for all other transitions
612
+ if (nextStepType !== 'SCAN_HOLOGRAM') {
613
+ setIsTorchOn(false);
614
+ }
615
+
616
+ setNextStepAndVibrate(nextStepType, fromStep);
617
+
618
+ // Prevent the COMPLETED handler from firing a duplicate callback with
619
+ // potentially wrong detectedDocumentType. This transition already sends
620
+ // the correct scannedData below.
621
+ if (nextStepType === 'COMPLETED') {
622
+ isCompletionCallbackInvoked.current = true;
623
+ }
624
+
625
+ // Only notify parent for step completions, not intermediate transitions.
626
+ // The COMPLETED handler constructs final data from accumulated state.
627
+ // For ID cards, front/back data must be sent incrementally since parent stores them separately.
628
+ const isIntermediatePassportStep =
629
+ scannedData.documentType === 'PASSPORT' && nextStepType !== 'COMPLETED';
630
+ if (!isIntermediatePassportStep) {
631
+ setTimeout(() => {
632
+ onIdentityDocumentScanned(scannedData);
633
+ }, 1000);
634
+ }
635
+ },
636
+ [onIdentityDocumentScanned, setNextStepAndVibrate, setIsTorchOn]
637
+ );
638
+
704
639
  const handleFaceAndText = useCallback(
705
640
  async (
706
641
  text: string,
@@ -712,97 +647,98 @@ const IdentityDocumentCamera = ({
712
647
  elementsOutside?: boolean,
713
648
  scannedText?: BlockText
714
649
  ) => {
715
- const detectDocumentType = (
716
- facesParam: Face[],
717
- ocrText: string,
718
- mrzFields?: MRZFields,
719
- frameWidthParam?: number,
720
- mrzTextParam?: string | null
721
- ) => {
722
- // Relaxed signature detection: matches signature/imza variants and OCR errors
723
- const hasSignatureMatch = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i.test(ocrText);
724
-
725
- if (isDebugEnabled()) {
726
- console.log(
727
- '[DocType] faces:',
728
- facesParam.length,
729
- 'mrzFields:',
730
- !!mrzFields,
731
- 'mrzText:',
732
- !!mrzTextParam,
733
- 'textLen:',
734
- ocrText?.length,
735
- 'hasSignature:',
736
- hasSignatureMatch
737
- );
738
- }
650
+ // Classify faces: Primary (>= 5% of frame) vs Secondary (< 5%)
651
+ const primaryFaces = faces.filter(
652
+ (face) =>
653
+ face.bounds.width >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT &&
654
+ face.bounds.height >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT
655
+ );
739
656
 
740
- // ID Back: no face + ID MRZ
741
- if (facesParam.length === 0 && mrzFields?.documentCode === 'I') {
742
- return 'ID_BACK';
743
- }
657
+ const secondaryFaces = faces.filter(
658
+ (face) =>
659
+ face.bounds.width < frameWidth * MIN_CARD_FACE_SIZE_PERCENT ||
660
+ face.bounds.height < frameWidth * MIN_CARD_FACE_SIZE_PERCENT
661
+ );
744
662
 
745
- // Passport: face + passport MRZ
746
- if (facesParam.length > 0 && mrzFields?.documentCode === 'P') {
747
- return 'PASSPORT';
748
- }
663
+ // All faces for processing
664
+ const allDetectedFaces = faces;
665
+
666
+ // Validate primary face meets ICAO standards (face height 70-80% of image, aspect ratio ~1:1.25)
667
+ let primaryFaceICAOCompliant = false;
668
+ if (primaryFaces.length > 0) {
669
+ const face = primaryFaces[0];
670
+ const faceHeightPercent = (face.bounds.height / frameHeight) * 100;
671
+ const aspectRatio = face.bounds.width / face.bounds.height;
672
+
673
+ // ICAO: face height 70-80% of image, width:height ratio between 0.75 and 0.85
674
+ primaryFaceICAOCompliant =
675
+ faceHeightPercent >= 70 &&
676
+ faceHeightPercent <= 80 &&
677
+ aspectRatio >= 0.75 &&
678
+ aspectRatio <= 0.85;
679
+ }
749
680
 
750
- // ID Front: face detected with signature text
751
- if (facesParam.length > 0 && ocrText?.length >= 5) {
752
- const hasSignature = hasSignatureMatch;
753
- // Only turn off torch during initial scan step, not during SCAN_HOLOGRAM
754
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
755
- setIsTorchOn(false);
756
- }
681
+ if (
682
+ isDebugEnabled() &&
683
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
684
+ faces.length > 0
685
+ ) {
686
+ debugLog('IdentityDocumentCamera', '[FACE DETECTION] All faces', {
687
+ totalFaces: faces.length,
688
+ primaryFacesCount: primaryFaces.length,
689
+ secondaryFacesCount: secondaryFaces.length,
690
+ frameWidth,
691
+ frameHeight,
692
+ minPrimarySize: frameWidth * MIN_CARD_FACE_SIZE_PERCENT,
693
+ primaryFaceICAOCompliant,
694
+ faceDetails: faces.map((f, i) => ({
695
+ index: i,
696
+ width: Math.round(f.bounds.width),
697
+ height: Math.round(f.bounds.height),
698
+ x: Math.round(f.bounds.x),
699
+ y: Math.round(f.bounds.y),
700
+ widthPercent:
701
+ ((f.bounds.width / frameWidth) * 100).toFixed(1) + '%',
702
+ heightPercent:
703
+ ((f.bounds.height / frameHeight) * 100).toFixed(1) + '%',
704
+ aspectRatio: (f.bounds.width / f.bounds.height).toFixed(2),
705
+ category:
706
+ f.bounds.width >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT &&
707
+ f.bounds.height >= frameWidth * MIN_CARD_FACE_SIZE_PERCENT
708
+ ? 'primary'
709
+ : 'secondary',
710
+ })),
711
+ });
712
+ }
757
713
 
758
- // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
759
- const cardSizedFaces = frameWidthParam
760
- ? facesParam.filter(
761
- (face) =>
762
- face.bounds.width >= frameWidthParam * 0.05 &&
763
- face.bounds.height >= frameWidthParam * 0.05
764
- )
765
- : facesParam;
714
+ // Get scan area bounds for face filtering
715
+ const { isInsideScan } = getScanAreaBounds(frameWidth, frameHeight);
716
+
717
+ // Filter to only faces inside scan area (for hologram, exclude passport secondary faces)
718
+ const facesInsideScanArea = primaryFaces.filter((face) =>
719
+ isInsideScan(
720
+ face.bounds.x,
721
+ face.bounds.y,
722
+ face.bounds.width,
723
+ face.bounds.height
724
+ )
725
+ );
766
726
 
767
- // CRITICAL: If passport MRZ pattern is detected but not parsed yet,
768
- // return UNKNOWN instead of ID_FRONT to avoid misclassifying passports
769
- // Passports always have MRZ visible on front starting with P<TUR or similar
770
- if (
771
- cardSizedFaces.length > 0 &&
772
- !mrzFields?.documentCode &&
773
- hasSignature
774
- ) {
775
- if (
776
- mrzTextParam &&
777
- mrzTextParam.length > 20 &&
778
- /P<[A-Z]{3}/.test(mrzTextParam)
779
- ) {
780
- // Passport MRZ pattern detected (P<TUR, P<USA, etc.) but not parsed
781
- // Could be passport with OCR errors - wait for proper parsing
782
- if (isDebugEnabled()) {
783
- console.log(
784
- '[DocType] Passport MRZ pattern (P<XXX) detected but not parsed - returning UNKNOWN to wait for proper classification'
785
- );
786
- }
787
- return 'UNKNOWN';
788
- }
789
- return 'ID_FRONT';
790
- }
791
- // Also ensure flash is off when scan is completed
792
- if (nextStep === 'COMPLETED' && isTorchOn) {
793
- setIsTorchOn(false);
727
+ if (
728
+ isDebugEnabled() &&
729
+ nextStep === 'SCAN_HOLOGRAM' &&
730
+ primaryFaces.length > facesInsideScanArea.length
731
+ ) {
732
+ debugLog(
733
+ 'IdentityDocumentCamera',
734
+ '[HOLOGRAM] Filtered out faces outside scan area',
735
+ {
736
+ totalFaces: primaryFaces.length,
737
+ facesInside: facesInsideScanArea.length,
738
+ filtered: primaryFaces.length - facesInsideScanArea.length,
794
739
  }
795
- }
796
-
797
- return 'UNKNOWN';
798
- };
799
-
800
- // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
801
- const cardSizedFaces = faces.filter(
802
- (face) =>
803
- face.bounds.width >= frameWidth * 0.05 &&
804
- face.bounds.height >= frameWidth * 0.05
805
- );
740
+ );
741
+ }
806
742
 
807
743
  // Cache barcode when detected, use cached value if current frame has no barcode
808
744
  // This handles inconsistent barcode detection across frames
@@ -830,27 +766,100 @@ const IdentityDocumentCamera = ({
830
766
  }
831
767
 
832
768
  if (nextStep === 'COMPLETED') {
769
+ // Prevent multiple callback invocations from repeated frames
770
+ if (isCompletionCallbackInvoked.current) {
771
+ return;
772
+ }
773
+ isCompletionCallbackInvoked.current = true;
774
+
833
775
  setStatus('SCANNED');
834
- return;
835
- }
776
+ // Construct scanned data from available state and invoke callback
777
+ // Use MRZ document code as ultimate authority for document type —
778
+ // detectedDocumentType may be wrong if locked from early noisy frames
779
+ const completedDocType =
780
+ lastValidMRZFields.current?.documentCode === 'P'
781
+ ? 'PASSPORT'
782
+ : (detectedDocumentType as
783
+ | 'ID_FRONT'
784
+ | 'ID_BACK'
785
+ | 'PASSPORT'
786
+ | 'UNKNOWN');
787
+ const scannedData: DocumentScannedData = {
788
+ documentType: completedDocType,
789
+ image: image ?? '',
790
+ faceImage: currentFaceImage,
791
+ secondaryFaceImage: currentSecondaryFaceImage,
792
+ hologramImage: currentHologramImage,
793
+ mrzText: lastValidMRZText.current ?? undefined,
794
+ mrzFields: lastValidMRZFields.current ?? undefined,
795
+ barcodeValue: barcodeToUse?.rawValue ?? undefined,
796
+ };
836
797
 
837
- if (elementsOutside) {
798
+ if (isDebugEnabled()) {
799
+ debugLog('IdentityDocumentCamera', '[COMPLETED] Final scanned data', {
800
+ documentType: completedDocType,
801
+ hasFaceImage: !!scannedData.faceImage,
802
+ hasSecondaryFace: !!scannedData.secondaryFaceImage,
803
+ secondaryFaceImageLength:
804
+ scannedData.secondaryFaceImage?.length || 0,
805
+ currentSecondaryFaceLength: currentSecondaryFaceImage?.length || 0,
806
+ hasHologramImage: !!scannedData.hologramImage,
807
+ hasMRZ: !!scannedData.mrzFields,
808
+ hasBarcode: !!scannedData.barcodeValue,
809
+ });
810
+ }
811
+
812
+ setTimeout(() => {
813
+ onIdentityDocumentScanned(scannedData);
814
+ }, 500);
838
815
  return;
839
816
  }
840
817
 
841
- if (nextStep === 'SCAN_ID_BACK' && cardSizedFaces.length > 0) {
842
- setStatus('INCORRECT');
818
+ // Skip elementsOutside check during SCAN_HOLOGRAM - allow document tilting for hologram capture
819
+ if (elementsOutside && nextStep !== 'SCAN_HOLOGRAM') {
843
820
  return;
844
821
  }
845
822
 
846
- // Only crop and lock face when ID_FRONT or PASSPORT is confirmed
823
+ // Parse MRZ early to use for document type detection
824
+ // Use JavaScript MRZ validation with corrections
825
+ // Prefer MRZ-only text if available (from detected MRZ blocks),
826
+ // otherwise fall back to all text (for backward compatibility)
827
+ const textForValidation = scannedText?.mrzOnlyText || text;
828
+ const mrzValidationResult =
829
+ mrzUtils.validateMRZWithCorrections(textForValidation);
830
+ const parsedMRZData = {
831
+ valid: mrzValidationResult.valid,
832
+ fields: mrzValidationResult.fields || null,
833
+ };
834
+ // Extract raw MRZ lines from text if validation succeeded
835
+ const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
836
+
837
+ // CRITICAL: Only detect document type during initial scan step
838
+ // For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
839
+ // interruptions when user tilts document (MRZ may not be visible)
840
+ // However, if MRZ code 'P' is detected, always use PASSPORT — the lock may
841
+ // be wrong (ID_FRONT locked before passport MRZ became readable)
842
+ const documentType =
843
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
844
+ ? detectDocumentType(
845
+ primaryFaces,
846
+ text,
847
+ parsedMRZData?.fields,
848
+ frameWidth,
849
+ mrzText
850
+ )
851
+ : parsedMRZData?.fields?.documentCode === 'P'
852
+ ? 'PASSPORT'
853
+ : detectedDocumentType;
854
+
855
+ // Crop faces once document type is confirmed or we're past the initial step
847
856
  const shouldCropFaces =
848
- detectedDocumentType === 'ID_FRONT' ||
849
- detectedDocumentType === 'PASSPORT' ||
857
+ documentType === 'ID_FRONT' ||
858
+ documentType === 'PASSPORT' ||
850
859
  nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
851
860
  const croppedFaces = shouldCropFaces
852
861
  ? await getFaceImages(
853
- cardSizedFaces,
862
+ primaryFaces,
854
863
  image ?? '',
855
864
  frameWidth,
856
865
  frameHeight
@@ -859,95 +868,41 @@ const IdentityDocumentCamera = ({
859
868
 
860
869
  // Validate document plane consistency across all captures
861
870
  let facePositionValid = true;
862
- if (cardSizedFaces.length > 0 && cardSizedFaces[0]) {
863
- const currentFaceBounds = cardSizedFaces[0].bounds;
871
+ if (primaryFaces.length > 0 && primaryFaces[0]) {
872
+ const currentFaceBounds = primaryFaces[0].bounds;
864
873
  if (lastFacePosition.current) {
865
- // Check if face position is within acceptable range
866
- // Use looser tolerance during hologram step since flash toggling causes position jitter
867
- const xDiff = Math.abs(
868
- currentFaceBounds.x - lastFacePosition.current.x
869
- );
870
- const yDiff = Math.abs(
871
- currentFaceBounds.y - lastFacePosition.current.y
872
- );
873
- const widthDiff = Math.abs(
874
- currentFaceBounds.width - lastFacePosition.current.width
875
- );
876
- const heightDiff = Math.abs(
877
- currentFaceBounds.height - lastFacePosition.current.height
878
- );
879
-
880
- const tolerance = nextStep === 'SCAN_HOLOGRAM' ? 0.5 : 0.2;
881
- const xTolerance = lastFacePosition.current.width * tolerance;
882
- const yTolerance = lastFacePosition.current.height * tolerance;
883
- const sizeTolerance = lastFacePosition.current.width * tolerance;
884
-
885
- facePositionValid =
886
- xDiff <= xTolerance &&
887
- yDiff <= yTolerance &&
888
- widthDiff <= sizeTolerance &&
889
- heightDiff <= sizeTolerance;
890
-
891
- if (!facePositionValid) {
892
- if (isDebugEnabled()) {
893
- console.log(
894
- `[DocPlane] Face position changed - document moved (xDiff=${xDiff.toFixed(1)}, yDiff=${yDiff.toFixed(1)}) - rejecting frame`
895
- );
896
- }
897
- }
898
-
899
- // Update reference position to follow gradual movement (sliding window)
900
- lastFacePosition.current = {
901
- x: currentFaceBounds.x,
902
- y: currentFaceBounds.y,
903
- width: currentFaceBounds.width,
904
- height: currentFaceBounds.height,
905
- };
906
- } else {
907
- // First capture - store reference position
908
- lastFacePosition.current = {
909
- x: currentFaceBounds.x,
910
- y: currentFaceBounds.y,
911
- width: currentFaceBounds.width,
912
- height: currentFaceBounds.height,
913
- };
914
- console.log(
915
- '[DocPlane] Stored reference face position for document plane validation'
874
+ facePositionValid = validateFacePosition(
875
+ currentFaceBounds,
876
+ lastFacePosition.current,
877
+ nextStep === 'SCAN_HOLOGRAM'
916
878
  );
917
879
  }
918
880
 
919
- // Update visual bounds for debug overlay
920
- // Transform face bounds from image coordinates to screen coordinates
921
- if (facePositionValid && frameDimensions) {
922
- const screen = Dimensions.get('window');
923
-
924
- // Camera uses FILL_CENTER: scale to fill screen while maintaining aspect ratio
925
- const frameAspect = frameDimensions.width / frameDimensions.height;
926
- const screenAspect = screen.width / screen.height;
927
-
928
- let scale: number;
929
- let offsetX = 0;
930
- let offsetY = 0;
931
-
932
- if (frameAspect > screenAspect) {
933
- // Frame is wider - scale by height, crop width
934
- scale = screen.height / frameDimensions.height;
935
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
936
- } else {
937
- // Frame is taller - scale by width, crop height
938
- scale = screen.width / frameDimensions.width;
939
- offsetY = (frameDimensions.height * scale - screen.height) / 2;
940
- }
881
+ // Update reference position (sliding window)
882
+ lastFacePosition.current = {
883
+ x: currentFaceBounds.x,
884
+ y: currentFaceBounds.y,
885
+ width: currentFaceBounds.width,
886
+ height: currentFaceBounds.height,
887
+ };
941
888
 
889
+ // Update debug overlay bounds
890
+ if (facePositionValid && frameDimensions) {
891
+ const { scale, offsetX, offsetY } = getFrameToScreenTransform(
892
+ frameDimensions.width,
893
+ frameDimensions.height
894
+ );
942
895
  const cropPadding = Math.max(
943
896
  currentFaceBounds.width * 0.15,
944
897
  currentFaceBounds.height * 0.15
945
898
  );
946
899
  setDocumentPlaneBounds({
947
- x: currentFaceBounds.x * scale - offsetX,
948
- y: currentFaceBounds.y * scale - offsetY,
949
- width: currentFaceBounds.width * scale,
950
- height: currentFaceBounds.height * scale,
900
+ ...transformBoundsToScreen(
901
+ currentFaceBounds,
902
+ scale,
903
+ offsetX,
904
+ offsetY
905
+ ),
951
906
  cropPadding: cropPadding * scale,
952
907
  });
953
908
  }
@@ -960,38 +915,40 @@ const IdentityDocumentCamera = ({
960
915
  shouldCropFaces &&
961
916
  croppedFaces.length > 0 &&
962
917
  croppedFaces[0] &&
963
- facePositionValid
918
+ facePositionValid &&
919
+ !currentFaceImage
964
920
  ) {
965
- if (!currentFaceImage) {
966
- // First face detection after doc type confirmed - lock it for all subsequent steps
967
- faceImageToUse = croppedFaces[0];
968
- setCurrentFaceImage(croppedFaces[0]);
921
+ // Validate face quality before accepting
922
+ const faceFullyVisible =
923
+ primaryFaces[0] &&
924
+ isFaceFullyVisible(primaryFaces[0], frameWidth, frameHeight);
925
+ const hasGlare = await checkFaceGlare(croppedFaces[0]);
926
+
927
+ if (!faceFullyVisible || hasGlare) {
928
+ // Reject face with glare or partially visible
969
929
  if (isDebugEnabled()) {
970
- console.log(
971
- '[DocPlane] Locked primary face from validated document plane (docType: ' +
972
- detectedDocumentType +
973
- ')'
974
- );
930
+ debugLog('IdentityDocumentCamera', '[FACE] Rejected', {
931
+ fullyVisible: faceFullyVisible,
932
+ hasGlare,
933
+ });
975
934
  }
935
+ // Continue scanning without locking this face
936
+ } else {
937
+ faceImageToUse = croppedFaces[0];
938
+ setCurrentFaceImage(croppedFaces[0]);
976
939
  }
977
940
  }
978
941
 
942
+ // Skip OCR text checks during SCAN_HOLOGRAM - flash and tilting make text unreadable
943
+ // but we only need face detection for hologram collection
979
944
  if (!text || text.length < 5 || !image) {
980
- setStatus('SEARCHING');
981
- return;
945
+ if (nextStep !== 'SCAN_HOLOGRAM') {
946
+ setStatus('SEARCHING');
947
+ return;
948
+ }
949
+ // During SCAN_HOLOGRAM, allow processing even if text is not readable
982
950
  }
983
951
 
984
- // Use JavaScript MRZ validation with corrections
985
- // Prefer MRZ-only text if available (from detected MRZ blocks),
986
- // otherwise fall back to all text (for backward compatibility)
987
- const textForValidation = scannedText?.mrzOnlyText || text;
988
- const mrzValidationResult =
989
- mrzUtils.validateMRZWithCorrections(textForValidation);
990
- const parsedMRZData = {
991
- valid: mrzValidationResult.valid,
992
- fields: mrzValidationResult.fields || null,
993
- };
994
-
995
952
  // Capture test mode data
996
953
  if (testMode && text && text.includes('<')) {
997
954
  const mrzOnlyText = scannedText?.mrzOnlyText || text;
@@ -1001,36 +958,12 @@ const IdentityDocumentCamera = ({
1001
958
  });
1002
959
  }
1003
960
 
1004
- // Log MRZ validation details for debugging
1005
- if (isDebugEnabled() && text && text.includes('<')) {
1006
- const mrzLines = text
1007
- .split('\n')
1008
- .filter((line) => line.includes('<') && line.length > 20);
1009
- if (mrzLines.length >= 2) {
1010
- console.log(
1011
- '[MRZ Debug] Raw OCR text lines:',
1012
- mrzLines.map((l) => `"${l}"`)
1013
- );
1014
- console.log('[MRZ Debug] Validation result:', {
1015
- valid: mrzValidationResult.valid,
1016
- format: mrzValidationResult.format,
1017
- documentCode: mrzValidationResult.fields?.documentCode,
1018
- documentNumber: mrzValidationResult.fields?.documentNumber,
1019
- optional1: mrzValidationResult.fields?.optional1,
1020
- error: mrzValidationResult.error,
1021
- });
1022
- }
1023
- }
1024
-
1025
- // Extract raw MRZ lines from text if validation succeeded
1026
- const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
1027
-
1028
- // MRZ stability check - require consistent valid reads to avoid OCR noise
1029
- // Compare parsed field values instead of raw text to handle OCR variations in filler characters
1030
- // Only proceed with MRZ if it's actually valid and has all required fields
961
+ // MRZ stability: require consistent valid reads across frames
962
+ // Skip during SCAN_HOLOGRAM - document type already locked
1031
963
  const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
1032
964
 
1033
965
  if (
966
+ nextStep !== 'SCAN_HOLOGRAM' &&
1034
967
  mrzText &&
1035
968
  parsedMRZData?.valid === true &&
1036
969
  parsedMRZData?.fields &&
@@ -1039,23 +972,14 @@ const IdentityDocumentCamera = ({
1039
972
  const currentFields = parsedMRZData.fields;
1040
973
 
1041
974
  if (areMRZFieldsEqual(lastValidMRZFields.current, currentFields)) {
1042
- // Same MRZ data detected again - increment counter
1043
975
  validMRZConsecutiveCount.current++;
1044
976
  } else {
1045
- // Different MRZ data - reset counter and store new data
1046
- if (isDebugEnabled()) {
1047
- console.log(
1048
- `[MRZ Stability] New MRZ detected, resetting counter (was ${validMRZConsecutiveCount.current}, doc: ${currentFields.documentNumber?.slice(-4)})`
1049
- );
1050
- }
1051
977
  lastValidMRZFields.current = currentFields;
1052
978
  lastValidMRZText.current = mrzText;
1053
979
  validMRZConsecutiveCount.current = 1;
1054
980
  }
1055
- } else {
1056
- // Invalid or no MRZ - don't reset completely, just skip this frame
1057
- // This allows temporary OCR noise without losing progress
1058
981
  }
982
+ // else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
1059
983
 
1060
984
  // Check if we have enough consistent valid reads
1061
985
  const mrzStableAndValid =
@@ -1063,276 +987,149 @@ const IdentityDocumentCamera = ({
1063
987
  parsedMRZData?.valid === true &&
1064
988
  areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
1065
989
 
1066
- // During SCAN_ID_BACK, handle MRZ/barcode directly without full document type detection
1067
- // This avoids the chicken-and-egg problem where detectDocumentType requires
1068
- // mrzFields.documentCode === 'I' but MRZ parsing may return different codes
990
+ // ============================================================================
991
+ // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
992
+ // ============================================================================
1069
993
  if (nextStep === 'SCAN_ID_BACK') {
1070
- // CRITICAL: Always check if wrong side is shown (front or passport when back is expected)
1071
- // ID_BACK should have NO faces and NO signature text
1072
- // Multiple indicators for robust detection:
1073
- const hasFaces = cardSizedFaces.length > 0;
1074
- const hasSignature = /signature|imza|İmza/i.test(text);
1075
- const hasPassportMRZ = parsedMRZData?.fields?.documentCode === 'P';
1076
- const hasPassportMRZPattern = mrzText && /P<[A-Z]{3}/.test(mrzText);
994
+ const handleIdBackStep = async () => {
995
+ // Guard: wrong side detection (front or passport when back is expected)
996
+ const hasFaces = primaryFaces.length > 0;
997
+ const hasSignature = /signature|imza|İmza/i.test(text);
998
+ const hasPassportMRZ = parsedMRZData?.fields?.documentCode === 'P';
999
+ const hasPassportMRZPattern =
1000
+ mrzText && PASSPORT_MRZ_PATTERN.test(mrzText);
1077
1001
 
1078
- if (
1079
- hasFaces ||
1080
- hasSignature ||
1081
- hasPassportMRZ ||
1082
- hasPassportMRZPattern
1083
- ) {
1084
- if (isDebugEnabled()) {
1085
- console.log(
1086
- `[ID_BACK Scan] Wrong side detected - faces: ${hasFaces}, signature: ${hasSignature}, passport MRZ: ${hasPassportMRZ}, passport pattern: ${hasPassportMRZPattern}`
1087
- );
1002
+ if (
1003
+ hasFaces ||
1004
+ hasSignature ||
1005
+ hasPassportMRZ ||
1006
+ hasPassportMRZPattern
1007
+ ) {
1008
+ setStatus('INCORRECT');
1009
+ return;
1088
1010
  }
1089
- setStatus('INCORRECT');
1090
- return;
1091
- }
1092
1011
 
1093
- // SAFETY CHECK: If we detect passport during ID_BACK scan via state, skip this step
1094
- // This shouldn't happen but protects against edge cases
1095
- if (detectedDocumentType === 'PASSPORT') {
1096
- if (isDebugEnabled()) {
1097
- console.log(
1098
- '[ID_BACK Scan] ERROR: Passport detected in ID_BACK step - skipping to COMPLETED'
1099
- );
1100
- }
1101
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
1102
- setTimeout(() => {
1103
- onIdentityDocumentScanned({
1012
+ // Safety: passport somehow reached ID_BACK step
1013
+ if (detectedDocumentType === 'PASSPORT') {
1014
+ transitionStepWithCallback('COMPLETED', 'SCAN_ID_BACK', {
1104
1015
  image,
1105
1016
  documentType: 'PASSPORT',
1106
1017
  mrzText: mrzText ?? undefined,
1107
1018
  mrzFields: parsedMRZData?.fields,
1108
1019
  });
1109
- }, 1000);
1110
- return;
1111
- }
1020
+ return;
1021
+ }
1112
1022
 
1113
- const hasMRZ = !!mrzText;
1114
- const hasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
1115
- // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
1116
- // AND ensure all required fields are present
1117
- const mrzAccepted =
1118
- parsedMRZData?.valid === true &&
1119
- hasRequiredFields &&
1120
- mrzStableAndValid;
1121
-
1122
- // For Turkish ID cards, barcode should match MRZ optional1 (serial number)
1123
- // But some cards have encoding differences, so be lenient
1124
- const barcodeMatchesMRZ =
1125
- barcodeToUse?.rawValue?.trim() ===
1126
- parsedMRZData?.fields?.optional1?.trim();
1127
-
1128
- // If barcode doesn't match exactly, check if it contains the optional1 value
1129
- const barcodeContainsMRZ =
1130
- barcodeToUse?.rawValue?.includes(
1131
- parsedMRZData?.fields?.optional1?.trim() || ''
1132
- ) ||
1133
- parsedMRZData?.fields?.optional1?.includes(
1134
- barcodeToUse?.rawValue?.trim() || ''
1135
- );
1023
+ setElementsOutsideScanArea([]);
1136
1024
 
1137
- // Require barcode for all documents (no special card fallback)
1138
- // Accept if exact match OR if one contains the other (handles encoding differences)
1139
- const barcodeAccepted =
1140
- onlyMRZScan ||
1141
- barcodeMatchesMRZ ||
1142
- (!!barcodeToUse?.rawValue && barcodeContainsMRZ);
1025
+ const flowResult = handleIDBackFlow(
1026
+ mrzText,
1027
+ parsedMRZData?.fields,
1028
+ parsedMRZData?.valid === true,
1029
+ mrzStableAndValid,
1030
+ hasRequiredMRZFields(parsedMRZData?.fields),
1031
+ barcodeToUse?.rawValue,
1032
+ onlyMRZScan
1033
+ );
1143
1034
 
1144
- // CRITICAL: Require all document elements to be in frame before accepting
1145
- // For ID_BACK: MRZ + barcode (check directly, not via state to avoid timing issues)
1146
- const hasBarcode = !!barcodeToUse?.rawValue;
1147
- const allRequiredElementsInFrame =
1148
- (hasMRZ && hasBarcode) || onlyMRZScan;
1035
+ if (!flowResult.shouldProceed) {
1036
+ mrzDetectionCurrentRetryCount.current++;
1037
+ setStatus(mrzText ? 'SCANNING' : 'SEARCHING');
1038
+ return;
1039
+ }
1149
1040
 
1150
- // Don't block based on bounds - just ensure elements are present
1151
- setElementsOutsideScanArea([]);
1041
+ // Check for glare on ID back before accepting
1042
+ const hasGlare = await checkDocumentGlare(
1043
+ image,
1044
+ frameWidth,
1045
+ frameHeight
1046
+ );
1047
+ if (hasGlare) {
1048
+ if (isDebugEnabled()) {
1049
+ debugLog(
1050
+ 'IdentityDocumentCamera',
1051
+ '[ID_BACK] Rejected - glare detected'
1052
+ );
1053
+ }
1054
+ setStatus('SCANNING');
1055
+ return;
1056
+ }
1152
1057
 
1153
- if (!allRequiredElementsInFrame && hasMRZ && mrzAccepted) {
1154
1058
  if (isDebugEnabled()) {
1155
- console.log(
1156
- '[ID_BACK Scan] MRZ valid but waiting for all elements in frame (MRZ + barcode)'
1157
- );
1059
+ debugLog('IdentityDocumentCamera', '[ID_BACK] MRZ validated', {
1060
+ documentNumber: parsedMRZData?.fields?.documentNumber,
1061
+ reads: validMRZConsecutiveCount.current,
1062
+ });
1158
1063
  }
1159
- setStatus('SCANNING');
1160
- return;
1161
- }
1162
-
1163
- if (
1164
- hasMRZ &&
1165
- mrzAccepted &&
1166
- barcodeAccepted &&
1167
- allRequiredElementsInFrame
1168
- ) {
1169
- logMRZDetails(
1170
- 'ID_BACK Scan',
1171
- parsedMRZData?.fields,
1172
- mrzText,
1173
- validMRZConsecutiveCount.current,
1174
- isDebugEnabled()
1175
- );
1176
- const scannedData: DocumentScannedData = {
1064
+ setDetectedDocumentType('ID_BACK');
1065
+ setStatus('SCANNED');
1066
+ transitionStepWithCallback('COMPLETED', 'SCAN_ID_BACK', {
1177
1067
  image,
1178
1068
  documentType: 'ID_BACK',
1179
1069
  mrzText: mrzText ?? undefined,
1180
1070
  mrzFields: parsedMRZData?.fields,
1181
1071
  barcodeValue: barcodeToUse?.rawValue ?? undefined,
1182
- };
1183
- setDetectedDocumentType('ID_BACK');
1184
- setStatus('SCANNED');
1185
- setIsTorchOn(false);
1186
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
1187
- setTimeout(() => {
1188
- onIdentityDocumentScanned(scannedData);
1189
- }, 1000);
1190
- } else {
1191
- if (hasMRZ && !mrzAccepted) {
1192
- logMRZValidationFailure(
1193
- 'ID_BACK Scan',
1194
- hasRequiredFields,
1195
- parsedMRZData,
1196
- mrzDetectionCurrentRetryCount.current,
1197
- isDebugEnabled()
1198
- );
1199
- } else if (hasMRZ && mrzAccepted && !barcodeAccepted) {
1200
- if (isDebugEnabled()) {
1201
- console.log(
1202
- '[ID_BACK Scan] MRZ valid but barcode check failed - retrying',
1203
- {
1204
- onlyMRZScan,
1205
- hasBarcodeValue: !!barcodeToUse?.rawValue,
1206
- barcodeMatchesMRZ,
1207
- barcodeContainsMRZ,
1208
- mrzOptional1: parsedMRZData?.fields?.optional1,
1209
- barcodeValue: barcodeToUse?.rawValue,
1210
- barcodeValueTrimmed: barcodeToUse?.rawValue?.trim(),
1211
- optional1Trimmed: parsedMRZData?.fields?.optional1?.trim(),
1212
- barcodeSource:
1213
- barcodeToUse === cachedBarcode.current
1214
- ? 'cached'
1215
- : 'current',
1216
- }
1217
- );
1218
- }
1219
- }
1220
- mrzDetectionCurrentRetryCount.current++;
1221
- setStatus(hasMRZ ? 'SCANNING' : 'SEARCHING');
1222
- }
1072
+ });
1073
+ };
1074
+
1075
+ handleIdBackStep();
1223
1076
  return;
1224
1077
  }
1225
1078
 
1226
- const documentType = detectDocumentType(
1227
- cardSizedFaces,
1228
- text,
1229
- parsedMRZData?.fields,
1230
- frameWidth,
1231
- mrzText
1232
- );
1079
+ // Turn off torch when ID_FRONT detected during initial scan
1080
+ if (
1081
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
1082
+ (documentType === 'ID_FRONT' || documentType === 'PASSPORT') &&
1083
+ isTorchOnRef.current
1084
+ ) {
1085
+ setIsTorchOn(false);
1086
+ }
1233
1087
 
1234
- // Update detected document type only during initial scan step
1235
- // CRITICAL: Only set document type from non-blurry, stable frames
1236
- // Once set to PASSPORT or definitively identified, preserve it to avoid incorrect flow transitions
1088
+ // Lock document type from stable quality frames during initial scan.
1089
+ // Also allow correcting ID_FRONT PASSPORT when passport MRZ appears later.
1090
+ // Passport MRZ may not be visible in early frames (while signature is),
1091
+ // causing premature ID_FRONT lock that must be correctable.
1237
1092
  if (
1238
1093
  nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
1239
- detectedDocumentType === 'UNKNOWN'
1094
+ (detectedDocumentType === 'UNKNOWN' ||
1095
+ detectedDocumentType === 'ID_FRONT')
1240
1096
  ) {
1241
- // Determine the document type to set based on current frame analysis
1242
- let docTypeToSet: 'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN' =
1243
- documentType;
1244
-
1245
- if (documentType === 'PASSPORT') {
1246
- // Passport detected definitively - candidate for locking in
1247
- docTypeToSet = 'PASSPORT';
1248
- } else if (
1249
- documentType === 'UNKNOWN' &&
1250
- cardSizedFaces.length > 0 &&
1251
- parsedMRZData?.fields?.documentCode === 'P'
1252
- ) {
1253
- // Early passport detection: face detected + passport MRZ code (even if not fully valid yet)
1254
- docTypeToSet = 'PASSPORT';
1255
- } else if (documentType === 'ID_FRONT') {
1256
- // Check if this is actually a passport based on MRZ code
1257
- // Passports can be misdetected as ID_FRONT when signature-like text is visible
1258
- if (parsedMRZData?.fields?.documentCode === 'P') {
1259
- if (isDebugEnabled()) {
1260
- console.log(
1261
- '[DocType] Correcting misdetection: ID_FRONT → PASSPORT (MRZ code P)'
1262
- );
1263
- }
1264
- docTypeToSet = 'PASSPORT';
1265
- } else if (parsedMRZData?.fields?.documentCode === 'I') {
1266
- // MRZ confirms it's an ID card
1267
- docTypeToSet = 'ID_FRONT';
1268
- } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
1269
- // Passport MRZ pattern visible but not parsed yet - wait for proper classification
1270
- if (isDebugEnabled()) {
1271
- console.log(
1272
- '[DocType] Passport MRZ pattern detected but not parsed - waiting instead of locking as ID_FRONT'
1273
- );
1274
- }
1275
- docTypeToSet = 'UNKNOWN';
1276
- } else {
1277
- // No MRZ code and no passport pattern - safe to classify as ID_FRONT
1278
- // ID cards typically don't have MRZ on front (only on back)
1279
- docTypeToSet = 'ID_FRONT';
1280
- }
1281
- } else {
1282
- docTypeToSet = 'UNKNOWN';
1283
- }
1097
+ const docTypeToSet = determineDocumentTypeToSet(
1098
+ documentType,
1099
+ primaryFaces,
1100
+ parsedMRZData?.fields,
1101
+ mrzText
1102
+ );
1284
1103
 
1285
- // Only update document type state if:
1286
- // 1. Frame quality is acceptable (not blurry, good brightness)
1287
- // 2. Document type has been detected consistently for multiple frames
1104
+ // If already locked as ID_FRONT, only allow correction to PASSPORT
1288
1105
  if (
1289
- lastFrameQuality.current.hasAcceptableQuality &&
1290
- docTypeToSet !== 'UNKNOWN'
1106
+ detectedDocumentType === 'ID_FRONT' &&
1107
+ docTypeToSet === 'PASSPORT'
1291
1108
  ) {
1292
- if (docTypeToSet === lastDetectedDocType.current) {
1293
- consistentDocTypeCount.current++;
1294
- if (isDebugEnabled()) {
1295
- console.log(
1296
- `[DocType Stability] Consistent detection: ${docTypeToSet} (${consistentDocTypeCount.current}/${REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS})`
1297
- );
1109
+ setDetectedDocumentType('PASSPORT');
1110
+ consistentDocTypeCount.current = 0;
1111
+ lastDetectedDocType.current = 'PASSPORT';
1112
+ } else if (detectedDocumentType === 'UNKNOWN') {
1113
+ if (
1114
+ lastFrameQuality.current.hasAcceptableQuality &&
1115
+ docTypeToSet !== 'UNKNOWN'
1116
+ ) {
1117
+ if (docTypeToSet === lastDetectedDocType.current) {
1118
+ consistentDocTypeCount.current++;
1119
+ } else {
1120
+ lastDetectedDocType.current = docTypeToSet;
1121
+ consistentDocTypeCount.current = 1;
1298
1122
  }
1299
1123
 
1300
1124
  if (
1301
1125
  consistentDocTypeCount.current >=
1302
1126
  REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS
1303
1127
  ) {
1304
- // Stable detection confirmed - lock it in
1305
- if (isDebugEnabled()) {
1306
- console.log(
1307
- `[DocType] Locked document type: ${docTypeToSet} (stable for ${consistentDocTypeCount.current} quality frames)`
1308
- );
1309
- }
1310
1128
  setDetectedDocumentType(docTypeToSet);
1311
1129
  }
1312
- } else {
1313
- // Document type changed - reset counter
1314
- if (isDebugEnabled()) {
1315
- console.log(
1316
- `[DocType Stability] Type changed: ${lastDetectedDocType.current} → ${docTypeToSet}, resetting counter`
1317
- );
1318
- }
1319
- lastDetectedDocType.current = docTypeToSet;
1320
- consistentDocTypeCount.current = 1;
1321
- }
1322
- } else if (
1323
- !lastFrameQuality.current.hasAcceptableQuality &&
1324
- docTypeToSet !== 'UNKNOWN'
1325
- ) {
1326
- // Poor quality frame - don't use for document type detection
1327
- if (isDebugEnabled()) {
1328
- console.log(
1329
- `[DocType Stability] Skipping poor quality frame (blurry: ${lastFrameQuality.current.isBlurry}, brightness: ${lastFrameQuality.current.brightness.toFixed(0)})`
1330
- );
1331
1130
  }
1332
1131
  }
1333
1132
  }
1334
- // Document type is now locked and won't be changed after initial scan
1335
- // Hologram and subsequent steps use the preserved detectedDocumentType state
1336
1133
 
1337
1134
  const scannedData: DocumentScannedData = {
1338
1135
  image,
@@ -1341,10 +1138,10 @@ const IdentityDocumentCamera = ({
1341
1138
  mrzFields: parsedMRZData?.fields,
1342
1139
  };
1343
1140
 
1344
- const isWrongSide =
1345
- nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK';
1346
-
1347
- if (isWrongSide) {
1141
+ if (
1142
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
1143
+ documentType === 'ID_BACK'
1144
+ ) {
1348
1145
  setStatus('INCORRECT');
1349
1146
  return;
1350
1147
  }
@@ -1354,238 +1151,366 @@ const IdentityDocumentCamera = ({
1354
1151
  scannedData.faceImage = faceImageToUse;
1355
1152
  }
1356
1153
 
1154
+ const continueScanning = (incrementMrzRetry: boolean = false) => {
1155
+ if (incrementMrzRetry) {
1156
+ mrzDetectionCurrentRetryCount.current++;
1157
+ }
1158
+ setStatus('SCANNING');
1159
+ };
1160
+
1357
1161
  if (!onlyMRZScan) {
1358
- // Hologram detection during SCAN_HOLOGRAM step - ALWAYS use first/leftmost face ONLY
1162
+ // Hologram detection during SCAN_HOLOGRAM step
1359
1163
  if (nextStep === 'SCAN_HOLOGRAM') {
1360
- if (isDebugEnabled()) {
1361
- console.log(
1362
- `[Hologram] In SCAN_HOLOGRAM step - currentHologramImage: ${!!currentHologramImage}, collected: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT}`
1363
- );
1364
- }
1365
-
1366
- // Always crop to the same face region across all hologram frames so
1367
- // OpenCV receives consistently-sized images for comparison.
1368
- // Use current face bounds if available, otherwise fall back to last known position.
1164
+ // CRITICAL: Only use faces inside scan area for hologram
1165
+ // This prevents passport secondary faces (outside frame or on right side) from being used
1369
1166
  const hologramFaceBounds =
1370
- cardSizedFaces.length > 0 && cardSizedFaces[0]
1371
- ? cardSizedFaces[0].bounds
1167
+ facesInsideScanArea.length > 0 && facesInsideScanArea[0]
1168
+ ? facesInsideScanArea[0].bounds
1372
1169
  : lastFacePosition.current;
1373
1170
  let primaryFaceOnly: string | undefined;
1374
1171
  if (hologramFaceBounds && image) {
1375
- const hologramCropped = await getFaceImages(
1376
- [{ bounds: hologramFaceBounds, rollAngle: 0, yawAngle: 0 }],
1377
- image,
1378
- frameWidth,
1379
- frameHeight
1380
- );
1381
- primaryFaceOnly = hologramCropped[0] ?? faceImageToUse;
1172
+ // Verify face is fully visible before using for hologram
1173
+ const faceFullyVisible = hologramFaceBounds
1174
+ ? hologramFaceBounds.x >= frameWidth * FACE_EDGE_MARGIN_PERCENT &&
1175
+ hologramFaceBounds.y >=
1176
+ frameHeight * FACE_EDGE_MARGIN_PERCENT &&
1177
+ hologramFaceBounds.x + hologramFaceBounds.width <=
1178
+ frameWidth * (1 - FACE_EDGE_MARGIN_PERCENT) &&
1179
+ hologramFaceBounds.y + hologramFaceBounds.height <=
1180
+ frameHeight * (1 - FACE_EDGE_MARGIN_PERCENT)
1181
+ : false;
1182
+
1183
+ if (faceFullyVisible) {
1184
+ const hologramCropped = await getFaceImages(
1185
+ [{ bounds: hologramFaceBounds, rollAngle: 0, yawAngle: 0 }],
1186
+ image,
1187
+ frameWidth,
1188
+ frameHeight,
1189
+ true // widerRightPadding for hologram detection
1190
+ );
1191
+ primaryFaceOnly = hologramCropped[0] ?? faceImageToUse;
1192
+ } else {
1193
+ // Face not fully visible - skip this frame
1194
+ if (isDebugEnabled()) {
1195
+ debugLog(
1196
+ 'IdentityDocumentCamera',
1197
+ '[HOLOGRAM] Face not fully visible',
1198
+ {
1199
+ faceX: hologramFaceBounds.x,
1200
+ faceY: hologramFaceBounds.y,
1201
+ faceWidth: hologramFaceBounds.width,
1202
+ faceHeight: hologramFaceBounds.height,
1203
+ frameWidth,
1204
+ frameHeight,
1205
+ }
1206
+ );
1207
+ }
1208
+ primaryFaceOnly = undefined;
1209
+ }
1382
1210
  } else {
1383
1211
  primaryFaceOnly = faceImageToUse;
1384
1212
  }
1385
1213
 
1386
1214
  // Skip face position validation for hologram — flash toggling causes position jitter
1387
1215
  if (primaryFaceOnly) {
1388
- // Reset consecutive no-face counter since we have a face
1389
1216
  hologramFramesWithoutFace.current = 0;
1390
1217
 
1391
1218
  if (currentHologramImage) {
1392
1219
  scannedData.hologramImage = currentHologramImage;
1393
1220
  } else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
1394
- // Add timing control to space out captures for better variation
1395
- const now = Date.now();
1221
+ // Space out captures for better variation
1396
1222
  const timeSinceLastCapture =
1397
- now - lastHologramCaptureTime.current;
1223
+ Date.now() - lastHologramCaptureTime.current;
1398
1224
 
1399
1225
  if (
1400
1226
  faceImages.current.length === 0 ||
1401
1227
  timeSinceLastCapture >= HOLOGRAM_CAPTURE_INTERVAL
1402
1228
  ) {
1403
- // Collect PRIMARY face image ONLY (always index 0) from same document plane
1404
- faceImages.current.push(primaryFaceOnly);
1405
- lastHologramCaptureTime.current = now;
1406
- hologramImageCountRef.current = faceImages.current.length;
1407
-
1408
- // Only update state at first and last frame to minimize re-renders
1409
- if (
1410
- faceImages.current.length === 1 ||
1411
- faceImages.current.length === HOLOGRAM_IMAGE_COUNT
1412
- ) {
1413
- setHologramImageCount(faceImages.current.length);
1414
- setLatestHologramFaceImage(primaryFaceOnly);
1229
+ // Keep torch on during hologram scan for consistent lighting
1230
+ if (!isTorchOnRef.current) {
1231
+ setIsTorchOn(true);
1415
1232
  }
1416
1233
 
1417
- if (isDebugEnabled()) {
1418
- console.log(
1419
- `[Hologram] Collected ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} face images`
1234
+ // Check hologram face for glare
1235
+ const hasGlare = await checkFaceGlare(primaryFaceOnly);
1236
+ if (!hasGlare) {
1237
+ faceImages.current.push(primaryFaceOnly);
1238
+ lastHologramCaptureTime.current = Date.now();
1239
+ hologramImageCountRef.current = faceImages.current.length;
1240
+
1241
+ if (isDebugEnabled()) {
1242
+ debugLog(
1243
+ 'IdentityDocumentCamera',
1244
+ '[HOLOGRAM] Frame captured',
1245
+ {
1246
+ frameIndex: faceImages.current.length - 1,
1247
+ totalFrames: faceImages.current.length,
1248
+ }
1249
+ );
1250
+ }
1251
+ } else if (isDebugEnabled()) {
1252
+ debugLog(
1253
+ 'IdentityDocumentCamera',
1254
+ '[HOLOGRAM] Rejected glare frame',
1255
+ {
1256
+ collectedCount: faceImages.current.length,
1257
+ }
1420
1258
  );
1421
1259
  }
1422
1260
 
1423
- // Keep flash on during processing - will turn off when step changes
1261
+ if (faceImages.current.length > 0) {
1262
+ // Update UI state at first and last frame only
1263
+ if (
1264
+ faceImages.current.length === 1 ||
1265
+ faceImages.current.length === HOLOGRAM_IMAGE_COUNT
1266
+ ) {
1267
+ setHologramImageCount(faceImages.current.length);
1268
+ setLatestHologramFaceImage(primaryFaceOnly);
1269
+ }
1270
+ }
1424
1271
  }
1425
1272
  } else if (faceImages.current.length >= HOLOGRAM_IMAGE_COUNT) {
1426
- // Process collected full document images
1427
- if (isDebugEnabled()) {
1428
- console.log(
1429
- `[Hologram] Processing ${faceImages.current.length} full document images`
1430
- );
1273
+ // Guard: skip if already processing or max retries exhausted
1274
+ if (isHologramDetectionInProgress.current) return;
1275
+ if (
1276
+ hologramDetectionCurrentRetryCount.current >=
1277
+ HOLOGRAM_DETECTION_RETRY_COUNT
1278
+ ) {
1279
+ faceImages.current = [];
1280
+ hologramImageCountRef.current = 0;
1281
+ setHologramImageCount(0);
1282
+ setLatestHologramFaceImage(undefined);
1283
+ return;
1431
1284
  }
1285
+
1286
+ // Process collected face images for hologram detection
1287
+ isHologramDetectionInProgress.current = true;
1432
1288
  try {
1433
1289
  const [hologramMask, hologram] = await detectHologramNative(
1434
1290
  faceImages.current
1435
1291
  );
1292
+ if (isDebugEnabled()) {
1293
+ debugLog(
1294
+ 'IdentityDocumentCamera',
1295
+ '[Hologram] Native result',
1296
+ {
1297
+ hasHologram: !!hologram,
1298
+ hasHologramMask: !!hologramMask,
1299
+ hologramLength: hologram?.length || 0,
1300
+ }
1301
+ );
1302
+ }
1436
1303
  if (hologram) {
1437
1304
  setCurrentHologramMaskImage(hologramMask);
1438
1305
  scannedData.hologramImage = hologram;
1439
1306
  setCurrentHologramImage(hologram);
1440
1307
  if (isDebugEnabled()) {
1441
- console.log('[Hologram] Detection successful');
1308
+ debugLog(
1309
+ 'IdentityDocumentCamera',
1310
+ '[Hologram] ✓ Saved hologram image'
1311
+ );
1442
1312
  }
1443
1313
  } else {
1444
1314
  if (isDebugEnabled()) {
1445
- console.log('[Hologram] No hologram detected');
1315
+ debugLog(
1316
+ 'IdentityDocumentCamera',
1317
+ '[Hologram] ✗ No hologram detected'
1318
+ );
1446
1319
  }
1447
1320
  }
1448
1321
  } catch (error) {
1449
1322
  console.error('[Hologram] Processing error:', error);
1450
1323
  } finally {
1451
- // Keep flash on - will turn off when step changes
1452
1324
  faceImages.current = [];
1453
1325
  hologramImageCountRef.current = 0;
1454
1326
  setHologramImageCount(0);
1455
1327
  setLatestHologramFaceImage(undefined);
1456
1328
  hologramDetectionCurrentRetryCount.current++;
1457
- if (isDebugEnabled()) {
1458
- console.log(
1459
- `[Hologram] Retry ${hologramDetectionCurrentRetryCount.current}/${HOLOGRAM_DETECTION_RETRY_COUNT}`
1460
- );
1461
- }
1329
+ isHologramDetectionInProgress.current = false;
1330
+ // Turn off torch after detection completes
1331
+ setIsTorchOn(false);
1462
1332
  }
1463
1333
  }
1464
1334
  } else {
1465
- // No face detected for hologram collection
1466
- // Track consecutive frames without face for safety timeout
1467
1335
  hologramFramesWithoutFace.current++;
1468
- if (isDebugEnabled()) {
1469
- console.log(
1470
- `[Hologram] No face detected - frame ${hologramFramesWithoutFace.current}/${HOLOGRAM_MAX_FRAMES_WITHOUT_FACE}`
1471
- );
1472
- }
1473
1336
  }
1474
1337
  } else if (currentHologramImage) {
1475
1338
  scannedData.hologramImage = currentHologramImage;
1476
- } else if (faceImages.current.length > 0) {
1477
- // Safety cleanup: not in hologram step but have images collected
1478
- faceImages.current = [];
1479
- hologramImageCountRef.current = 0;
1480
- setHologramImageCount(0);
1481
- setLatestHologramFaceImage(undefined);
1482
- if (isDebugEnabled()) {
1483
- console.log(
1484
- '[Hologram] Defensive cleanup - cleared images outside hologram step'
1339
+ }
1340
+
1341
+ // Secondary face capture (continuous during initial scan and hologram detection)
1342
+ // MLI (Multi Layer Image) is small secondary face on Turkish ID cards
1343
+ if (
1344
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ||
1345
+ nextStep === 'SCAN_HOLOGRAM'
1346
+ ) {
1347
+ if (isDebugEnabled() && allDetectedFaces.length > 1) {
1348
+ debugLog(
1349
+ 'IdentityDocumentCamera',
1350
+ '[MLI FACE] Entry conditions check',
1351
+ {
1352
+ hasCurrentSecondary: !!currentSecondaryFaceImage,
1353
+ hasPrimaryFace: !!scannedData.faceImage,
1354
+ totalFaces: allDetectedFaces.length,
1355
+ facePositionValid,
1356
+ willAttemptDetection:
1357
+ !currentSecondaryFaceImage &&
1358
+ !!scannedData.faceImage &&
1359
+ allDetectedFaces.length > 1 &&
1360
+ facePositionValid,
1361
+ }
1485
1362
  );
1486
1363
  }
1487
- }
1488
1364
 
1489
- // SKIP secondary face detection during SCAN_HOLOGRAM - only focus on primary face
1490
- // Secondary face was already captured during initial scan (SCAN_ID_FRONT_OR_PASSPORT)
1491
- // During hologram, we only collect hologram images from primary face
1492
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1493
- // Capture secondary face - must be similar to main face AND from same document plane
1494
1365
  if (currentSecondaryFaceImage) {
1495
1366
  scannedData.secondaryFaceImage = currentSecondaryFaceImage;
1496
1367
  } else if (
1497
- !!scannedData.faceImage &&
1498
- croppedFaces.length > 1 &&
1499
- !!croppedFaces[1] &&
1368
+ scannedData.faceImage &&
1369
+ allDetectedFaces.length > 1 &&
1500
1370
  facePositionValid
1501
1371
  ) {
1502
- // Always validate similarity to ensure it's the same person on the same document
1503
- const isSimilar = await areImagesSimilarNative(
1504
- scannedData.faceImage,
1505
- croppedFaces[1],
1506
- 15000 // Default threshold from main branch
1372
+ // Detect MLI face (smaller than main face, to the right)
1373
+ const primaryFace = primaryFaces[0];
1374
+
1375
+ if (isDebugEnabled() && primaryFace) {
1376
+ debugLog(
1377
+ 'IdentityDocumentCamera',
1378
+ '[MLI FACE] Starting detection',
1379
+ {
1380
+ totalFaces: allDetectedFaces.length,
1381
+ primaryFaceSize: `${Math.round(primaryFace.bounds.width)}x${Math.round(primaryFace.bounds.height)}`,
1382
+ primaryFacePos: `x:${Math.round(primaryFace.bounds.x)} y:${Math.round(primaryFace.bounds.y)}`,
1383
+ otherFaces: allDetectedFaces
1384
+ .filter((f) => f !== primaryFace)
1385
+ .map((f) => ({
1386
+ size: `${Math.round(f.bounds.width)}x${Math.round(f.bounds.height)}`,
1387
+ pos: `x:${Math.round(f.bounds.x)} y:${Math.round(f.bounds.y)}`,
1388
+ isRight: f.bounds.x > primaryFace.bounds.x,
1389
+ verticalRange: `${Math.round(primaryFace.bounds.y - primaryFace.bounds.height * 0.3)}-${Math.round(primaryFace.bounds.y + primaryFace.bounds.height * 1.3)}`,
1390
+ inVerticalRange:
1391
+ f.bounds.y >=
1392
+ primaryFace.bounds.y -
1393
+ primaryFace.bounds.height * 0.3 &&
1394
+ f.bounds.y <=
1395
+ primaryFace.bounds.y +
1396
+ primaryFace.bounds.height * 1.3,
1397
+ })),
1398
+ }
1399
+ );
1400
+ }
1401
+
1402
+ const potentialMLIFaces = allDetectedFaces.filter(
1403
+ (f) =>
1404
+ f !== primaryFace &&
1405
+ f.bounds.x > primaryFace.bounds.x && // MLI is to the right of main face
1406
+ f.bounds.y >=
1407
+ primaryFace.bounds.y - primaryFace.bounds.height * 0.3 && // Same vertical level (±30%)
1408
+ f.bounds.y <=
1409
+ primaryFace.bounds.y + primaryFace.bounds.height * 1.3
1507
1410
  );
1508
1411
 
1509
- if (isSimilar) {
1510
- scannedData.secondaryFaceImage = croppedFaces[1];
1511
- setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
1412
+ if (isDebugEnabled()) {
1413
+ debugLog(
1414
+ 'IdentityDocumentCamera',
1415
+ '[MLI FACE] Position filter result',
1416
+ {
1417
+ potentialMLICount: potentialMLIFaces.length,
1418
+ }
1419
+ );
1420
+ }
1421
+
1422
+ if (potentialMLIFaces.length > 0 && potentialMLIFaces[0]) {
1423
+ const secondaryFace = potentialMLIFaces[0];
1512
1424
 
1513
- // Update secondary face bounds for debug overlay
1514
- if (faces.length > 1 && faces[1] && frameDimensions) {
1515
- const screen = Dimensions.get('window');
1516
- const frameAspect =
1517
- frameDimensions.width / frameDimensions.height;
1518
- const screenAspect = screen.width / screen.height;
1425
+ // Crop MLI face separately
1426
+ const mliFaceCropped = await getFaceImages(
1427
+ [primaryFace, secondaryFace],
1428
+ image ?? '',
1429
+ frameWidth,
1430
+ frameHeight
1431
+ );
1432
+
1433
+ if (mliFaceCropped.length > 1 && mliFaceCropped[1]) {
1434
+ // Visual similarity check with lenient threshold
1435
+ const visualResult = await compareFaceVisualSimilarityNative(
1436
+ mliFaceCropped[0],
1437
+ mliFaceCropped[1]
1438
+ );
1439
+
1440
+ const similarityScore = visualResult?.similarity || 0;
1441
+ const isLikelySamePerson = similarityScore >= 0.2; // Very lenient: 20%
1442
+
1443
+ if (isDebugEnabled()) {
1444
+ debugLog(
1445
+ 'IdentityDocumentCamera',
1446
+ '[MLI FACE] Similarity check',
1447
+ {
1448
+ visualSimilarity: similarityScore.toFixed(3),
1449
+ isLikelySamePerson,
1450
+ threshold: 0.2,
1451
+ }
1452
+ );
1453
+ }
1519
1454
 
1520
- let scale: number;
1521
- let offsetX = 0;
1522
- let offsetY = 0;
1455
+ if (isLikelySamePerson) {
1456
+ // Skip glare check for MLI - it's a small printed photo with different reflective properties
1457
+ // Backend will validate quality
1458
+ scannedData.secondaryFaceImage = mliFaceCropped[1];
1459
+ setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
1523
1460
 
1524
- if (frameAspect > screenAspect) {
1525
- scale = screen.height / frameDimensions.height;
1526
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
1527
- } else {
1528
- scale = screen.width / frameDimensions.width;
1529
- offsetY =
1530
- (frameDimensions.height * scale - screen.height) / 2;
1531
- }
1461
+ if (isDebugEnabled()) {
1462
+ debugLog(
1463
+ 'IdentityDocumentCamera',
1464
+ '[MLI FACE] Captured successfully',
1465
+ {
1466
+ similarity: similarityScore.toFixed(3),
1467
+ imageLength: mliFaceCropped[1]?.length || 0,
1468
+ imageSet: !!scannedData.secondaryFaceImage,
1469
+ stateSet: !!currentSecondaryFaceImage,
1470
+ primarySize: `${Math.round(primaryFace.bounds.width)}x${Math.round(primaryFace.bounds.height)}`,
1471
+ secondarySize: `${Math.round(secondaryFace.bounds.width)}x${Math.round(secondaryFace.bounds.height)}`,
1472
+ }
1473
+ );
1474
+ }
1532
1475
 
1533
- const scanLeft = (screen.width * 0.05 + offsetX) / scale;
1534
- const scanTop = (screen.height * 0.36 + offsetY) / scale;
1535
- const scanRight = (screen.width * 0.95 + offsetX) / scale;
1536
- const scanBottom = (screen.height * 0.64 + offsetY) / scale;
1537
- const isInsideScan = (
1538
- x: number,
1539
- y: number,
1540
- w: number,
1541
- h: number
1542
- ) =>
1543
- x >= scanLeft &&
1544
- y >= scanTop &&
1545
- x + w <= scanRight &&
1546
- y + h <= scanBottom;
1547
-
1548
- const secondaryBounds = faces[1].bounds;
1549
- if (
1550
- isInsideScan(
1551
- secondaryBounds.x,
1552
- secondaryBounds.y,
1553
- secondaryBounds.width,
1554
- secondaryBounds.height
1555
- )
1556
- ) {
1557
- setSecondaryFaceBounds({
1558
- x: secondaryBounds.x * scale - offsetX,
1559
- y: secondaryBounds.y * scale - offsetY,
1560
- width: secondaryBounds.width * scale,
1561
- height: secondaryBounds.height * scale,
1562
- });
1563
- } else {
1564
- setSecondaryFaceBounds(null);
1476
+ if (frameDimensions) {
1477
+ const { scale, offsetX, offsetY } =
1478
+ getFrameToScreenTransform(
1479
+ frameDimensions.width,
1480
+ frameDimensions.height
1481
+ );
1482
+ const secondaryBounds = secondaryFace.bounds;
1483
+ setSecondaryFaceBounds(
1484
+ transformBoundsToScreen(
1485
+ secondaryBounds,
1486
+ scale,
1487
+ offsetX,
1488
+ offsetY
1489
+ )
1490
+ );
1491
+ }
1492
+ } else if (isDebugEnabled()) {
1493
+ debugLog(
1494
+ 'IdentityDocumentCamera',
1495
+ '[MLI FACE] Rejected - similarity too low',
1496
+ { similarity: similarityScore.toFixed(3), threshold: 0.2 }
1497
+ );
1565
1498
  }
1566
1499
  }
1567
-
1568
- if (isDebugEnabled()) {
1569
- console.log(
1570
- '[SecondaryFace] ✓ Captured and validated as similar to main face (same document plane)'
1571
- );
1572
- }
1573
- } else {
1574
- secondaryFaceDetectionCurrentRetryCount.current++;
1575
- if (isDebugEnabled()) {
1576
- console.log(
1577
- '[SecondaryFace] ✗ Rejected - not similar enough to main face'
1578
- );
1579
- }
1580
1500
  }
1581
1501
  } else {
1582
1502
  secondaryFaceDetectionCurrentRetryCount.current++;
1583
- if (!facePositionValid && croppedFaces.length > 1) {
1584
- if (isDebugEnabled()) {
1585
- console.log(
1586
- '[SecondaryFace] Rejected - document plane changed'
1587
- );
1588
- }
1503
+ if (isDebugEnabled() && allDetectedFaces.length > 1) {
1504
+ debugLog(
1505
+ 'IdentityDocumentCamera',
1506
+ '[MLI FACE] Conditions not met',
1507
+ {
1508
+ hasPrimaryFace: !!scannedData.faceImage,
1509
+ primaryFacesCount: primaryFaces.length,
1510
+ allDetectedFaces: allDetectedFaces.length,
1511
+ facePositionValid,
1512
+ }
1513
+ );
1589
1514
  }
1590
1515
  }
1591
1516
  } else if (currentSecondaryFaceImage) {
@@ -1594,344 +1519,213 @@ const IdentityDocumentCamera = ({
1594
1519
  }
1595
1520
  }
1596
1521
 
1597
- // UNIFIED SCAN_HOLOGRAM completion - ONLY check primary face and hologram collection
1598
- // Document type is already definitively determined before entering this step
1522
+ // ============================================================================
1523
+ // SCAN_HOLOGRAM STEP
1524
+ // ============================================================================
1599
1525
  if (nextStep === 'SCAN_HOLOGRAM') {
1600
- // CRITICAL: Verify correct side is shown - hologram is ALWAYS on the front side with photo
1601
- // If wrong side detected, warn user immediately
1602
- const hasFaces = cardSizedFaces.length > 0;
1603
- const hasBarcode = !!barcode?.rawValue; // ID back has barcode, front doesn't
1526
+ // Guard: barcode visible = back side shown
1527
+ if (barcode?.rawValue) {
1528
+ setStatus('INCORRECT');
1529
+ return;
1530
+ }
1604
1531
 
1605
- // For passport: back side has no photo and different text pattern
1606
- // For ID card: back side has no photo, has barcode
1607
- const isWrongSideForHologram = !hasFaces || hasBarcode;
1532
+ const isCollecting = faceImages.current.length > 0;
1533
+ const maxRetriesReached =
1534
+ hologramDetectionCurrentRetryCount.current >=
1535
+ HOLOGRAM_DETECTION_RETRY_COUNT;
1608
1536
 
1609
- if (isWrongSideForHologram) {
1610
- if (isDebugEnabled()) {
1611
- console.log(
1612
- `[SCAN_HOLOGRAM] Wrong side detected - no faces: ${!hasFaces}, has barcode: ${hasBarcode} - expecting front side with photo`
1613
- );
1537
+ // Wait for face if not yet started collecting
1538
+ if (!isCollecting && primaryFaces.length === 0) {
1539
+ hologramFramesWithoutFace.current++;
1540
+ if (
1541
+ hologramFramesWithoutFace.current >=
1542
+ HOLOGRAM_MAX_FRAMES_WITHOUT_FACE
1543
+ ) {
1544
+ setStatus('INCORRECT');
1545
+ return;
1614
1546
  }
1615
- setStatus('INCORRECT');
1547
+ continueScanning();
1616
1548
  return;
1617
1549
  }
1618
1550
 
1619
- // Safety timeout: if we can't detect face for too many consecutive frames, give up
1620
- const faceDetectionTimeout =
1621
- hologramFramesWithoutFace.current >= HOLOGRAM_MAX_FRAMES_WITHOUT_FACE;
1551
+ // Complete when hologram captured OR retries exhausted (not mid-collection)
1552
+ const stepComplete =
1553
+ (!!scannedData.hologramImage ||
1554
+ (maxRetriesReached && !isCollecting)) &&
1555
+ !!faceImageToUse; // Require face before completing
1556
+
1557
+ if (stepComplete) {
1558
+ // Ensure preserved MRZ data is included (current frame may not have
1559
+ // readable MRZ due to flash/tilting during hologram capture)
1560
+ if (!scannedData.mrzText && lastValidMRZText.current) {
1561
+ scannedData.mrzText = lastValidMRZText.current;
1562
+ }
1563
+ if (!scannedData.mrzFields && lastValidMRZFields.current) {
1564
+ scannedData.mrzFields = lastValidMRZFields.current;
1565
+ }
1622
1566
 
1623
- // Don't skip if actively collecting images
1624
- const isActivelyCollecting =
1625
- faceImages.current.length > 0 &&
1626
- faceImages.current.length < HOLOGRAM_IMAGE_COUNT;
1567
+ setStatus('SCANNED');
1627
1568
 
1628
- const hologramConditionMet =
1629
- !!scannedData.hologramImage ||
1630
- (hologramDetectionCurrentRetryCount.current >=
1631
- HOLOGRAM_DETECTION_RETRY_COUNT &&
1632
- !isActivelyCollecting) || // Don't skip if mid-collection
1633
- (faceDetectionTimeout && !isActivelyCollecting); // Don't timeout if mid-collection
1634
-
1635
- // During hologram scan, we ONLY care about hologram collection - no other checks
1636
- // Secondary face, MRZ, document type checks are all skipped
1637
- // Document type was already definitively determined in the initial scan phase
1638
-
1639
- // Log detailed state for debugging
1640
- if (isActivelyCollecting && isDebugEnabled()) {
1641
- console.log(
1642
- `[SCAN_HOLOGRAM] Actively collecting: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} - continuing collection`
1569
+ // Use scannedData.mrzFields which we just ensured has preserved MRZ
1570
+ const mrzDocCode = scannedData.mrzFields?.documentCode;
1571
+ const nextStepAfterHologram = getNextStepAfterHologram(
1572
+ detectedDocumentType,
1573
+ documentType,
1574
+ mrzDocCode
1643
1575
  );
1644
- }
1645
1576
 
1646
- if (hologramConditionMet) {
1647
- if (faceDetectionTimeout && isDebugEnabled()) {
1648
- console.log(
1649
- '[SCAN_HOLOGRAM] Face detection timeout - proceeding without hologram'
1650
- );
1651
- }
1652
- setStatus('SCANNED');
1653
- if (nextStep !== 'SCAN_HOLOGRAM') {
1654
- setIsTorchOn(false);
1655
- }
1656
- // Route based on PRESERVED detectedDocumentType state (set during initial scan)
1657
- // Also check current frame's documentType and MRZ code as fallback
1658
- // Passport has no back side - go directly to COMPLETED
1659
- const isPassport =
1660
- detectedDocumentType === 'PASSPORT' ||
1661
- documentType === 'PASSPORT' ||
1662
- parsedMRZData?.fields?.documentCode === 'P';
1663
- if (isDebugEnabled()) {
1664
- console.log('[SCAN_HOLOGRAM] Document type check:', {
1665
- detectedDocumentType,
1666
- documentType,
1667
- mrzCode: parsedMRZData?.fields?.documentCode,
1668
- isPassport,
1669
- });
1670
- }
1671
- if (isPassport) {
1672
- if (isDebugEnabled()) {
1673
- console.log(
1674
- '[SCAN_HOLOGRAM] Passport detected - completing scan (no back side)'
1675
- );
1676
- }
1677
- setNextStepAndVibrate('COMPLETED', 'SCAN_HOLOGRAM');
1678
- } else {
1679
- if (isDebugEnabled()) {
1680
- console.log(
1681
- '[SCAN_HOLOGRAM] ID card detected - proceeding to back scan'
1682
- );
1683
- }
1684
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_HOLOGRAM');
1685
- }
1686
- setTimeout(() => {
1687
- onIdentityDocumentScanned(scannedData);
1688
- }, 1000);
1577
+ transitionStepWithCallback(
1578
+ nextStepAfterHologram,
1579
+ 'SCAN_HOLOGRAM',
1580
+ scannedData
1581
+ );
1689
1582
  return;
1690
1583
  }
1691
- // Still collecting or conditions not met - stay in SCAN_HOLOGRAM
1692
- // Don't fall through to document type branching
1693
- setStatus('SCANNING');
1584
+
1585
+ continueScanning();
1694
1586
  return;
1695
1587
  }
1696
1588
 
1697
- if (documentType === 'ID_FRONT') {
1698
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1699
- // CRITICAL: Verify this is actually an ID card, not a passport misdetected as ID_FRONT
1700
- // Passports can show signature-like text and be temporarily classified as ID_FRONT
1701
- if (parsedMRZData?.fields?.documentCode === 'P') {
1589
+ // ============================================================================
1590
+ // INITIAL SCAN STEP - Detect document type and validate
1591
+ // ============================================================================
1592
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1593
+ // Determine which flow handler to use.
1594
+ // Current-frame passport detection always takes precedence over a locked
1595
+ // ID_FRONT — passport MRZ may not appear until later frames.
1596
+ const flowDocumentType =
1597
+ documentType === 'PASSPORT'
1598
+ ? 'PASSPORT'
1599
+ : detectedDocumentType !== 'UNKNOWN'
1600
+ ? detectedDocumentType
1601
+ : documentType;
1602
+
1603
+ const handlePassportInitialStep = async () => {
1604
+ const flowResult = handlePassportFlow(
1605
+ primaryFaces,
1606
+ mrzText,
1607
+ parsedMRZData?.fields,
1608
+ mrzStableAndValid,
1609
+ onlyMRZScan,
1610
+ hasRequiredMRZFields(parsedMRZData?.fields),
1611
+ !!faceImageToUse
1612
+ );
1613
+
1614
+ if (!flowResult.shouldProceed) {
1615
+ continueScanning(true);
1616
+ return;
1617
+ }
1618
+
1619
+ // Check for glare on passport before accepting
1620
+ const hasGlare = await checkDocumentGlare(
1621
+ image,
1622
+ frameWidth,
1623
+ frameHeight
1624
+ );
1625
+ if (hasGlare) {
1702
1626
  if (isDebugEnabled()) {
1703
- console.log(
1704
- '[ID_FRONT Scan] Detected as ID_FRONT but MRZ shows passport (code P) - waiting for passport branch'
1627
+ debugLog(
1628
+ 'IdentityDocumentCamera',
1629
+ '[PASSPORT] Rejected - glare detected'
1705
1630
  );
1706
1631
  }
1707
- setStatus('SCANNING');
1632
+ continueScanning(false);
1708
1633
  return;
1709
1634
  }
1710
1635
 
1711
- const hasFace = cardSizedFaces.length > 0;
1712
- const hasSignature = /signature|imza|İmza/i.test(text);
1713
- const retryThreshold = 60;
1714
- const allowFaceOnly =
1715
- mrzDetectionCurrentRetryCount.current > retryThreshold;
1716
- const allRequiredElementsInFrame =
1717
- hasFace && (hasSignature || allowFaceOnly);
1636
+ setDetectedDocumentType('PASSPORT');
1637
+ setStatus('SCANNED');
1718
1638
 
1719
- setElementsOutsideScanArea([]);
1639
+ const nextPassportStep =
1640
+ flowResult.nextAction === 'PROCEED_TO_COMPLETED'
1641
+ ? 'COMPLETED'
1642
+ : 'SCAN_HOLOGRAM';
1643
+ transitionStepWithCallback(
1644
+ nextPassportStep,
1645
+ 'SCAN_ID_FRONT_OR_PASSPORT',
1646
+ scannedData
1647
+ );
1648
+ };
1720
1649
 
1721
- if (!allRequiredElementsInFrame) {
1722
- console.log(
1723
- '[ID_FRONT Scan] Valid but waiting for all elements in frame (face + signature)'
1724
- );
1725
- mrzDetectionCurrentRetryCount.current++;
1726
- setStatus('SCANNING');
1650
+ const handleIdFrontInitialStep = async () => {
1651
+ const flowResult = handleIDFrontFlow(
1652
+ primaryFaces,
1653
+ text,
1654
+ mrzText,
1655
+ parsedMRZData?.fields,
1656
+ mrzDetectionCurrentRetryCount.current
1657
+ );
1658
+
1659
+ if (!flowResult.shouldProceed) {
1660
+ if (flowResult.nextAction === 'REJECT_AS_PASSPORT') {
1661
+ setDetectedDocumentType('UNKNOWN');
1662
+ consistentDocTypeCount.current = 0;
1663
+ lastDetectedDocType.current = 'UNKNOWN';
1664
+ }
1665
+ continueScanning(flowResult.nextAction !== 'REJECT_AS_PASSPORT');
1727
1666
  return;
1728
1667
  }
1729
1668
 
1730
- // CRITICAL: Final verification that this is definitively an ID card before proceeding
1731
- // Check if we have MRZ and if it indicates ID card (not passport)
1732
- if (parsedMRZData?.fields?.documentCode) {
1733
- if (parsedMRZData.fields.documentCode === 'I') {
1734
- if (isDebugEnabled()) {
1735
- console.log('[ID_FRONT Scan] MRZ confirms ID card (code I)');
1736
- }
1737
- } else if (parsedMRZData.fields.documentCode === 'P') {
1738
- if (isDebugEnabled()) {
1739
- console.log(
1740
- '[ID_FRONT Scan] MRZ shows passport (code P) - rejecting as ID_FRONT'
1741
- );
1742
- }
1743
- setStatus('SCANNING');
1744
- return;
1745
- }
1746
- } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
1747
- // No parsed MRZ BUT passport MRZ pattern visible (P<TUR, P<USA, etc.)
1748
- // This is likely a passport with OCR errors - wait for proper parsing
1669
+ // Check for glare on ID front before accepting
1670
+ const hasGlare = await checkDocumentGlare(
1671
+ image,
1672
+ frameWidth,
1673
+ frameHeight
1674
+ );
1675
+ if (hasGlare) {
1749
1676
  if (isDebugEnabled()) {
1750
- console.log(
1751
- '[ID_FRONT Scan] Passport MRZ pattern (P<XXX) visible but not parsed - waiting for passport classification'
1677
+ debugLog(
1678
+ 'IdentityDocumentCamera',
1679
+ '[ID_FRONT] Rejected - glare detected'
1752
1680
  );
1753
1681
  }
1754
- mrzDetectionCurrentRetryCount.current++;
1755
- setStatus('SCANNING');
1682
+ continueScanning(false);
1756
1683
  return;
1757
1684
  }
1758
- // No MRZ or no passport pattern - proceed as ID card
1759
- // ID cards typically don't have MRZ on front side (only on back)
1760
1685
 
1761
- // CRITICAL: Lock document type state to ID_FRONT before proceeding
1762
- // This ensures hologram completion knows it's an ID card (needs ID_BACK step)
1763
1686
  setDetectedDocumentType('ID_FRONT');
1764
1687
  setStatus('SCANNED');
1765
- setIsTorchOn(false);
1766
- if (onlyMRZScan) {
1767
- // Passport has no back side - go directly to COMPLETED
1768
- // At this point detectedDocumentType is definitively set
1769
- if (detectedDocumentType === 'PASSPORT') {
1770
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
1771
- } else {
1772
- setNextStepAndVibrate(
1773
- 'SCAN_ID_BACK',
1774
- 'SCAN_ID_FRONT_OR_PASSPORT'
1775
- );
1776
- }
1777
- setTimeout(() => {
1778
- onIdentityDocumentScanned(scannedData);
1779
- }, 1000);
1780
- } else {
1781
- if (isDebugEnabled()) {
1782
- console.log(
1783
- '[ID_FRONT Scan] Confirmed as ID card - proceeding to hologram'
1784
- );
1785
- }
1786
- setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1787
- setTimeout(() => {
1788
- onIdentityDocumentScanned(scannedData);
1789
- }, 1000);
1790
- }
1791
- }
1792
- // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
1793
- } else if (documentType === 'PASSPORT') {
1794
- if (
1795
- nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
1796
- !scannedData.hologramImage
1797
- ) {
1798
- if (onlyMRZScan) {
1799
- const hasRequiredFields = hasRequiredMRZFields(
1800
- parsedMRZData?.fields
1801
- );
1802
- // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
1803
- if (
1804
- !!scannedData.mrzText &&
1805
- hasRequiredFields &&
1806
- mrzStableAndValid
1807
- ) {
1808
- const hasFace = cardSizedFaces.length > 0;
1809
- const hasMRZ = !!mrzText;
1810
- const allRequiredElementsInFrame = hasFace && hasMRZ;
1811
-
1812
- setElementsOutsideScanArea([]);
1813
-
1814
- if (!allRequiredElementsInFrame) {
1815
- console.log(
1816
- '[Passport Scan] MRZ valid but waiting for all elements in frame (face + MRZ)'
1817
- );
1818
- setStatus('SCANNING');
1819
- return;
1820
- }
1821
- logMRZDetails(
1822
- 'Passport Scan',
1823
- parsedMRZData?.fields,
1824
- mrzText,
1825
- validMRZConsecutiveCount.current,
1826
- isDebugEnabled()
1827
- );
1828
- setDetectedDocumentType('PASSPORT');
1829
- setStatus('SCANNED');
1830
- setIsTorchOn(false);
1831
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
1832
- setTimeout(() => {
1833
- onIdentityDocumentScanned(scannedData);
1834
- }, 1000);
1835
- return; // CRITICAL: Exit after MRZ-only scan completes to prevent fall-through
1836
- } else {
1837
- if (!!scannedData.mrzText && !mrzStableAndValid) {
1838
- logMRZValidationFailure(
1839
- 'Passport Scan',
1840
- hasRequiredFields,
1841
- parsedMRZData,
1842
- mrzDetectionCurrentRetryCount.current,
1843
- isDebugEnabled()
1844
- );
1845
- }
1846
- mrzDetectionCurrentRetryCount.current++;
1847
- setStatus('SCANNING');
1848
- return; // Don't fall through to else-if
1849
- }
1850
- } else {
1851
- // Normal passport scan (with hologram) - require MRZ to be detected before proceeding
1852
- const hasFace = cardSizedFaces.length > 0;
1853
- const hasMRZ = !!mrzText;
1854
- const allRequiredElementsInFrame = hasFace && hasMRZ;
1855
-
1856
- setElementsOutsideScanArea([]);
1857
-
1858
- if (!allRequiredElementsInFrame) {
1859
- console.log(
1860
- '[Passport Scan] Valid but waiting for all elements in frame (face + MRZ)'
1861
- );
1862
- setStatus('SCANNING');
1863
- return;
1864
- }
1865
1688
 
1866
- // CRITICAL: Final verification - ensure MRZ definitively identifies this as passport
1867
- // This must pass before we can proceed to hologram
1868
- if (
1869
- !parsedMRZData?.fields?.documentCode ||
1870
- parsedMRZData.fields.documentCode !== 'P'
1871
- ) {
1872
- console.log(
1873
- '[Passport Scan] MRZ detected but not confirmed as passport (code:',
1874
- parsedMRZData?.fields?.documentCode || 'none',
1875
- ') - waiting for valid passport MRZ'
1876
- );
1877
- setStatus('SCANNING');
1878
- return;
1879
- }
1689
+ const nextIdFrontStep = onlyMRZScan
1690
+ ? 'SCAN_ID_BACK'
1691
+ : 'SCAN_HOLOGRAM';
1692
+ transitionStepWithCallback(
1693
+ nextIdFrontStep,
1694
+ 'SCAN_ID_FRONT_OR_PASSPORT',
1695
+ scannedData
1696
+ );
1697
+ };
1880
1698
 
1881
- console.log(
1882
- '[Passport Scan] MRZ confirmed passport (code P) - definitively identified, proceeding to hologram'
1883
- );
1884
- // CRITICAL: Lock document type state to PASSPORT before proceeding to hologram
1885
- // This ensures hologram completion knows it's a passport (no ID_BACK step)
1886
- setDetectedDocumentType('PASSPORT');
1887
- setStatus('SCANNED');
1888
- setIsTorchOn(false);
1889
- setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1890
- setTimeout(() => {
1891
- onIdentityDocumentScanned(scannedData);
1892
- }, 1000);
1893
- }
1699
+ // PASSPORT FLOW: Face + MRZ with code 'P'
1700
+ if (flowDocumentType === 'PASSPORT') {
1701
+ handlePassportInitialStep();
1702
+ return;
1894
1703
  }
1895
- // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
1896
- } else if (documentType === 'ID_BACK') {
1897
- // ID_BACK is now handled in the early-return path above (SCAN_ID_BACK)
1898
- // This branch only triggers if somehow ID_BACK is detected during a non-back-scan step
1899
- mrzDetectionCurrentRetryCount.current++;
1900
- setStatus('SCANNING');
1901
- } else {
1902
- // Document type UNKNOWN - continue scanning until we can classify it
1903
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1904
- console.log(
1905
- '[Document Scan] Type UNKNOWN - waiting for clearer detection (faces:',
1906
- cardSizedFaces.length,
1907
- 'mrzCode:',
1908
- parsedMRZData?.fields?.documentCode || 'none',
1909
- 'text length:',
1910
- text.length,
1911
- ')'
1912
- );
1704
+
1705
+ // ID CARD FLOW: Face + No passport MRZ pattern
1706
+ if (flowDocumentType === 'ID_FRONT') {
1707
+ handleIdFrontInitialStep();
1708
+ return;
1913
1709
  }
1914
- setStatus('SCANNING');
1710
+
1711
+ // UNKNOWN - Continue scanning
1712
+ continueScanning();
1713
+ return;
1915
1714
  }
1916
1715
  },
1917
1716
  [
1918
1717
  nextStep,
1919
1718
  frameDimensions,
1920
1719
  currentHologramImage,
1921
- currentFaceImage,
1922
- hasRequiredMRZFields,
1923
- areMRZFieldsEqual,
1924
1720
  detectedDocumentType,
1721
+ currentFaceImage,
1722
+ testMode,
1925
1723
  onlyMRZScan,
1926
- isTorchOn,
1927
1724
  setIsTorchOn,
1928
- setNextStepAndVibrate,
1725
+ transitionStepWithCallback,
1929
1726
  onIdentityDocumentScanned,
1930
- logMRZDetails,
1931
- logMRZValidationFailure,
1932
1727
  currentSecondaryFaceImage,
1933
1728
  detectHologramNative,
1934
- mrzUtils,
1935
1729
  ]
1936
1730
  );
1937
1731
 
@@ -2082,61 +1876,18 @@ const IdentityDocumentCamera = ({
2082
1876
  },
2083
1877
  cornerPoints: b.cornerPoints ?? [],
2084
1878
  }));
2085
-
2086
- // Log barcode detection for debugging (only when scanning ID back)
2087
- if (
2088
- barcodes.length > 0 &&
2089
- nextStep === 'SCAN_ID_BACK' &&
2090
- isDebugEnabled()
2091
- ) {
2092
- console.log(`[Barcode JS] Detected ${barcodes.length} barcode(s):`);
2093
- barcodes.forEach((b, idx) => {
2094
- const formatNames: { [key: number]: string } = {
2095
- 5: 'PDF417',
2096
- 64: 'QR_CODE',
2097
- 1: 'CODE_128',
2098
- 2: 'CODE_39',
2099
- 13: 'EAN_13',
2100
- 8: 'EAN_8',
2101
- 4096: 'AZTEC',
2102
- 16: 'DATA_MATRIX',
2103
- };
2104
- const formatName =
2105
- formatNames[b.format] || `UNKNOWN(${b.format})`;
2106
- console.log(
2107
- ` [${idx + 1}] ${formatName}: ${b.rawValue.substring(0, 50)}`
2108
- );
2109
- });
2110
- }
2111
1879
  }
2112
1880
 
2113
1881
  // Update all debug overlay bounds continuously when debug mode is enabled
2114
1882
  if (isDebugEnabled() && frameDimensions) {
2115
- const screen = Dimensions.get('window');
2116
- const frameAspect = frameDimensions.width / frameDimensions.height;
2117
- const screenAspect = screen.width / screen.height;
2118
-
2119
- let scale: number;
2120
- let offsetX = 0;
2121
- let offsetY = 0;
2122
-
2123
- if (frameAspect > screenAspect) {
2124
- scale = screen.height / frameDimensions.height;
2125
- offsetX = (frameDimensions.width * scale - screen.width) / 2;
2126
- } else {
2127
- scale = screen.width / frameDimensions.width;
2128
- offsetY = (frameDimensions.height * scale - screen.height) / 2;
2129
- }
2130
-
2131
- const scanLeft = (screen.width * 0.05 + offsetX) / scale;
2132
- const scanTop = (screen.height * 0.36 + offsetY) / scale;
2133
- const scanRight = (screen.width * 0.95 + offsetX) / scale;
2134
- const scanBottom = (screen.height * 0.64 + offsetY) / scale;
2135
- const isInsideScan = (x: number, y: number, w: number, h: number) =>
2136
- x >= scanLeft &&
2137
- y >= scanTop &&
2138
- x + w <= scanRight &&
2139
- y + h <= scanBottom;
1883
+ const { scale, offsetX, offsetY } = getFrameToScreenTransform(
1884
+ frameDimensions.width,
1885
+ frameDimensions.height
1886
+ );
1887
+ const { isInsideScan } = getScanAreaBounds(
1888
+ frameDimensions.width,
1889
+ frameDimensions.height
1890
+ );
2140
1891
 
2141
1892
  // Update barcode bounds
2142
1893
  if (barcodes.length > 0 && barcodes[0]) {
@@ -2150,15 +1901,12 @@ const IdentityDocumentCamera = ({
2150
1901
  x: c.x * scale - offsetX,
2151
1902
  y: c.y * scale - offsetY,
2152
1903
  }));
2153
- // Calculate angle from first two corners (bottom edge)
2154
- const dx = transformedCorners[1].x - transformedCorners[0].x;
2155
- const dy = transformedCorners[1].y - transformedCorners[0].y;
2156
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
1904
+ angle = angleBetweenPoints(
1905
+ transformedCorners[0],
1906
+ transformedCorners[1]
1907
+ );
2157
1908
  }
2158
1909
 
2159
- if (isDebugEnabled()) {
2160
- console.log('[Debug] Barcode detected:', { bbox, angle });
2161
- }
2162
1910
  setBarcodeBounds({
2163
1911
  x: bbox.left * scale - offsetX,
2164
1912
  y: bbox.top * scale - offsetY,
@@ -2228,46 +1976,21 @@ const IdentityDocumentCamera = ({
2228
1976
 
2229
1977
  // Detect MRZ and signature text areas continuously
2230
1978
  if (textBlocks.length > 0) {
2231
- console.log('[Debug] Text blocks count:', textBlocks.length);
2232
- // Find MRZ-like text blocks (bottom area, contains MRZ-like characters)
2233
- // More strict pattern: look for blocks with 8+ consecutive uppercase/numbers/< characters AND
2234
- // must contain at least one '<' character (true MRZ characteristic)
2235
- const mrzPattern = /[A-Z0-9<]{8,}.*</i;
2236
- const bottomHalf = frame.height * 0.5; // Increased from 0.67 to catch more MRZ blocks
2237
-
2238
- // Log bottom area blocks for debugging
2239
- const bottomBlocks = textBlocks.filter(
2240
- (block) => block.blockFrame && block.blockFrame.y > bottomHalf
2241
- );
2242
- if (bottomBlocks.length > 0) {
2243
- console.log(
2244
- '[Debug] Bottom area blocks:',
2245
- bottomBlocks.map((b) => b.text.substring(0, 30))
2246
- );
2247
- }
2248
-
1979
+ const bottomHalf = frame.height * 0.5;
2249
1980
  const mrzBlocks = textBlocks.filter(
2250
1981
  (block) =>
2251
1982
  block.blockFrame &&
2252
1983
  block.blockFrame.y > bottomHalf &&
2253
- mrzPattern.test(block.text)
1984
+ MRZ_BLOCK_PATTERN.test(block.text)
2254
1985
  );
2255
1986
 
2256
- console.log('[Debug] MRZ blocks found:', mrzBlocks.length);
2257
1987
  if (mrzBlocks.length > 0) {
2258
- // Extract MRZ-only text from detected blocks (sorted by Y position for correct line order)
2259
1988
  const sortedMrzBlocks = [...mrzBlocks].sort(
2260
1989
  (a, b) => (a.blockFrame?.y || 0) - (b.blockFrame?.y || 0)
2261
1990
  );
2262
1991
  scannedText.mrzOnlyText = sortedMrzBlocks
2263
1992
  .map((b) => b.text)
2264
1993
  .join('\n');
2265
- if (isDebugEnabled()) {
2266
- console.log(
2267
- '[MRZ Extraction] Using only MRZ blocks:',
2268
- scannedText.mrzOnlyText.substring(0, 100)
2269
- );
2270
- }
2271
1994
 
2272
1995
  const minX = Math.min(...mrzBlocks.map((b) => b.blockFrame!.x));
2273
1996
  const minY = Math.min(...mrzBlocks.map((b) => b.blockFrame!.y));
@@ -2277,30 +2000,17 @@ const IdentityDocumentCamera = ({
2277
2000
  const maxY = Math.max(
2278
2001
  ...mrzBlocks.map((b) => b.blockFrame!.y + b.blockFrame!.height)
2279
2002
  );
2280
-
2281
- // Collect all corner points from MRZ blocks
2282
2003
  const allCornerPoints = mrzBlocks
2283
2004
  .flatMap((b) => b.cornerPoints || [])
2284
2005
  .map((c) => ({
2285
2006
  x: c.x * scale - offsetX,
2286
2007
  y: c.y * scale - offsetY,
2287
2008
  }));
2009
+ const angle =
2010
+ allCornerPoints.length >= 2
2011
+ ? angleBetweenPoints(allCornerPoints[0], allCornerPoints[1])
2012
+ : 0;
2288
2013
 
2289
- let angle = 0;
2290
- if (allCornerPoints.length >= 2) {
2291
- // Calculate angle from first two points
2292
- const dx = allCornerPoints[1].x - allCornerPoints[0].x;
2293
- const dy = allCornerPoints[1].y - allCornerPoints[0].y;
2294
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
2295
- }
2296
-
2297
- console.log('[Debug] MRZ bounds:', {
2298
- minX,
2299
- minY,
2300
- maxX,
2301
- maxY,
2302
- angle,
2303
- });
2304
2014
  setMrzBounds({
2305
2015
  x: minX * scale - offsetX,
2306
2016
  y: minY * scale - offsetY,
@@ -2314,19 +2024,11 @@ const IdentityDocumentCamera = ({
2314
2024
  setMrzBounds(null);
2315
2025
  }
2316
2026
 
2317
- // Detect signature area
2318
- const signaturePattern = /signature|imza|İmza/i;
2319
2027
  const signatureBlocks = textBlocks.filter(
2320
- (block) => block.blockFrame && signaturePattern.test(block.text)
2028
+ (block) =>
2029
+ block.blockFrame && SIGNATURE_TEXT_REGEX.test(block.text)
2321
2030
  );
2322
2031
 
2323
- if (textBlocks.length > 0 && signatureBlocks.length === 0) {
2324
- console.log(
2325
- `[Signature Debug] No signature blocks found. All blocks (${textBlocks.length}):`,
2326
- textBlocks.map((b) => b.text).join(' | ')
2327
- );
2328
- }
2329
-
2330
2032
  if (signatureBlocks.length > 0) {
2331
2033
  const minX = Math.min(
2332
2034
  ...signatureBlocks.map((b) => b.blockFrame!.x)
@@ -2344,22 +2046,16 @@ const IdentityDocumentCamera = ({
2344
2046
  (b) => b.blockFrame!.y + b.blockFrame!.height
2345
2047
  )
2346
2048
  );
2347
-
2348
- // Collect all corner points from signature blocks
2349
2049
  const allCornerPoints = signatureBlocks
2350
2050
  .flatMap((b) => b.cornerPoints || [])
2351
2051
  .map((c) => ({
2352
2052
  x: c.x * scale - offsetX,
2353
2053
  y: c.y * scale - offsetY,
2354
2054
  }));
2355
-
2356
- let angle = 0;
2357
- if (allCornerPoints.length >= 2) {
2358
- // Calculate angle from first two points
2359
- const dx = allCornerPoints[1].x - allCornerPoints[0].x;
2360
- const dy = allCornerPoints[1].y - allCornerPoints[0].y;
2361
- angle = Math.atan2(dy, dx) * (180 / Math.PI);
2362
- }
2055
+ const angle =
2056
+ allCornerPoints.length >= 2
2057
+ ? angleBetweenPoints(allCornerPoints[0], allCornerPoints[1])
2058
+ : 0;
2363
2059
 
2364
2060
  setSignatureBounds({
2365
2061
  x: minX * scale - offsetX,
@@ -2373,81 +2069,9 @@ const IdentityDocumentCamera = ({
2373
2069
  } else {
2374
2070
  setSignatureBounds(null);
2375
2071
  }
2376
-
2377
- // Check if all required elements are detected based on document type
2378
- if (nextStep === 'SCAN_ID_BACK') {
2379
- // ID Back: MRZ + barcode (barcode optional but preferred)
2380
- const hasMRZ = mrzBlocks.length > 0;
2381
- const hasBarcode =
2382
- barcodes.length > 0 || cachedBarcode.current !== null;
2383
- const allPresent = hasMRZ && hasBarcode;
2384
- setAllElementsDetected(allPresent);
2385
-
2386
- // Don't block based on bounds - allow elements even if slightly outside
2387
- setElementsOutsideScanArea([]);
2388
-
2389
- if (!allPresent) {
2390
- const missing = [];
2391
- if (!hasMRZ) missing.push('MRZ');
2392
- if (!hasBarcode) missing.push('Barcode');
2393
- console.log(
2394
- `[Frame Check] Missing elements: ${missing.join(', ')}`
2395
- );
2396
- } else {
2397
- console.log('[Frame Check] ✓ All elements detected in frame');
2398
- }
2399
- } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
2400
- // Check if it's passport (has MRZ) or ID front (no MRZ)
2401
- const hasMRZ = mrzBlocks.length > 0;
2402
- const hasFace = detectedFaces.length > 0;
2403
- const hasSignature = signatureBlocks.length > 0;
2404
-
2405
- // Don't block based on bounds - allow elements even if slightly outside
2406
- setElementsOutsideScanArea([]);
2407
-
2408
- let allPresent = false;
2409
- if (hasMRZ) {
2410
- // Passport: face + MRZ
2411
- allPresent = hasFace && hasMRZ;
2412
- if (!allPresent) {
2413
- const missing = [];
2414
- if (!hasFace) missing.push('Face');
2415
- if (!hasMRZ) missing.push('MRZ');
2416
- console.log(
2417
- `[Frame Check] Passport - Missing elements: ${missing.join(', ')}`
2418
- );
2419
- } else {
2420
- console.log(
2421
- '[Frame Check] ✓ Passport - All elements detected (face + MRZ)'
2422
- );
2423
- }
2424
- } else {
2425
- // ID Front: face + signature
2426
- allPresent = hasFace && hasSignature;
2427
- if (!allPresent) {
2428
- const missing = [];
2429
- if (!hasFace) missing.push('Face');
2430
- if (!hasSignature) missing.push('Signature');
2431
- console.log(
2432
- `[Frame Check] ID Front - Missing elements: ${missing.join(', ')}`
2433
- );
2434
- } else {
2435
- console.log(
2436
- '[Frame Check] ✓ ID Front - All elements detected (face + signature)'
2437
- );
2438
- }
2439
- }
2440
-
2441
- setAllElementsDetected(allPresent);
2442
- } else {
2443
- setAllElementsDetected(false);
2444
- setElementsOutsideScanArea([]);
2445
- }
2446
2072
  } else {
2447
2073
  setMrzBounds(null);
2448
2074
  setSignatureBounds(null);
2449
- setAllElementsDetected(false);
2450
- setElementsOutsideScanArea([]);
2451
2075
  }
2452
2076
  } else if (!isDebugEnabled()) {
2453
2077
  // Clear all bounds when debug mode is disabled
@@ -2458,21 +2082,17 @@ const IdentityDocumentCamera = ({
2458
2082
  setSignatureBounds(null);
2459
2083
  }
2460
2084
 
2461
- // Update allElementsDetected for status text display (regardless of debug mode)
2085
+ // Update allElementsDetected for status text display
2462
2086
  if (nextStep === 'SCAN_ID_BACK') {
2463
- const hasMRZ = textBlocks.some((b) =>
2464
- /[A-Z0-9<]{8,}.*</i.test(b.text)
2465
- );
2087
+ const hasMRZ = textBlocks.some((b) => MRZ_BLOCK_PATTERN.test(b.text));
2466
2088
  const hasBarcode =
2467
2089
  barcodes.length > 0 || cachedBarcode.current !== null;
2468
2090
  setAllElementsDetected(hasMRZ && hasBarcode);
2469
2091
  } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
2470
- const hasMRZ = textBlocks.some((b) =>
2471
- /[A-Z0-9<]{8,}.*</i.test(b.text)
2472
- );
2092
+ const hasMRZ = textBlocks.some((b) => MRZ_BLOCK_PATTERN.test(b.text));
2473
2093
  const hasFace = detectedFaces.length > 0;
2474
2094
  const hasSignature = textBlocks.some((b) =>
2475
- /signature|imza|İmza/i.test(b.text)
2095
+ SIGNATURE_TEXT_REGEX.test(b.text)
2476
2096
  );
2477
2097
  setAllElementsDetected(
2478
2098
  hasMRZ ? hasFace && hasMRZ : hasFace && hasSignature
@@ -2482,29 +2102,8 @@ const IdentityDocumentCamera = ({
2482
2102
  }
2483
2103
 
2484
2104
  // Check if detected elements are inside the scan area
2485
- const scanScreen = Dimensions.get('window');
2486
- const scanFrameAspect = frame.width / frame.height;
2487
- const scanScreenAspect = scanScreen.width / scanScreen.height;
2488
- let scanScale: number;
2489
- let scanOffsetX = 0;
2490
- let scanOffsetY = 0;
2491
- if (scanFrameAspect > scanScreenAspect) {
2492
- scanScale = scanScreen.height / frame.height;
2493
- scanOffsetX = (frame.width * scanScale - scanScreen.width) / 2;
2494
- } else {
2495
- scanScale = scanScreen.width / frame.width;
2496
- scanOffsetY = (frame.height * scanScale - scanScreen.height) / 2;
2497
- }
2498
- const scanLeft = (scanScreen.width * 0.05 + scanOffsetX) / scanScale;
2499
- const scanTop = (scanScreen.height * 0.36 + scanOffsetY) / scanScale;
2500
- const scanRight = (scanScreen.width * 0.95 + scanOffsetX) / scanScale;
2501
- const scanBottom = (scanScreen.height * 0.64 + scanOffsetY) / scanScale;
2502
-
2503
- const isInsideScan = (x: number, y: number, w: number, h: number) =>
2504
- x >= scanLeft &&
2505
- y >= scanTop &&
2506
- x + w <= scanRight &&
2507
- y + h <= scanBottom;
2105
+ const { scanLeft, scanTop, scanRight, scanBottom, isInsideScan } =
2106
+ getScanAreaBounds(frame.width, frame.height);
2508
2107
 
2509
2108
  const outsideElements: string[] = [];
2510
2109
 
@@ -2550,8 +2149,8 @@ const IdentityDocumentCamera = ({
2550
2149
  y2: bf.y + bf.height,
2551
2150
  });
2552
2151
  }
2553
- const isMRZ = /[A-Z0-9<]{8,}.*</i.test(block.text);
2554
- const isSignature = /signature|imza|İmza/i.test(block.text);
2152
+ const isMRZ = MRZ_BLOCK_PATTERN.test(block.text);
2153
+ const isSignature = SIGNATURE_TEXT_REGEX.test(block.text);
2555
2154
  if (
2556
2155
  (isMRZ || isSignature) &&
2557
2156
  !isInsideScan(bf.x, bf.y, bf.width, bf.height)
@@ -2717,444 +2316,6 @@ const IdentityDocumentCamera = ({
2717
2316
  onCameraReady={handleCameraReady}
2718
2317
  onCameraError={handleCameraError}
2719
2318
  />
2720
- {isDebugEnabled() &&
2721
- documentPlaneBounds &&
2722
- nextStep !== 'COMPLETED' && (
2723
- <>
2724
- {/* Crop area border (with padding) */}
2725
- {!!documentPlaneBounds.cropPadding && (
2726
- <View
2727
- style={{
2728
- position: 'absolute',
2729
- left:
2730
- documentPlaneBounds.x - documentPlaneBounds.cropPadding,
2731
- top:
2732
- documentPlaneBounds.y - documentPlaneBounds.cropPadding,
2733
- width:
2734
- documentPlaneBounds.width +
2735
- 2 * documentPlaneBounds.cropPadding,
2736
- height:
2737
- documentPlaneBounds.height +
2738
- 2 * documentPlaneBounds.cropPadding,
2739
- borderWidth: 2,
2740
- borderColor: 'rgba(76, 175, 80, 0.5)',
2741
- borderStyle: 'dashed',
2742
- borderRadius: 8,
2743
- backgroundColor: 'transparent',
2744
- transform: [
2745
- {
2746
- rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg`,
2747
- },
2748
- ],
2749
- transformOrigin: 'center',
2750
- }}
2751
- />
2752
- )}
2753
- {/* Actual face border */}
2754
- <View
2755
- style={{
2756
- position: 'absolute',
2757
- left: documentPlaneBounds.x,
2758
- top: documentPlaneBounds.y,
2759
- width: documentPlaneBounds.width,
2760
- height: documentPlaneBounds.height,
2761
- borderWidth: 3,
2762
- borderColor: '#4CAF50',
2763
- borderRadius: 8,
2764
- backgroundColor: 'transparent',
2765
- transform: [
2766
- { rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg` },
2767
- ],
2768
- transformOrigin: 'center',
2769
- }}
2770
- >
2771
- {!!documentPlaneBounds.rollAngle &&
2772
- Math.abs(documentPlaneBounds.rollAngle) > 5 && (
2773
- <TextView
2774
- style={{
2775
- position: 'absolute',
2776
- top: -20,
2777
- left: 0,
2778
- color: '#4CAF50',
2779
- fontSize: 10,
2780
- fontWeight: 'bold',
2781
- backgroundColor: 'rgba(0,0,0,0.7)',
2782
- paddingHorizontal: 4,
2783
- paddingVertical: 2,
2784
- borderRadius: 2,
2785
- }}
2786
- >
2787
- {documentPlaneBounds.rollAngle.toFixed(1)}°
2788
- </TextView>
2789
- )}
2790
- </View>
2791
- </>
2792
- )}
2793
- {isDebugEnabled() &&
2794
- secondaryFaceBounds &&
2795
- nextStep !== 'COMPLETED' && (
2796
- <>
2797
- {/* Crop area border (with padding) */}
2798
- {!!secondaryFaceBounds.cropPadding && (
2799
- <View
2800
- style={{
2801
- position: 'absolute',
2802
- left:
2803
- secondaryFaceBounds.x - secondaryFaceBounds.cropPadding,
2804
- top:
2805
- secondaryFaceBounds.y - secondaryFaceBounds.cropPadding,
2806
- width:
2807
- secondaryFaceBounds.width +
2808
- 2 * secondaryFaceBounds.cropPadding,
2809
- height:
2810
- secondaryFaceBounds.height +
2811
- 2 * secondaryFaceBounds.cropPadding,
2812
- borderWidth: 2,
2813
- borderColor: 'rgba(33, 150, 243, 0.5)',
2814
- borderStyle: 'dashed',
2815
- borderRadius: 8,
2816
- backgroundColor: 'transparent',
2817
- transform: [
2818
- {
2819
- rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg`,
2820
- },
2821
- ],
2822
- transformOrigin: 'center',
2823
- }}
2824
- />
2825
- )}
2826
- {/* Actual face border */}
2827
- <View
2828
- style={{
2829
- position: 'absolute',
2830
- left: secondaryFaceBounds.x,
2831
- top: secondaryFaceBounds.y,
2832
- width: secondaryFaceBounds.width,
2833
- height: secondaryFaceBounds.height,
2834
- borderWidth: 3,
2835
- borderColor: '#2196F3',
2836
- borderRadius: 8,
2837
- backgroundColor: 'transparent',
2838
- transform: [
2839
- { rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg` },
2840
- ],
2841
- transformOrigin: 'center',
2842
- }}
2843
- >
2844
- {!!secondaryFaceBounds.rollAngle &&
2845
- Math.abs(secondaryFaceBounds.rollAngle) > 5 && (
2846
- <TextView
2847
- style={{
2848
- position: 'absolute',
2849
- top: -20,
2850
- left: 0,
2851
- color: '#2196F3',
2852
- fontSize: 10,
2853
- fontWeight: 'bold',
2854
- backgroundColor: 'rgba(0,0,0,0.7)',
2855
- paddingHorizontal: 4,
2856
- paddingVertical: 2,
2857
- borderRadius: 2,
2858
- }}
2859
- >
2860
- {secondaryFaceBounds.rollAngle.toFixed(1)}°
2861
- </TextView>
2862
- )}
2863
- </View>
2864
- </>
2865
- )}
2866
- {isDebugEnabled() && barcodeBounds && nextStep !== 'COMPLETED' && (
2867
- <>
2868
- {barcodeBounds.corners && barcodeBounds.corners.length >= 4 ? (
2869
- // Render using corner points for precise rotated border
2870
- <>
2871
- {/* Draw border lines between corners */}
2872
- {[0, 1, 2, 3].map((i) => {
2873
- const start = barcodeBounds.corners![i];
2874
- const end = barcodeBounds.corners![(i + 1) % 4];
2875
- const dx = end.x - start.x;
2876
- const dy = end.y - start.y;
2877
- const length = Math.sqrt(dx * dx + dy * dy);
2878
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2879
-
2880
- return (
2881
- <View
2882
- key={i}
2883
- style={{
2884
- position: 'absolute',
2885
- left: start.x,
2886
- top: start.y,
2887
- width: length,
2888
- height: 3,
2889
- backgroundColor: '#FF9800',
2890
- transform: [{ rotate: `${angle}deg` }],
2891
- transformOrigin: 'top left',
2892
- }}
2893
- />
2894
- );
2895
- })}
2896
- {/* Draw corner markers */}
2897
- {barcodeBounds.corners.map((corner, idx) => (
2898
- <View
2899
- key={`corner-${idx}`}
2900
- style={{
2901
- position: 'absolute',
2902
- left: corner.x - 4,
2903
- top: corner.y - 4,
2904
- width: 8,
2905
- height: 8,
2906
- borderRadius: 4,
2907
- backgroundColor: '#FF9800',
2908
- }}
2909
- />
2910
- ))}
2911
- {/* Angle indicator */}
2912
- {!!barcodeBounds.angle &&
2913
- Math.abs(barcodeBounds.angle) > 5 && (
2914
- <TextView
2915
- style={{
2916
- position: 'absolute',
2917
- left: barcodeBounds.x,
2918
- top: barcodeBounds.y - 20,
2919
- color: '#FF9800',
2920
- fontSize: 10,
2921
- fontWeight: 'bold',
2922
- backgroundColor: 'rgba(0,0,0,0.7)',
2923
- paddingHorizontal: 4,
2924
- paddingVertical: 2,
2925
- borderRadius: 2,
2926
- }}
2927
- >
2928
- {barcodeBounds.angle.toFixed(1)}°
2929
- </TextView>
2930
- )}
2931
- </>
2932
- ) : (
2933
- // Fallback to rotated rectangle if corners not available
2934
- <View
2935
- style={{
2936
- position: 'absolute',
2937
- left: barcodeBounds.x + barcodeBounds.width / 2,
2938
- top: barcodeBounds.y + barcodeBounds.height / 2,
2939
- width: barcodeBounds.width,
2940
- height: barcodeBounds.height,
2941
- marginLeft: -barcodeBounds.width / 2,
2942
- marginTop: -barcodeBounds.height / 2,
2943
- borderWidth: 3,
2944
- borderColor: '#FF9800',
2945
- borderRadius: 8,
2946
- backgroundColor: 'transparent',
2947
- transform: [{ rotate: `${barcodeBounds.angle || 0}deg` }],
2948
- }}
2949
- >
2950
- {!!barcodeBounds.angle &&
2951
- Math.abs(barcodeBounds.angle) > 5 && (
2952
- <TextView
2953
- style={{
2954
- position: 'absolute',
2955
- top: -20,
2956
- left: 0,
2957
- color: '#FF9800',
2958
- fontSize: 10,
2959
- fontWeight: 'bold',
2960
- backgroundColor: 'rgba(0,0,0,0.7)',
2961
- paddingHorizontal: 4,
2962
- paddingVertical: 2,
2963
- borderRadius: 2,
2964
- }}
2965
- >
2966
- {barcodeBounds.angle.toFixed(1)}°
2967
- </TextView>
2968
- )}
2969
- </View>
2970
- )}
2971
- </>
2972
- )}
2973
- {isDebugEnabled() && mrzBounds && nextStep !== 'COMPLETED' && (
2974
- <>
2975
- {mrzBounds.corners && mrzBounds.corners.length >= 2 ? (
2976
- // Render using corner points for precise rotated border
2977
- <>
2978
- {/* Draw border lines between consecutive corners */}
2979
- {mrzBounds.corners.map((corner, idx) => {
2980
- const nextCorner =
2981
- mrzBounds.corners![(idx + 1) % mrzBounds.corners!.length];
2982
- const dx = nextCorner.x - corner.x;
2983
- const dy = nextCorner.y - corner.y;
2984
- const length = Math.sqrt(dx * dx + dy * dy);
2985
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2986
-
2987
- return (
2988
- <View
2989
- key={idx}
2990
- style={{
2991
- position: 'absolute',
2992
- left: corner.x,
2993
- top: corner.y,
2994
- width: length,
2995
- height: 3,
2996
- backgroundColor: '#9C27B0',
2997
- transform: [{ rotate: `${angle}deg` }],
2998
- transformOrigin: 'top left',
2999
- }}
3000
- />
3001
- );
3002
- })}
3003
- {/* Angle indicator */}
3004
- {!!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && (
3005
- <TextView
3006
- style={{
3007
- position: 'absolute',
3008
- left: mrzBounds.x,
3009
- top: mrzBounds.y - 20,
3010
- color: '#9C27B0',
3011
- fontSize: 10,
3012
- fontWeight: 'bold',
3013
- backgroundColor: 'rgba(0,0,0,0.7)',
3014
- paddingHorizontal: 4,
3015
- paddingVertical: 2,
3016
- borderRadius: 2,
3017
- }}
3018
- >
3019
- {mrzBounds.angle.toFixed(1)}°
3020
- </TextView>
3021
- )}
3022
- </>
3023
- ) : (
3024
- // Fallback to rotated rectangle if corners not available
3025
- <View
3026
- style={{
3027
- position: 'absolute',
3028
- left: mrzBounds.x + mrzBounds.width / 2,
3029
- top: mrzBounds.y + mrzBounds.height / 2,
3030
- width: mrzBounds.width,
3031
- height: mrzBounds.height,
3032
- marginLeft: -mrzBounds.width / 2,
3033
- marginTop: -mrzBounds.height / 2,
3034
- borderWidth: 3,
3035
- borderColor: '#9C27B0',
3036
- borderRadius: 8,
3037
- backgroundColor: 'transparent',
3038
- transform: [{ rotate: `${mrzBounds.angle || 0}deg` }],
3039
- }}
3040
- >
3041
- {!!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && (
3042
- <TextView
3043
- style={{
3044
- position: 'absolute',
3045
- top: -20,
3046
- left: 0,
3047
- color: '#9C27B0',
3048
- fontSize: 10,
3049
- fontWeight: 'bold',
3050
- backgroundColor: 'rgba(0,0,0,0.7)',
3051
- paddingHorizontal: 4,
3052
- paddingVertical: 2,
3053
- borderRadius: 2,
3054
- }}
3055
- >
3056
- {mrzBounds.angle.toFixed(1)}°
3057
- </TextView>
3058
- )}
3059
- </View>
3060
- )}
3061
- </>
3062
- )}
3063
- {isDebugEnabled() && signatureBounds && nextStep !== 'COMPLETED' && (
3064
- <>
3065
- {signatureBounds.corners &&
3066
- signatureBounds.corners.length >= 2 ? (
3067
- // Render using corner points for precise rotated border
3068
- <>
3069
- {/* Draw border lines between consecutive corners */}
3070
- {signatureBounds.corners.map((corner, idx) => {
3071
- const nextCorner =
3072
- signatureBounds.corners![
3073
- (idx + 1) % signatureBounds.corners!.length
3074
- ];
3075
- const dx = nextCorner.x - corner.x;
3076
- const dy = nextCorner.y - corner.y;
3077
- const length = Math.sqrt(dx * dx + dy * dy);
3078
- const angle = Math.atan2(dy, dx) * (180 / Math.PI);
3079
-
3080
- return (
3081
- <View
3082
- key={idx}
3083
- style={{
3084
- position: 'absolute',
3085
- left: corner.x,
3086
- top: corner.y,
3087
- width: length,
3088
- height: 3,
3089
- backgroundColor: '#00BCD4',
3090
- transform: [{ rotate: `${angle}deg` }],
3091
- transformOrigin: 'top left',
3092
- }}
3093
- />
3094
- );
3095
- })}
3096
- {/* Angle indicator */}
3097
- {!!signatureBounds.angle &&
3098
- Math.abs(signatureBounds.angle) > 5 && (
3099
- <TextView
3100
- style={{
3101
- position: 'absolute',
3102
- left: signatureBounds.x,
3103
- top: signatureBounds.y - 20,
3104
- color: '#00BCD4',
3105
- fontSize: 10,
3106
- fontWeight: 'bold',
3107
- backgroundColor: 'rgba(0,0,0,0.7)',
3108
- paddingHorizontal: 4,
3109
- paddingVertical: 2,
3110
- borderRadius: 2,
3111
- }}
3112
- >
3113
- {signatureBounds.angle.toFixed(1)}°
3114
- </TextView>
3115
- )}
3116
- </>
3117
- ) : (
3118
- // Fallback to rotated rectangle if corners not available
3119
- <View
3120
- style={{
3121
- position: 'absolute',
3122
- left: signatureBounds.x + signatureBounds.width / 2,
3123
- top: signatureBounds.y + signatureBounds.height / 2,
3124
- width: signatureBounds.width,
3125
- height: signatureBounds.height,
3126
- marginLeft: -signatureBounds.width / 2,
3127
- marginTop: -signatureBounds.height / 2,
3128
- borderWidth: 3,
3129
- borderColor: '#00BCD4',
3130
- borderRadius: 8,
3131
- backgroundColor: 'transparent',
3132
- transform: [{ rotate: `${signatureBounds.angle || 0}deg` }],
3133
- }}
3134
- >
3135
- {!!signatureBounds.angle &&
3136
- Math.abs(signatureBounds.angle) > 5 && (
3137
- <TextView
3138
- style={{
3139
- position: 'absolute',
3140
- top: -20,
3141
- left: 0,
3142
- color: '#00BCD4',
3143
- fontSize: 10,
3144
- fontWeight: 'bold',
3145
- backgroundColor: 'rgba(0,0,0,0.7)',
3146
- paddingHorizontal: 4,
3147
- paddingVertical: 2,
3148
- borderRadius: 2,
3149
- }}
3150
- >
3151
- {signatureBounds.angle.toFixed(1)}°
3152
- </TextView>
3153
- )}
3154
- </View>
3155
- )}
3156
- </>
3157
- )}
3158
2319
  <View style={[styles.topZone, { paddingTop: insets.top }]}>
3159
2320
  {nextStep !== 'COMPLETED' &&
3160
2321
  status !== 'SCANNED' &&
@@ -3188,14 +2349,17 @@ const IdentityDocumentCamera = ({
3188
2349
  </TextView>
3189
2350
  )}
3190
2351
 
3191
- <TextView
2352
+ <AnimatedText
3192
2353
  style={[
3193
2354
  styles.topZoneText,
3194
2355
  // Priority order for coloring (later styles override earlier ones)
3195
2356
  // 1. Success (green) - scan completed
3196
- status === 'SCANNED' && styles.topZoneTextSuccess,
3197
- // 2. Error (red) - wrong side
2357
+ nextStep === 'COMPLETED' && styles.topZoneTextSuccess,
2358
+ // 2. Error (red) - wrong side - with flash opacity
3198
2359
  status === 'INCORRECT' && styles.topZoneTextError,
2360
+ status === 'INCORRECT' && {
2361
+ opacity: errorFlashAnim,
2362
+ },
3199
2363
  // 3. Warning (yellow) - quality issues
3200
2364
  (isBrightnessLow || isFrameBlurry) && styles.topZoneTextWarning,
3201
2365
  // 4. Scanning (green) - all elements detected AND inside scan area
@@ -3208,266 +2372,27 @@ const IdentityDocumentCamera = ({
3208
2372
  // 5. Default (white) - aligning (not all detected OR elements outside scan area)
3209
2373
  ]}
3210
2374
  >
3211
- {status === 'SCANNED'
3212
- ? completedStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3213
- ? detectedDocumentType === 'PASSPORT'
3214
- ? t('identityDocumentCamera.passportScanned')
3215
- : t('identityDocumentCamera.frontSideScanned')
3216
- : completedStep === 'SCAN_ID_BACK'
3217
- ? t('identityDocumentCamera.backSideScanned')
3218
- : completedStep === 'SCAN_HOLOGRAM'
3219
- ? t('identityDocumentCamera.hologramVerified')
3220
- : t('identityDocumentCamera.scanCompleted')
3221
- : status === 'INCORRECT'
3222
- ? nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3223
- ? t('identityDocumentCamera.wrongSideFront')
3224
- : nextStep === 'SCAN_ID_BACK'
3225
- ? t('identityDocumentCamera.wrongSideBack')
3226
- : nextStep === 'SCAN_HOLOGRAM'
3227
- ? t('identityDocumentCamera.wrongSideFront') // Hologram is on front, show same message as front scan
3228
- : t('identityDocumentCamera.alignPhotoSide')
3229
- : isBrightnessLow
3230
- ? t('identityDocumentCamera.lowBrightness')
3231
- : isFrameBlurry
3232
- ? t('identityDocumentCamera.avoidBlur')
3233
- : status === 'SCANNING' &&
3234
- allElementsDetected &&
3235
- elementsOutsideScanArea.length === 0
3236
- ? nextStep === 'SCAN_ID_BACK'
3237
- ? t('identityDocumentCamera.idCardBackDetected')
3238
- : detectedDocumentType === 'PASSPORT'
3239
- ? t('identityDocumentCamera.passportDetected')
3240
- : detectedDocumentType === 'ID_FRONT'
3241
- ? t('identityDocumentCamera.idCardFrontDetected')
3242
- : nextStep === 'SCAN_HOLOGRAM'
3243
- ? t('identityDocumentCamera.alignHologram')
3244
- : t('identityDocumentCamera.readingDocument')
3245
- : elementsOutsideScanArea.length > 0
3246
- ? t('identityDocumentCamera.centerDocument')
3247
- : (status === 'SCANNING' || status === 'SEARCHING') &&
3248
- !allElementsDetected
3249
- ? nextStep === 'SCAN_ID_BACK'
3250
- ? t('identityDocumentCamera.alignIDBack')
3251
- : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3252
- ? detectedDocumentType === 'PASSPORT'
3253
- ? t('identityDocumentCamera.alignPassport')
3254
- : detectedDocumentType === 'ID_FRONT'
3255
- ? t('identityDocumentCamera.alignIDFront')
3256
- : t('identityDocumentCamera.alignPhotoSide')
3257
- : nextStep === 'SCAN_HOLOGRAM'
3258
- ? t('identityDocumentCamera.alignHologram')
3259
- : t('identityDocumentCamera.readingDocument')
3260
- : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3261
- ? status === 'SCANNING'
3262
- ? t('identityDocumentCamera.readingDocument')
3263
- : t('identityDocumentCamera.alignPhotoSide')
3264
- : nextStep === 'SCAN_HOLOGRAM'
3265
- ? t('identityDocumentCamera.alignHologram')
3266
- : nextStep === 'SCAN_ID_BACK'
3267
- ? status === 'SCANNING'
3268
- ? t(
3269
- 'identityDocumentCamera.readingDocument'
3270
- )
3271
- : t(
3272
- 'identityDocumentCamera.alignIDBackSide'
3273
- )
3274
- : nextStep === 'COMPLETED'
3275
- ? t('identityDocumentCamera.scanCompleted')
3276
- : ''}
3277
- </TextView>
2375
+ {getStatusMessage(
2376
+ nextStep,
2377
+ status,
2378
+ detectedDocumentType,
2379
+ isBrightnessLow,
2380
+ isFrameBlurry,
2381
+ allElementsDetected,
2382
+ elementsOutsideScanArea,
2383
+ t
2384
+ )}
2385
+ </AnimatedText>
3278
2386
  </View>
3279
2387
  <View style={styles.leftZone} />
3280
2388
  <View style={styles.rightZone} />
3281
- <View style={styles.bottomZone}>
3282
- <View style={styles.debugImagesRow}>
3283
- {isDebugEnabled() && (
3284
- <View style={styles.imageContainer}>
3285
- {currentFaceImage ? (
3286
- <Image
3287
- source={{
3288
- uri: `data:image/jpeg;base64,${currentFaceImage}`,
3289
- }}
3290
- style={styles.faceImage}
3291
- />
3292
- ) : (
3293
- <View
3294
- style={[
3295
- styles.faceImage,
3296
- { backgroundColor: '#333', justifyContent: 'center' },
3297
- ]}
3298
- >
3299
- <TextView
3300
- style={{
3301
- color: '#666',
3302
- fontSize: 10,
3303
- textAlign: 'center',
3304
- }}
3305
- >
3306
- Waiting...
3307
- </TextView>
3308
- </View>
3309
- )}
3310
- <TextView
3311
- style={[
3312
- styles.imageContainerText,
3313
- currentFaceImage && { color: '#4CAF50' },
3314
- ]}
3315
- >
3316
- {`${currentFaceImage ? '✓ ' : ''}Face`}
3317
- </TextView>
3318
- </View>
3319
- )}
3320
- {isDebugEnabled() && (
3321
- <View style={styles.imageContainer}>
3322
- {currentSecondaryFaceImage ? (
3323
- <Image
3324
- source={{
3325
- uri: `data:image/jpeg;base64,${currentSecondaryFaceImage}`,
3326
- }}
3327
- style={styles.faceImage}
3328
- />
3329
- ) : (
3330
- <View
3331
- style={[
3332
- styles.faceImage,
3333
- { backgroundColor: '#333', justifyContent: 'center' },
3334
- ]}
3335
- >
3336
- <TextView
3337
- style={{
3338
- color: '#666',
3339
- fontSize: 10,
3340
- textAlign: 'center',
3341
- }}
3342
- >
3343
- Waiting...
3344
- </TextView>
3345
- </View>
3346
- )}
3347
- <TextView
3348
- style={[
3349
- styles.imageContainerText,
3350
- currentSecondaryFaceImage && { color: '#4CAF50' },
3351
- ]}
3352
- >
3353
- {`${currentSecondaryFaceImage ? '✓ ' : ''}2nd Face`}
3354
- </TextView>
3355
- </View>
3356
- )}
3357
- {isDebugEnabled() && (
3358
- <View style={styles.imageContainer}>
3359
- {currentHologramImage ? (
3360
- <Image
3361
- source={{
3362
- uri: `data:image/jpeg;base64,${currentHologramImage}`,
3363
- }}
3364
- style={styles.faceImage}
3365
- />
3366
- ) : latestHologramFaceImage && hologramImageCount > 0 ? (
3367
- <View style={{ position: 'relative' }}>
3368
- <Image
3369
- source={{
3370
- uri: `data:image/jpeg;base64,${latestHologramFaceImage}`,
3371
- }}
3372
- style={[styles.faceImage, { opacity: 0.7 }]}
3373
- />
3374
- <View
3375
- style={{
3376
- position: 'absolute',
3377
- bottom: 0,
3378
- left: 0,
3379
- right: 0,
3380
- backgroundColor: 'rgba(0,0,0,0.7)',
3381
- padding: 2,
3382
- }}
3383
- >
3384
- <TextView
3385
- style={{
3386
- color: '#FFA500',
3387
- fontSize: 8,
3388
- textAlign: 'center',
3389
- fontWeight: 'bold',
3390
- }}
3391
- >
3392
- {hologramImageCount}/{HOLOGRAM_IMAGE_COUNT}
3393
- </TextView>
3394
- </View>
3395
- </View>
3396
- ) : (
3397
- <View
3398
- style={[
3399
- styles.faceImage,
3400
- { backgroundColor: '#333', justifyContent: 'center' },
3401
- ]}
3402
- >
3403
- <TextView
3404
- style={{
3405
- color: '#666',
3406
- fontSize: 10,
3407
- textAlign: 'center',
3408
- }}
3409
- >
3410
- Waiting...
3411
- </TextView>
3412
- </View>
3413
- )}
3414
- <TextView
3415
- style={[
3416
- styles.imageContainerText,
3417
- currentHologramImage && { color: '#4CAF50' },
3418
- latestHologramFaceImage &&
3419
- !currentHologramImage && { color: '#FFA500' },
3420
- ]}
3421
- >
3422
- {`${currentHologramImage ? '✓ ' : latestHologramFaceImage ? '⏳ ' : ''}Hologram`}
3423
- </TextView>
3424
- </View>
3425
- )}
3426
- {isDebugEnabled() && (
3427
- <View style={styles.imageContainer}>
3428
- {_currentHologramMaskImage ? (
3429
- <Image
3430
- source={{
3431
- uri: `data:image/jpeg;base64,${_currentHologramMaskImage}`,
3432
- }}
3433
- style={styles.faceImage}
3434
- />
3435
- ) : (
3436
- <View
3437
- style={[
3438
- styles.faceImage,
3439
- { backgroundColor: '#333', justifyContent: 'center' },
3440
- ]}
3441
- >
3442
- <TextView
3443
- style={{
3444
- color: '#666',
3445
- fontSize: 10,
3446
- textAlign: 'center',
3447
- }}
3448
- >
3449
- Waiting...
3450
- </TextView>
3451
- </View>
3452
- )}
3453
- <TextView
3454
- style={[
3455
- styles.imageContainerText,
3456
- _currentHologramMaskImage && { color: '#4CAF50' },
3457
- ]}
3458
- >
3459
- {`${_currentHologramMaskImage ? '✓ ' : ''}Mask`}
3460
- </TextView>
3461
- </View>
3462
- )}
3463
- </View>
3464
- </View>
2389
+ <View style={styles.bottomZone} />
3465
2390
  <View
3466
2391
  style={[
3467
2392
  styles.scanArea,
3468
2393
  {
3469
2394
  borderColor:
3470
- status === 'SCANNED' || nextStep === 'COMPLETED'
2395
+ nextStep === 'COMPLETED'
3471
2396
  ? '#4CAF50'
3472
2397
  : status === 'INCORRECT'
3473
2398
  ? '#f44336'
@@ -3480,7 +2405,7 @@ const IdentityDocumentCamera = ({
3480
2405
  },
3481
2406
  ]}
3482
2407
  >
3483
- {nextStep === 'COMPLETED' || status === 'SCANNED' ? (
2408
+ {nextStep === 'COMPLETED' ? (
3484
2409
  <LottieView
3485
2410
  source={require('../../Shared/Animations/success.json')}
3486
2411
  style={styles.animation}
@@ -3501,130 +2426,33 @@ const IdentityDocumentCamera = ({
3501
2426
  loop={true}
3502
2427
  autoPlay
3503
2428
  />
3504
- ) : status === 'SCANNING' ? (
3505
- <LottieView
3506
- source={require('../../Shared/Animations/scanning.json')}
3507
- style={styles.animation}
3508
- loop={true}
3509
- autoPlay
3510
- />
3511
2429
  ) : null}
3512
2430
  </View>
3513
2431
  {isDebugEnabled() && (
3514
- <SafeAreaView
3515
- style={{
3516
- position: 'absolute',
3517
- top: 0,
3518
- left: 0,
3519
- right: 0,
3520
- alignItems: 'center',
3521
- pointerEvents: 'none',
3522
- }}
3523
- >
3524
- <View
3525
- style={{
3526
- marginTop: 10,
3527
- backgroundColor: 'rgba(0, 0, 0, 0.85)',
3528
- padding: 10,
3529
- borderRadius: 8,
3530
- borderWidth: 1,
3531
- borderColor: '#FF6B6B',
3532
- minWidth: 200,
3533
- }}
3534
- >
3535
- <TextView
3536
- style={{
3537
- color: '#FF6B6B',
3538
- fontSize: 11,
3539
- fontWeight: 'bold',
3540
- marginBottom: 6,
3541
- textAlign: 'center',
3542
- }}
3543
- >
3544
- DEBUG MODE
3545
- </TextView>
3546
- <TextView
3547
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3548
- >
3549
- {`Step: ${nextStep}`}
3550
- </TextView>
3551
- <TextView
3552
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3553
- >
3554
- {`Status: ${status}`}
3555
- </TextView>
3556
- <TextView
3557
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3558
- >
3559
- {`Doc Type: ${detectedDocumentType}`}
3560
- </TextView>
3561
- <TextView
3562
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3563
- >
3564
- {`Brightness: ${isBrightnessLow ? '⚠️ LOW' : '✓'}`}
3565
- </TextView>
3566
- <TextView
3567
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3568
- >
3569
- {`Blur: ${isFrameBlurry ? '⚠️' : '✓'}`}
3570
- </TextView>
3571
- <TextView
3572
- style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3573
- >
3574
- {`Flash: ${isTorchOn ? '🔦' : '○'}`}
3575
- </TextView>
3576
- <TextView style={{ color: '#88D8B0', fontSize: 9 }}>
3577
- {`Face Detection: ${faceDetectionEnabled ? '✓' : '✗'}`}
3578
- </TextView>
3579
- </View>
3580
- </SafeAreaView>
2432
+ <DebugOverlay
2433
+ nextStep={nextStep}
2434
+ status={status}
2435
+ detectedDocumentType={detectedDocumentType}
2436
+ isBrightnessLow={isBrightnessLow}
2437
+ isFrameBlurry={isFrameBlurry}
2438
+ isTorchOn={isTorchOn}
2439
+ documentPlaneBounds={documentPlaneBounds}
2440
+ secondaryFaceBounds={secondaryFaceBounds}
2441
+ barcodeBounds={barcodeBounds}
2442
+ mrzBounds={mrzBounds}
2443
+ signatureBounds={signatureBounds}
2444
+ currentFaceImage={currentFaceImage}
2445
+ currentSecondaryFaceImage={currentSecondaryFaceImage}
2446
+ currentHologramImage={currentHologramImage}
2447
+ currentHologramMaskImage={_currentHologramMaskImage}
2448
+ latestHologramFaceImage={latestHologramFaceImage}
2449
+ hologramImageCount={hologramImageCount}
2450
+ allElementsDetected={allElementsDetected}
2451
+ elementsOutsideScanArea={elementsOutsideScanArea}
2452
+ />
3581
2453
  )}
3582
2454
  {testMode && testModeData && (
3583
- <SafeAreaView
3584
- style={{
3585
- position: 'absolute',
3586
- bottom: 0,
3587
- left: 0,
3588
- right: 0,
3589
- maxHeight: '40%',
3590
- backgroundColor: 'rgba(0, 0, 0, 0.95)',
3591
- borderTopWidth: 2,
3592
- borderTopColor: '#FFA500',
3593
- }}
3594
- >
3595
- <ScrollView style={{ flex: 1 }}>
3596
- <View style={{ padding: 10 }}>
3597
- <TextView
3598
- style={{
3599
- color: '#FFA500',
3600
- fontSize: 12,
3601
- fontWeight: 'bold',
3602
- marginBottom: 8,
3603
- textAlign: 'center',
3604
- }}
3605
- >
3606
- MRZ Text Read
3607
- </TextView>
3608
-
3609
- <TextView
3610
- style={{
3611
- color: '#FFFFFF',
3612
- fontSize: 9,
3613
- fontFamily: 'monospace',
3614
- lineHeight: 16,
3615
- }}
3616
- >
3617
- {testModeData.mrzText
3618
- .split('\n')
3619
- .map(
3620
- (line, i) =>
3621
- `Line ${i + 1}: ${line} (${line.length} chars)`
3622
- )
3623
- .join('\n')}
3624
- </TextView>
3625
- </View>
3626
- </ScrollView>
3627
- </SafeAreaView>
2455
+ <TestModePanel mrzText={testModeData.mrzText} />
3628
2456
  )}
3629
2457
  </>
3630
2458
  )}
@@ -3633,9 +2461,6 @@ const IdentityDocumentCamera = ({
3633
2461
  };
3634
2462
 
3635
2463
  const styles = StyleSheet.create({
3636
- container: {
3637
- flex: 1,
3638
- },
3639
2464
  permissionContainer: {
3640
2465
  flex: 1,
3641
2466
  justifyContent: 'center',
@@ -3732,50 +2557,6 @@ const styles = StyleSheet.create({
3732
2557
  gap: 10,
3733
2558
  justifyContent: 'flex-start',
3734
2559
  },
3735
- debugImagesRow: {
3736
- display: 'flex',
3737
- flexDirection: 'row',
3738
- gap: 10,
3739
- justifyContent: 'center',
3740
- flexWrap: 'wrap',
3741
- },
3742
- cardDetectionRow: {
3743
- display: 'flex',
3744
- flexDirection: 'row',
3745
- justifyContent: 'center',
3746
- marginTop: 5,
3747
- },
3748
- imageContainer: {
3749
- display: 'flex',
3750
- flexDirection: 'column',
3751
- alignItems: 'center',
3752
- },
3753
- imageContainerText: {
3754
- color: 'white',
3755
- fontSize: 8,
3756
- textAlign: 'center',
3757
- fontWeight: 'bold',
3758
- marginTop: 2,
3759
- },
3760
- faceImage: {
3761
- width: 60,
3762
- height: 80,
3763
- borderRadius: 4,
3764
- borderWidth: 1,
3765
- borderColor: 'white',
3766
- },
3767
- cardDetectionImage: {
3768
- width: 160,
3769
- height: 120,
3770
- borderRadius: 8,
3771
- borderWidth: 2,
3772
- borderColor: '#FF9800',
3773
- },
3774
- cardDetectionContainer: {
3775
- display: 'flex',
3776
- flexDirection: 'column',
3777
- alignItems: 'center',
3778
- },
3779
2560
  guide: {
3780
2561
  flex: 1,
3781
2562
  display: 'flex',