@trustchex/react-native-sdk 1.374.0 → 1.409.0

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