@trustchex/react-native-sdk 1.355.1 → 1.357.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 (254) hide show
  1. package/README.md +2 -9
  2. package/TrustchexSDK.podspec +5 -4
  3. package/android/build.gradle +6 -4
  4. package/android/src/main/AndroidManifest.xml +1 -1
  5. package/android/src/main/java/com/trustchex/reactnativesdk/TrustchexSDKPackage.kt +45 -25
  6. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraManager.kt +168 -0
  7. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +871 -0
  8. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +245 -0
  9. package/android/src/main/java/com/trustchex/reactnativesdk/mrz/MRZValidationModule.kt +785 -0
  10. package/android/src/main/java/com/trustchex/reactnativesdk/mrz/MRZValidator.kt +419 -0
  11. package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +818 -0
  12. package/ios/Camera/TrustchexCameraManager.m +37 -0
  13. package/ios/Camera/TrustchexCameraManager.swift +125 -0
  14. package/ios/Camera/TrustchexCameraView.swift +1176 -0
  15. package/ios/MLKit/MLKitModule.m +23 -0
  16. package/ios/MLKit/MLKitModule.swift +250 -0
  17. package/ios/MRZValidation.m +39 -0
  18. package/ios/MRZValidation.swift +802 -0
  19. package/ios/MRZValidator.swift +466 -0
  20. package/ios/OpenCV/OpenCVModule.h +4 -0
  21. package/ios/OpenCV/OpenCVModule.mm +810 -0
  22. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +2 -3
  23. package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +1 -2
  24. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +418 -193
  25. package/lib/module/Screens/Static/OTPVerificationScreen.js +11 -11
  26. package/lib/module/Screens/Static/QrCodeScanningScreen.js +5 -1
  27. package/lib/module/Screens/Static/ResultScreen.js +25 -2
  28. package/lib/module/Screens/Static/VerificationSessionCheckScreen.js +25 -7
  29. package/lib/module/Shared/Components/DebugNavigationPanel.js +234 -24
  30. package/lib/module/Shared/Components/EIDScanner.js +99 -9
  31. package/lib/module/Shared/Components/FaceCamera.js +170 -179
  32. package/lib/module/Shared/Components/IdentityDocumentCamera.js +2151 -771
  33. package/lib/module/Shared/Components/QrCodeScannerCamera.js +109 -107
  34. package/lib/module/Shared/Components/TrustchexCamera.js +122 -0
  35. package/lib/module/Shared/EIDReader/tlv/tlv.helpers.js +91 -0
  36. package/lib/module/Shared/EIDReader/tlv/tlv.utils.js +2 -124
  37. package/lib/module/Shared/EIDReader/tlv/tlvInputStream.js +4 -4
  38. package/lib/module/Shared/EIDReader/tlv/tlvOutputState.js +4 -4
  39. package/lib/module/Shared/EIDReader/tlv/tlvOutputStream.js +4 -4
  40. package/lib/module/Shared/Libs/analytics.utils.js +2 -2
  41. package/lib/module/Shared/Libs/debug.utils.js +132 -0
  42. package/lib/module/Shared/Libs/deeplink.utils.js +6 -5
  43. package/lib/module/Shared/Libs/demo.utils.js +13 -3
  44. package/lib/module/Shared/Libs/mrz.utils.js +1 -175
  45. package/lib/module/Shared/Libs/native-device-info.utils.js +12 -6
  46. package/lib/module/Shared/Libs/tts.utils.js +40 -6
  47. package/lib/module/Shared/Services/AnalyticsService.js +9 -8
  48. package/lib/module/Shared/Types/mrzFields.js +1 -0
  49. package/lib/module/Translation/Resources/en.js +87 -88
  50. package/lib/module/Translation/Resources/tr.js +84 -85
  51. package/lib/module/Trustchex.js +9 -2
  52. package/lib/module/index.js +1 -0
  53. package/lib/module/version.js +1 -1
  54. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  55. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  56. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  57. package/lib/typescript/src/Screens/Static/OTPVerificationScreen.d.ts.map +1 -1
  58. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  59. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  60. package/lib/typescript/src/Screens/Static/VerificationSessionCheckScreen.d.ts.map +1 -1
  61. package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
  62. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts +2 -2
  63. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  64. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts +18 -4
  65. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
  66. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts +3 -4
  67. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  68. package/lib/typescript/src/Shared/Components/QrCodeScannerCamera.d.ts +2 -1
  69. package/lib/typescript/src/Shared/Components/QrCodeScannerCamera.d.ts.map +1 -1
  70. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +124 -0
  71. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -0
  72. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.helpers.d.ts +11 -0
  73. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.helpers.d.ts.map +1 -0
  74. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.utils.d.ts +2 -39
  75. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.utils.d.ts.map +1 -1
  76. package/lib/typescript/src/Shared/Libs/analytics.utils.d.ts.map +1 -1
  77. package/lib/typescript/src/Shared/Libs/debug.utils.d.ts +42 -0
  78. package/lib/typescript/src/Shared/Libs/debug.utils.d.ts.map +1 -0
  79. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  80. package/lib/typescript/src/Shared/Libs/demo.utils.d.ts.map +1 -1
  81. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  82. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +0 -4
  83. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  84. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  85. package/lib/typescript/src/Shared/Libs/tts.utils.d.ts +4 -3
  86. package/lib/typescript/src/Shared/Libs/tts.utils.d.ts.map +1 -1
  87. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  88. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +2 -2
  89. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
  90. package/lib/typescript/src/Shared/Types/mrzFields.d.ts +11 -0
  91. package/lib/typescript/src/Shared/Types/mrzFields.d.ts.map +1 -0
  92. package/lib/typescript/src/Translation/Resources/en.d.ts +4 -5
  93. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  94. package/lib/typescript/src/Translation/Resources/tr.d.ts +4 -5
  95. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  96. package/lib/typescript/src/Trustchex.d.ts +2 -0
  97. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  98. package/lib/typescript/src/index.d.ts +1 -0
  99. package/lib/typescript/src/index.d.ts.map +1 -1
  100. package/lib/typescript/src/version.d.ts +1 -1
  101. package/package.json +4 -35
  102. package/src/Screens/Dynamic/ContractAcceptanceScreen.tsx +1 -1
  103. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +7 -5
  104. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +2 -3
  105. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +498 -216
  106. package/src/Screens/Static/OTPVerificationScreen.tsx +37 -31
  107. package/src/Screens/Static/QrCodeScanningScreen.tsx +8 -1
  108. package/src/Screens/Static/ResultScreen.tsx +136 -88
  109. package/src/Screens/Static/VerificationSessionCheckScreen.tsx +46 -13
  110. package/src/Shared/Components/DebugNavigationPanel.tsx +290 -34
  111. package/src/Shared/Components/EIDScanner.tsx +94 -16
  112. package/src/Shared/Components/FaceCamera.tsx +236 -203
  113. package/src/Shared/Components/IdentityDocumentCamera.tsx +3073 -1030
  114. package/src/Shared/Components/QrCodeScannerCamera.tsx +133 -127
  115. package/src/Shared/Components/TrustchexCamera.tsx +289 -0
  116. package/src/Shared/Config/camera-enhancement.config.ts +2 -2
  117. package/src/Shared/EIDReader/tlv/tlv.helpers.ts +96 -0
  118. package/src/Shared/EIDReader/tlv/tlv.utils.ts +2 -125
  119. package/src/Shared/EIDReader/tlv/tlvInputStream.ts +4 -4
  120. package/src/Shared/EIDReader/tlv/tlvOutputState.ts +4 -4
  121. package/src/Shared/EIDReader/tlv/tlvOutputStream.ts +4 -4
  122. package/src/Shared/Libs/analytics.utils.ts +48 -20
  123. package/src/Shared/Libs/debug.utils.ts +149 -0
  124. package/src/Shared/Libs/deeplink.utils.ts +7 -5
  125. package/src/Shared/Libs/demo.utils.ts +4 -0
  126. package/src/Shared/Libs/http-client.ts +12 -8
  127. package/src/Shared/Libs/mrz.utils.ts +1 -163
  128. package/src/Shared/Libs/native-device-info.utils.ts +12 -6
  129. package/src/Shared/Libs/tts.utils.ts +48 -6
  130. package/src/Shared/Services/AnalyticsService.ts +69 -24
  131. package/src/Shared/Types/identificationInfo.ts +2 -2
  132. package/src/Shared/Types/mrzFields.ts +29 -0
  133. package/src/Translation/Resources/en.ts +90 -100
  134. package/src/Translation/Resources/tr.ts +89 -97
  135. package/src/Translation/index.ts +1 -1
  136. package/src/Trustchex.tsx +21 -4
  137. package/src/index.tsx +14 -0
  138. package/src/version.ts +1 -1
  139. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/barcodescanner/BarcodeScannerFrameProcessorPlugin.kt +0 -301
  140. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/BitmapUtils.kt +0 -205
  141. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/CropperPlugin.kt +0 -72
  142. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/FrameMetadata.kt +0 -4
  143. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/facedetector/FaceDetectorFrameProcessorPlugin.kt +0 -303
  144. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/textrecognition/TextRecognitionFrameProcessorPlugin.kt +0 -115
  145. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin-Bridging-Header.h +0 -9
  146. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin.mm +0 -22
  147. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin.swift +0 -188
  148. package/ios/VisionCameraPlugins/Cropper/Cropper-Bridging-Header.h +0 -13
  149. package/ios/VisionCameraPlugins/Cropper/Cropper.h +0 -20
  150. package/ios/VisionCameraPlugins/Cropper/Cropper.mm +0 -22
  151. package/ios/VisionCameraPlugins/Cropper/Cropper.swift +0 -145
  152. package/ios/VisionCameraPlugins/Cropper/CropperUtils.swift +0 -49
  153. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin-Bridging-Header.h +0 -4
  154. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin.mm +0 -22
  155. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin.swift +0 -320
  156. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin-Bridging-Header.h +0 -4
  157. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin.mm +0 -27
  158. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin.swift +0 -144
  159. package/lib/module/Shared/Libs/camera.utils.js +0 -308
  160. package/lib/module/Shared/Libs/frame-enhancement.utils.js +0 -133
  161. package/lib/module/Shared/Libs/opencv.utils.js +0 -21
  162. package/lib/module/Shared/Libs/worklet.utils.js +0 -68
  163. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.js +0 -46
  164. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.js +0 -35
  165. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/index.js +0 -19
  166. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.js +0 -26
  167. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/types.js +0 -3
  168. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.js +0 -197
  169. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.js +0 -101
  170. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.js +0 -60
  171. package/lib/module/Shared/VisionCameraPlugins/Cropper/index.js +0 -47
  172. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/Camera.js +0 -42
  173. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/detectFaces.js +0 -35
  174. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/index.js +0 -4
  175. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/types.js +0 -3
  176. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/Camera.js +0 -56
  177. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.js +0 -20
  178. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.js +0 -9
  179. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/index.js +0 -6
  180. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/scanText.js +0 -20
  181. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/translateText.js +0 -19
  182. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/types.js +0 -3
  183. package/lib/typescript/src/Shared/Libs/camera.utils.d.ts +0 -87
  184. package/lib/typescript/src/Shared/Libs/camera.utils.d.ts.map +0 -1
  185. package/lib/typescript/src/Shared/Libs/frame-enhancement.utils.d.ts +0 -25
  186. package/lib/typescript/src/Shared/Libs/frame-enhancement.utils.d.ts.map +0 -1
  187. package/lib/typescript/src/Shared/Libs/opencv.utils.d.ts +0 -3
  188. package/lib/typescript/src/Shared/Libs/opencv.utils.d.ts.map +0 -1
  189. package/lib/typescript/src/Shared/Libs/worklet.utils.d.ts +0 -9
  190. package/lib/typescript/src/Shared/Libs/worklet.utils.d.ts.map +0 -1
  191. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.d.ts +0 -13
  192. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.d.ts.map +0 -1
  193. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.d.ts +0 -6
  194. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.d.ts.map +0 -1
  195. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/index.d.ts +0 -12
  196. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/index.d.ts.map +0 -1
  197. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.d.ts +0 -3
  198. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.d.ts.map +0 -1
  199. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/types.d.ts +0 -52
  200. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/types.d.ts.map +0 -1
  201. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.d.ts +0 -62
  202. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.d.ts.map +0 -1
  203. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.d.ts +0 -34
  204. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.d.ts.map +0 -1
  205. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.d.ts +0 -32
  206. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.d.ts.map +0 -1
  207. package/lib/typescript/src/Shared/VisionCameraPlugins/Cropper/index.d.ts +0 -23
  208. package/lib/typescript/src/Shared/VisionCameraPlugins/Cropper/index.d.ts.map +0 -1
  209. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/Camera.d.ts +0 -9
  210. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/Camera.d.ts.map +0 -1
  211. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.d.ts +0 -3
  212. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.d.ts.map +0 -1
  213. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/index.d.ts +0 -3
  214. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/index.d.ts.map +0 -1
  215. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/types.d.ts +0 -79
  216. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/types.d.ts.map +0 -1
  217. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/Camera.d.ts +0 -6
  218. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/Camera.d.ts.map +0 -1
  219. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.d.ts +0 -3
  220. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.d.ts.map +0 -1
  221. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.d.ts +0 -3
  222. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.d.ts.map +0 -1
  223. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/index.d.ts +0 -5
  224. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/index.d.ts.map +0 -1
  225. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/scanText.d.ts +0 -3
  226. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/scanText.d.ts.map +0 -1
  227. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/translateText.d.ts +0 -3
  228. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/translateText.d.ts.map +0 -1
  229. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/types.d.ts +0 -67
  230. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/types.d.ts.map +0 -1
  231. package/src/Shared/Libs/camera.utils.ts +0 -345
  232. package/src/Shared/Libs/frame-enhancement.utils.ts +0 -217
  233. package/src/Shared/Libs/opencv.utils.ts +0 -40
  234. package/src/Shared/Libs/worklet.utils.ts +0 -72
  235. package/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.ts +0 -79
  236. package/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.ts +0 -46
  237. package/src/Shared/VisionCameraPlugins/BarcodeScanner/index.ts +0 -60
  238. package/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.ts +0 -32
  239. package/src/Shared/VisionCameraPlugins/BarcodeScanner/types.ts +0 -82
  240. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.ts +0 -195
  241. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.ts +0 -135
  242. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.ts +0 -84
  243. package/src/Shared/VisionCameraPlugins/Cropper/index.ts +0 -78
  244. package/src/Shared/VisionCameraPlugins/FaceDetector/Camera.tsx +0 -63
  245. package/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.ts +0 -44
  246. package/src/Shared/VisionCameraPlugins/FaceDetector/index.ts +0 -3
  247. package/src/Shared/VisionCameraPlugins/FaceDetector/types.ts +0 -99
  248. package/src/Shared/VisionCameraPlugins/TextRecognition/Camera.tsx +0 -76
  249. package/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.ts +0 -18
  250. package/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.ts +0 -7
  251. package/src/Shared/VisionCameraPlugins/TextRecognition/index.ts +0 -7
  252. package/src/Shared/VisionCameraPlugins/TextRecognition/scanText.ts +0 -27
  253. package/src/Shared/VisionCameraPlugins/TextRecognition/translateText.ts +0 -21
  254. package/src/Shared/VisionCameraPlugins/TextRecognition/types.ts +0 -141
@@ -1,60 +1,41 @@
1
1
  /* eslint-disable react-native/no-inline-styles */
2
- import React, { useEffect, useState } from 'react';
2
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
3
3
  import {
4
4
  View,
5
5
  StyleSheet,
6
6
  Text as TextView,
7
7
  Platform,
8
+ StatusBar,
8
9
  Vibration,
9
- TouchableOpacity,
10
- type GestureResponderEvent,
11
- Text,
12
10
  Linking,
13
11
  Image,
14
12
  ActivityIndicator,
13
+ PermissionsAndroid,
14
+ Dimensions,
15
+ type NativeSyntheticEvent,
16
+ type ViewStyle,
15
17
  } from 'react-native';
16
18
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
19
  import {
18
- Camera,
19
- runAtTargetFps,
20
- useCameraDevice,
21
- useCameraFormat,
22
- useCameraPermission,
23
- useFrameProcessor,
24
- } from 'react-native-vision-camera';
25
- import type { Frame } from 'react-native-vision-camera';
26
- import { runAsync } from '../Libs/worklet.utils';
27
- import { useRunOnJS, useSharedValue } from 'react-native-worklets-core';
28
- import { useTextRecognition } from '../VisionCameraPlugins/TextRecognition';
29
- import type { FieldRecords } from 'mrz';
30
- import {
31
- type Face,
32
- useFaceDetector,
33
- } from '../VisionCameraPlugins/FaceDetector';
34
- import mrzUtils from '../Libs/mrz.utils';
35
- import { crop, type CropResult } from '../VisionCameraPlugins/Cropper';
20
+ TrustchexCamera,
21
+ type TrustchexCameraHandle,
22
+ type Frame,
23
+ } from './TrustchexCamera';
24
+ import { NativeModules } from 'react-native';
25
+ import type { MRZFields } from '../Types/mrzFields';
36
26
  import { useKeepAwake } from '../Libs/native-keep-awake.utils';
37
- import ImageEditor from '@react-native-community/image-editor';
38
27
  import { useIsFocused } from '@react-navigation/native';
39
- import {
40
- AdaptiveThresholdTypes,
41
- ColorConversionCodes,
42
- DataTypes,
43
- type Mat,
44
- ObjectType,
45
- OpenCV,
46
- ThresholdTypes,
47
- } from 'react-native-fast-opencv';
48
- import { getAverageBrightness, getScanAreaCenterPoint, calculateExposureStep, isBlurry as checkBlurry } from '../Libs/camera.utils';
49
28
  import { useTranslation } from 'react-i18next';
29
+ import { debugLog, logError, isDebugEnabled } from '../Libs/debug.utils';
50
30
  import LottieView from 'lottie-react-native';
51
31
  import StyledButton from './StyledButton';
52
32
  import { SafeAreaView } from 'react-native-safe-area-context';
53
- import { type Barcode, scanCodes } from '../VisionCameraPlugins/BarcodeScanner';
54
- import { speakWithDebounce } from '../Libs/tts.utils';
33
+ import { speak, resetLastMessage } from '../Libs/tts.utils';
55
34
  import AppContext from '../Contexts/AppContext';
56
35
  import { useTheme } from '../Contexts/ThemeContext';
57
36
 
37
+ const { OpenCVModule } = NativeModules;
38
+
58
39
  export type DocumentScannedData = {
59
40
  documentType: 'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN';
60
41
  image: string;
@@ -63,7 +44,7 @@ export type DocumentScannedData = {
63
44
  hologramImage?: string;
64
45
  barcodeValue?: string;
65
46
  mrzText?: string;
66
- mrzFields?: FieldRecords;
47
+ mrzFields?: MRZFields;
67
48
  };
68
49
 
69
50
  export type BlockText = {
@@ -107,63 +88,64 @@ type ElementsData = [
107
88
  export type PhotoOptions = {
108
89
  uri: string;
109
90
  orientation?:
110
- | 'landscapeRight'
111
- | 'portrait'
112
- | 'portraitUpsideDown'
113
- | 'landscapeLeft';
91
+ | 'landscapeRight'
92
+ | 'portrait'
93
+ | 'portraitUpsideDown'
94
+ | 'landscapeLeft';
114
95
  };
115
96
 
116
97
  export interface IdentityDocumentCameraProps {
117
98
  onlyMRZScan: boolean;
118
99
  onIdentityDocumentScanned: (scannedData: DocumentScannedData) => void;
119
- showDebugImages?: boolean; // For development: show detected face and other images
120
100
  }
121
101
 
122
- // const windowWidth = Dimensions.get('window').width;
123
- // const windowHeight = Dimensions.get('window').height;
102
+ interface Face {
103
+ bounds: { x: number; y: number; width: number; height: number };
104
+ rollAngle?: number;
105
+ pitchAngle?: number;
106
+ yawAngle?: number;
107
+ leftEyeOpenProbability?: number;
108
+ rightEyeOpenProbability?: number;
109
+ smilingProbability?: number;
110
+ }
124
111
 
125
- const HOLOGRAM_IMAGE_COUNT = 7;
126
- const HOLOGRAM_DETECTION_THRESHOLD = 3500;
127
- const HOLOGRAM_DETECTION_RETRY_COUNT = 3;
128
- const SECOND_FACE_DETECTION_RETRY_COUNT = 10;
129
- const MRZ_VALIDATION_RETRY_COUNT = 3;
112
+ interface Barcode {
113
+ rawValue: string;
114
+ displayValue: string;
115
+ format: number;
116
+ boundingBox: { left: number; top: number; right: number; bottom: number };
117
+ cornerPoints: Array<{ x: number; y: number }>;
118
+ value?: string;
119
+ }
130
120
 
131
- let faceImages: string[] = [];
121
+ const HOLOGRAM_IMAGE_COUNT = 12;
122
+ const HOLOGRAM_DETECTION_THRESHOLD = 1000; // Lowered for better sensitivity while still filtering noise
123
+ const HOLOGRAM_DETECTION_RETRY_COUNT = 3; // More attempts but faster each (~1s per attempt)
124
+ const SECOND_FACE_DETECTION_RETRY_COUNT = 10;
125
+ const MIN_BRIGHTNESS_THRESHOLD = 60;
126
+ const MAX_CONSECUTIVE_QUALITY_FAILURES = 30;
132
127
 
133
128
  const IdentityDocumentCamera = ({
134
129
  onlyMRZScan,
135
130
  onIdentityDocumentScanned,
136
- showDebugImages = false,
137
131
  }: IdentityDocumentCameraProps) => {
138
132
  useKeepAwake();
139
133
  const theme = useTheme();
140
134
  const insets = useSafeAreaInsets();
141
135
  const appContext = React.useContext(AppContext);
142
- const cameraRef = React.useRef<Camera>(null);
143
- const cameraPermission = useCameraPermission();
144
- const [permissionsRequested, setPermissionsRequested] = React.useState(false);
145
- const [isActive, setIsActive] = React.useState(false);
136
+ const cameraRef = useRef<TrustchexCameraHandle>(null);
137
+ const [hasPermission, setHasPermission] = useState(false);
138
+ const [permissionsRequested, setPermissionsRequested] = useState(false);
139
+ const [isActive, setIsActive] = useState(false);
146
140
  const isFocused = useIsFocused();
147
- const [isTorchOn, setIsTorchOn] = React.useState(false);
148
- const exposureValue = useSharedValue(0);
149
- const [exposure, setExposure] = React.useState(0);
150
- const device = useCameraDevice('back', {
151
- physicalDevices: ['wide-angle-camera'],
152
- });
153
- const format = useCameraFormat(device, [
154
- {
155
- videoResolution: {
156
- width: 1920,
157
- height: 1080,
158
- },
159
- iso: 'max',
160
- photoHdr: false,
161
- videoHdr: false,
162
- videoStabilizationMode: 'standard',
163
- autoFocusSystem: 'phase-detection',
164
- },
165
- ]);
166
- const isCameraInitialized = useSharedValue(false);
141
+ const isTorchOnRef = useRef(false);
142
+ const [isTorchOn, _setIsTorchOn] = useState(false);
143
+ const setIsTorchOn = useCallback((val: boolean) => {
144
+ isTorchOnRef.current = val;
145
+ _setIsTorchOn(val);
146
+ }, []);
147
+ const [_exposure, _setExposure] = useState(0);
148
+ const isCameraInitialized = useRef(false);
167
149
  const [currentFaceImage, setCurrentFaceImage] = useState<string | undefined>(
168
150
  undefined
169
151
  );
@@ -191,152 +173,354 @@ const IdentityDocumentCamera = ({
191
173
  const [detectedDocumentType, setDetectedDocumentType] = useState<
192
174
  'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN'
193
175
  >('UNKNOWN');
194
- const hologramDetectionCurrentRetryCount = useSharedValue(0);
195
- const secondaryFaceDetectionCurrentRetryCount = useSharedValue(0);
196
- const mrzDetectionCurrentRetryCount = useSharedValue(0);
197
- const faceDetectionErrorCount = useSharedValue(0);
198
- const consecutiveBlurCount = useSharedValue(0);
176
+ const hologramDetectionCurrentRetryCount = useRef(0);
177
+ const secondaryFaceDetectionCurrentRetryCount = useRef(0);
178
+ const consecutiveQualityFailures = useRef(0);
179
+ const mrzDetectionCurrentRetryCount = useRef(0);
180
+
181
+ // MRZ stability tracking - require consistent valid reads
182
+ const lastValidMRZText = useRef<string | null>(null);
183
+ const lastValidMRZFields = useRef<any>(null);
184
+ const validMRZConsecutiveCount = useRef(0);
185
+ const REQUIRED_CONSISTENT_MRZ_READS = 2; // Require 2 consistent valid reads
186
+
187
+ // Document type stability tracking - require consistent detections from good quality frames
188
+ const lastDetectedDocType = useRef<
189
+ 'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN'
190
+ >('UNKNOWN');
191
+ const consistentDocTypeCount = useRef(0);
192
+ const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3; // Require 3 consistent detections from quality frames
193
+
194
+ // Frame quality tracking - persist across callbacks
195
+ const lastFrameQuality = useRef({
196
+ hasAcceptableQuality: true,
197
+ isBlurry: false,
198
+ brightness: 128,
199
+ });
200
+
201
+ // Barcode caching - persist detected barcode across frames for reliability
202
+ const cachedBarcode = useRef<Barcode | null>(null);
203
+
204
+ // Helper to compare MRZ field values (ignore raw text variations)
205
+ const areMRZFieldsEqual = useCallback(
206
+ (fields1: any, fields2: any): boolean => {
207
+ if (!fields1 || !fields2) return false;
208
+ // Compare critical fields that define document identity
209
+ return (
210
+ fields1.documentNumber === fields2.documentNumber &&
211
+ fields1.birthDate === fields2.birthDate &&
212
+ fields1.expirationDate === fields2.expirationDate &&
213
+ fields1.firstName === fields2.firstName &&
214
+ fields1.lastName === fields2.lastName &&
215
+ fields1.issuingState === fields2.issuingState
216
+ );
217
+ },
218
+ []
219
+ );
220
+
221
+ // Helper functions to reduce duplication
222
+
223
+ /**
224
+ * Check if all required MRZ fields are present
225
+ */
226
+ const hasRequiredMRZFields = useCallback(
227
+ (fields: any): boolean =>
228
+ !!fields?.firstName &&
229
+ !!fields?.lastName &&
230
+ !!fields?.documentNumber &&
231
+ !!fields?.birthDate,
232
+ []
233
+ );
234
+
235
+ /**
236
+ * Log detailed MRZ information for debugging and verification
237
+ */
238
+ const logMRZDetails = useCallback(
239
+ (
240
+ stepName: string,
241
+ fields: any,
242
+ mrzText: string | null,
243
+ consecutiveReads: number,
244
+ isDebugMode: boolean
245
+ ) => {
246
+ if (isDebugMode) {
247
+ debugLog(
248
+ 'IdentityDocumentCamera',
249
+ `[${stepName}] MRZ validated successfully (consistent reads: ${consecutiveReads})`
250
+ );
251
+ debugLog('IdentityDocumentCamera', `[${stepName}] Final MRZ Fields:`, {
252
+ documentNumber: fields?.documentNumber,
253
+ name: `${fields?.lastName} ${fields?.firstName}`,
254
+ birthDate: fields?.birthDate,
255
+ expirationDate: fields?.expirationDate,
256
+ nationality: fields?.nationality || fields?.issuingState,
257
+ sex: fields?.sex,
258
+ personalId: fields?.optional1,
259
+ });
260
+ if (mrzText) {
261
+ const mrzLines = mrzText
262
+ .split('\n')
263
+ .map((l) => l.replace(/\s/g, ''))
264
+ .filter((l) => l.length >= 20 && /^[A-Z0-9<]+$/.test(l));
265
+ debugLog(
266
+ 'IdentityDocumentCamera',
267
+ `[${stepName}] MRZ lines (${mrzLines.length}):`
268
+ );
269
+ mrzLines.forEach((line, idx) => {
270
+ debugLog('IdentityDocumentCamera', ` ${idx + 1}: ${line}`);
271
+ });
272
+ }
273
+ }
274
+ },
275
+ []
276
+ );
277
+
278
+ /**
279
+ * Log MRZ validation failure details for debugging
280
+ */
281
+ const logMRZValidationFailure = useCallback(
282
+ (
283
+ stepName: string,
284
+ hasRequiredFields: boolean,
285
+ parsedData: any,
286
+ retryCount: number,
287
+ isDebugMode: boolean
288
+ ) => {
289
+ if (isDebugMode) {
290
+ const debugInfo: any = {
291
+ hasRequiredFields,
292
+ isValid: parsedData?.valid,
293
+ retryCount,
294
+ };
295
+
296
+ if (parsedData?.valid) {
297
+ debugInfo.consistentReads = validMRZConsecutiveCount.current;
298
+ debugInfo.requiredReads = REQUIRED_CONSISTENT_MRZ_READS;
299
+ debugInfo.fieldsMatch = areMRZFieldsEqual(
300
+ lastValidMRZFields.current,
301
+ parsedData?.fields
302
+ );
303
+ }
304
+
305
+ debugLog(
306
+ 'IdentityDocumentCamera',
307
+ `[${stepName}] MRZ detected but validation failed - retrying`,
308
+ debugInfo
309
+ );
310
+ }
311
+ },
312
+ [areMRZFieldsEqual]
313
+ );
314
+
315
+ const lastHologramCaptureTime = useRef(0);
316
+ const HOLOGRAM_CAPTURE_INTERVAL = 250; // ms between captures - allows flash to fully stabilize for accurate diff
317
+ const hologramFramesWithoutFace = useRef(0); // Track consecutive frames without face during hologram collection
318
+ const HOLOGRAM_MAX_FRAMES_WITHOUT_FACE = 30; // ~1 second at 30fps - safety timeout
319
+
320
+ const faceDetectionErrorCount = useRef(0);
321
+ const brightnessHistory = useRef<number[]>([]);
199
322
  const [faceDetectionEnabled, setFaceDetectionEnabled] = useState(true);
323
+ const faceImages = useRef<string[]>([]);
324
+ const hologramImageCountRef = useRef(0);
325
+ const [hologramImageCount, setHologramImageCount] = useState(0);
326
+ const lastVoiceGuidanceMessage = useRef<string>('');
327
+ const [latestHologramFaceImage, setLatestHologramFaceImage] = useState<
328
+ string | undefined
329
+ >(undefined);
330
+ const lastFacePosition = useRef<{
331
+ x: number;
332
+ y: number;
333
+ width: number;
334
+ height: number;
335
+ } | null>(null);
336
+ const [documentPlaneBounds, setDocumentPlaneBounds] = useState<{
337
+ x: number;
338
+ y: number;
339
+ width: number;
340
+ height: number;
341
+ rollAngle?: number;
342
+ cropPadding?: number;
343
+ } | null>(null);
344
+ const [secondaryFaceBounds, setSecondaryFaceBounds] = useState<{
345
+ x: number;
346
+ y: number;
347
+ width: number;
348
+ height: number;
349
+ rollAngle?: number;
350
+ cropPadding?: number;
351
+ } | null>(null);
352
+ const [barcodeBounds, setBarcodeBounds] = useState<{
353
+ x: number;
354
+ y: number;
355
+ width: number;
356
+ height: number;
357
+ angle?: number;
358
+ corners?: Array<{ x: number; y: number }>;
359
+ } | null>(null);
360
+ const [mrzBounds, setMrzBounds] = useState<{
361
+ x: number;
362
+ y: number;
363
+ width: number;
364
+ height: number;
365
+ angle?: number;
366
+ corners?: Array<{ x: number; y: number }>;
367
+ } | null>(null);
368
+ const [signatureBounds, setSignatureBounds] = useState<{
369
+ x: number;
370
+ y: number;
371
+ width: number;
372
+ height: number;
373
+ angle?: number;
374
+ corners?: Array<{ x: number; y: number }>;
375
+ } | null>(null);
376
+ const [frameDimensions, setFrameDimensions] = useState<{
377
+ width: number;
378
+ height: number;
379
+ } | null>(null);
380
+
381
+ // Track if all required elements are detected in current frame
382
+ const [allElementsDetected, setAllElementsDetected] = useState(false);
383
+ // Track if detected elements are within scan area
384
+ const [elementsOutsideScanArea, setElementsOutsideScanArea] = useState<
385
+ string[]
386
+ >([]);
200
387
  const { t } = useTranslation();
201
- // const [boundingBox, setBoundingBox] = useState<Bounds>({
202
- // x: 0,
203
- // y: 0,
204
- // width: 0,
205
- // height: 0,
206
- // });
207
-
208
- const { scanText } = useTextRecognition({
209
- language: 'latin',
210
- });
211
- const { detectFaces } = useFaceDetector({
212
- contourMode: 'none',
213
- landmarkMode: 'none',
214
- classificationMode: 'all',
215
- performanceMode: 'accurate',
216
- trackingEnabled: false,
217
- minFaceSize: 0.1,
218
- autoScale: false,
219
- });
220
388
 
221
389
  useEffect(() => {
222
390
  const requestPermissions = async () => {
223
- if (!cameraPermission.hasPermission) {
224
- await cameraPermission.requestPermission();
391
+ if (Platform.OS === 'android') {
392
+ const granted = await PermissionsAndroid.request(
393
+ PermissionsAndroid.PERMISSIONS.CAMERA
394
+ );
395
+ setHasPermission(granted === PermissionsAndroid.RESULTS.GRANTED);
396
+ } else {
397
+ setHasPermission(true);
225
398
  }
226
399
  setPermissionsRequested(true);
227
400
  };
228
401
  requestPermissions();
229
- }, [cameraPermission]);
402
+ }, []);
230
403
 
231
404
  useEffect(() => {
232
- if (!!device && !!format && isFocused) {
405
+ if (isFocused && hasPermission && hasGuideShown) {
233
406
  setIsActive(true);
234
407
  } else {
235
408
  setIsActive(false);
236
- // Clear any pending OpenCV operations when camera becomes inactive
237
- try {
238
- OpenCV.clearBuffers();
239
- } catch (error) {
240
- // Ignore cleanup errors
241
- }
242
-
243
- // Reset face images array to free memory
244
- faceImages = [];
245
-
246
- // Reset retry counters
247
- hologramDetectionCurrentRetryCount.value = 0;
248
- secondaryFaceDetectionCurrentRetryCount.value = 0;
249
- mrzDetectionCurrentRetryCount.value = 0;
409
+ faceImages.current = [];
410
+ hologramImageCountRef.current = 0;
411
+ setHologramImageCount(0);
412
+ setLatestHologramFaceImage(undefined);
413
+ hologramDetectionCurrentRetryCount.current = 0;
414
+ secondaryFaceDetectionCurrentRetryCount.current = 0;
415
+ mrzDetectionCurrentRetryCount.current = 0;
416
+ lastValidMRZText.current = null;
417
+ lastValidMRZFields.current = null;
418
+ validMRZConsecutiveCount.current = 0;
419
+ lastValidMRZText.current = null;
420
+ lastValidMRZFields.current = null;
421
+ validMRZConsecutiveCount.current = 0;
422
+ cachedBarcode.current = null; // Clear cached barcode on new scan
423
+ lastVoiceGuidanceMessage.current = '';
424
+ resetLastMessage();
250
425
  }
251
426
 
252
427
  return () => {
253
428
  setIsActive(false);
254
- // Cleanup on unmount
255
- try {
256
- OpenCV.clearBuffers();
257
- } catch (error) {
258
- // Ignore cleanup errors
259
- }
260
-
261
- // Clear face images array
262
- faceImages = [];
429
+ faceImages.current = [];
430
+ hologramImageCountRef.current = 0;
431
+ setHologramImageCount(0);
432
+ setLatestHologramFaceImage(undefined);
433
+ lastVoiceGuidanceMessage.current = '';
434
+ resetLastMessage();
263
435
  };
264
- }, [
265
- device,
266
- format,
267
- isFocused,
268
- hologramDetectionCurrentRetryCount,
269
- secondaryFaceDetectionCurrentRetryCount,
270
- mrzDetectionCurrentRetryCount,
271
- faceDetectionErrorCount,
272
- ]);
436
+ }, [isFocused, hasPermission, hasGuideShown]);
273
437
 
274
438
  useEffect(() => {
275
439
  if (hasGuideShown) {
440
+ // Generate message - match UI display logic exactly for consistency
276
441
  let message = '';
277
442
 
278
- // Priority: scanned > incorrect > blur during scanning > brightness > blur > step-specific
279
443
  if (status === 'SCANNED') {
280
- // Use step-specific completion messages
281
- if (completedStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
282
- message = detectedDocumentType === 'PASSPORT'
283
- ? t('identityDocumentCamera.passportScanned')
284
- : t('identityDocumentCamera.frontSideScanned');
285
- } else if (completedStep === 'SCAN_ID_BACK') {
286
- message = t('identityDocumentCamera.backSideScanned');
287
- } else if (completedStep === 'SCAN_HOLOGRAM') {
288
- message = t('identityDocumentCamera.hologramVerified');
289
- } else {
290
- message = t('identityDocumentCamera.scanCompleted');
291
- }
444
+ message =
445
+ completedStep === 'SCAN_ID_FRONT_OR_PASSPORT'
446
+ ? detectedDocumentType === 'PASSPORT'
447
+ ? t('identityDocumentCamera.passportScanned')
448
+ : t('identityDocumentCamera.frontSideScanned')
449
+ : completedStep === 'SCAN_ID_BACK'
450
+ ? t('identityDocumentCamera.backSideScanned')
451
+ : completedStep === 'SCAN_HOLOGRAM'
452
+ ? t('identityDocumentCamera.hologramVerified')
453
+ : t('identityDocumentCamera.scanCompleted');
292
454
  } else if (status === 'INCORRECT') {
293
- // Wrong side detected - warn user
294
- if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
295
- message = t('identityDocumentCamera.wrongSideFront');
296
- } else if (nextStep === 'SCAN_ID_BACK') {
297
- message = t('identityDocumentCamera.wrongSideBack');
298
- }
455
+ message =
456
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
457
+ ? t('identityDocumentCamera.wrongSideFront')
458
+ : nextStep === 'SCAN_ID_BACK'
459
+ ? t('identityDocumentCamera.wrongSideBack')
460
+ : nextStep === 'SCAN_HOLOGRAM'
461
+ ? t('identityDocumentCamera.wrongSideFront')
462
+ : t('identityDocumentCamera.alignPhotoSide');
299
463
  } else if (isBrightnessLow) {
300
- // Brightness warning takes priority over blur
301
464
  message = t('identityDocumentCamera.lowBrightness');
302
465
  } else if (isFrameBlurry) {
303
- // Show blur warning only when brightness is sufficient
304
466
  message = t('identityDocumentCamera.avoidBlur');
305
- } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
306
- // Enhanced feedback based on detection status
307
- if (status === 'SCANNING') {
308
- if (currentFaceImage) {
309
- // Document-specific detection message
310
- if (detectedDocumentType === 'PASSPORT') {
311
- message = t('identityDocumentCamera.passportDetected');
312
- } else if (detectedDocumentType === 'ID_FRONT') {
313
- message = t('identityDocumentCamera.idCardFrontDetected');
314
- } else {
315
- message = t('identityDocumentCamera.readingDocument');
316
- }
317
- } else {
318
- message = t('identityDocumentCamera.readingDocument');
319
- }
320
- } else {
321
- message = t('identityDocumentCamera.alignPhotoSide');
322
- }
323
- } else if (nextStep === 'SCAN_HOLOGRAM') {
324
- message = t('identityDocumentCamera.alignHologram');
325
- } else if (nextStep === 'SCAN_ID_BACK') {
326
- if (status === 'SCANNING') {
327
- message = t('identityDocumentCamera.readingDocument');
328
- } else {
329
- message = t('identityDocumentCamera.alignIDBackSide');
330
- }
331
- } else if (nextStep === 'COMPLETED') {
332
- message = t('identityDocumentCamera.scanCompleted');
467
+ } else if (
468
+ status === 'SCANNING' &&
469
+ allElementsDetected &&
470
+ elementsOutsideScanArea.length === 0
471
+ ) {
472
+ message =
473
+ nextStep === 'SCAN_ID_BACK'
474
+ ? t('identityDocumentCamera.idCardBackDetected')
475
+ : detectedDocumentType === 'PASSPORT'
476
+ ? t('identityDocumentCamera.passportDetected')
477
+ : detectedDocumentType === 'ID_FRONT'
478
+ ? t('identityDocumentCamera.idCardFrontDetected')
479
+ : nextStep === 'SCAN_HOLOGRAM'
480
+ ? t('identityDocumentCamera.alignHologram')
481
+ : t('identityDocumentCamera.readingDocument');
482
+ } else if (elementsOutsideScanArea.length > 0) {
483
+ message = t('identityDocumentCamera.centerDocument');
484
+ } else if (
485
+ (status === 'SCANNING' || status === 'SEARCHING') &&
486
+ !allElementsDetected
487
+ ) {
488
+ message =
489
+ nextStep === 'SCAN_ID_BACK'
490
+ ? t('identityDocumentCamera.alignIDBack')
491
+ : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
492
+ ? detectedDocumentType === 'PASSPORT'
493
+ ? t('identityDocumentCamera.alignPassport')
494
+ : detectedDocumentType === 'ID_FRONT'
495
+ ? t('identityDocumentCamera.alignIDFront')
496
+ : t('identityDocumentCamera.alignPhotoSide')
497
+ : nextStep === 'SCAN_HOLOGRAM'
498
+ ? t('identityDocumentCamera.alignHologram')
499
+ : t('identityDocumentCamera.readingDocument');
500
+ } else {
501
+ message =
502
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
503
+ ? status === 'SCANNING'
504
+ ? t('identityDocumentCamera.readingDocument')
505
+ : t('identityDocumentCamera.alignPhotoSide')
506
+ : nextStep === 'SCAN_HOLOGRAM'
507
+ ? t('identityDocumentCamera.alignHologram')
508
+ : nextStep === 'SCAN_ID_BACK'
509
+ ? status === 'SCANNING'
510
+ ? t('identityDocumentCamera.readingDocument')
511
+ : t('identityDocumentCamera.alignIDBackSide')
512
+ : nextStep === 'COMPLETED'
513
+ ? t('identityDocumentCamera.scanCompleted')
514
+ : '';
333
515
  }
334
516
 
335
517
  if (
336
518
  appContext.currentWorkflowStep?.data?.voiceGuidanceActive &&
337
- message
519
+ message &&
520
+ message !== lastVoiceGuidanceMessage.current
338
521
  ) {
339
- speakWithDebounce(message);
522
+ lastVoiceGuidanceMessage.current = message;
523
+ speak(message, true);
340
524
  }
341
525
  }
342
526
  }, [
@@ -349,402 +533,287 @@ const IdentityDocumentCamera = ({
349
533
  completedStep,
350
534
  currentFaceImage,
351
535
  detectedDocumentType,
536
+ allElementsDetected,
537
+ elementsOutsideScanArea,
352
538
  t,
353
539
  ]);
354
540
 
355
- // Auto-reset INCORRECT status after showing warning briefly
356
541
  useEffect(() => {
357
542
  if (status === 'INCORRECT') {
358
543
  const timeout = setTimeout(() => {
359
544
  setStatus('SEARCHING');
360
- }, 1500); // Show warning for 1.5 seconds
545
+ }, 1500);
361
546
  return () => clearTimeout(timeout);
362
547
  }
363
548
  }, [status]);
364
549
 
365
- // Periodic autofocus - refocus on scan area center every 2.5 seconds
550
+ // Disable face detection when scanning back side (no face expected, avoids false positives)
366
551
  useEffect(() => {
367
- if (!isActive || !device || !cameraRef.current || !device.supportsFocus) {
368
- return;
369
- }
370
-
371
- // Only autofocus during searching and scanning states
372
- if (status !== 'SEARCHING' && status !== 'SCANNING') {
373
- return;
552
+ if (nextStep === 'SCAN_ID_BACK') {
553
+ setFaceDetectionEnabled(false);
554
+ } else {
555
+ setFaceDetectionEnabled(true);
374
556
  }
557
+ }, [nextStep]);
375
558
 
376
- const autofocusInterval = setInterval(async () => {
559
+ // Native OpenCV: detect hologram from sequence of face images
560
+ const detectHologramNative = useCallback(
561
+ async (images: string[]): Promise<[string, string] | []> => {
377
562
  try {
378
- // Get camera dimensions (assuming format dimensions)
379
- const width = format?.videoWidth ?? 1920;
380
- const height = format?.videoHeight ?? 1080;
381
-
382
- // Calculate center point of scan area
383
- const centerPoint = getScanAreaCenterPoint(width, height);
384
-
385
- // Focus on the center of the scan area
386
- await cameraRef.current?.focus({
387
- x: centerPoint.x,
388
- y: centerPoint.y,
389
- });
563
+ if (isDebugEnabled()) {
564
+ debugLog(
565
+ 'IdentityDocumentCamera',
566
+ `[Hologram] Detecting hologram from ${images.length} images`
567
+ );
568
+ }
569
+ // Limit images to prevent memory issues
570
+ const limitedImages = images.slice(0, HOLOGRAM_IMAGE_COUNT);
571
+ const result = await OpenCVModule.detectHologram(
572
+ limitedImages,
573
+ HOLOGRAM_DETECTION_THRESHOLD
574
+ );
575
+ if (result) {
576
+ return [result.hologramMask, result.hologramImage];
577
+ }
390
578
  } catch (error) {
391
- // Ignore autofocus errors
579
+ logError('[Hologram] Detection error:', error);
392
580
  }
393
- }, 2500); // Every 2.5 seconds
394
-
395
- return () => clearInterval(autofocusInterval);
396
- }, [isActive, device, format, status]);
397
-
398
- const detectDocumentType = (
399
- faces: Face[],
400
- ocrText: string,
401
- mrzFields?: FieldRecords
402
- ) => {
403
- if (
404
- faces.length > 0 &&
405
- !mrzFields &&
406
- ocrText?.includes('Signature')
407
- // ocrText?.includes('Surname') &&
408
- // ocrText?.includes('Given Name(s)') &&
409
- // ocrText?.includes('Date of Birth') &&
410
- // ocrText?.includes('Document No') &&
411
- // ocrText?.includes('Valid Until')
412
- ) {
413
- return 'ID_FRONT';
414
- } else if (
415
- faces.length === 0 &&
416
- mrzFields?.documentCode === 'I'
417
- // ocrText?.includes("Father's Name") &&
418
- // ocrText?.includes("Mother's Name") &&
419
- // ocrText?.includes('Issued By')
420
- ) {
421
- return 'ID_BACK';
422
- } else if (faces.length > 0 && mrzFields?.documentCode === 'P') {
423
- return 'PASSPORT';
424
- }
425
-
426
- return 'UNKNOWN';
427
- };
428
-
429
- // const setBoundingBoxInJS = useRunOnJS(
430
- // (bounds: Bounds) => {
431
- // setBoundingBox(bounds);
432
- // },
433
- // [setBoundingBox],
434
- // );
435
-
436
- // const isBlockInFrame = (block: BlocksData) => {
437
- // 'worklet';
438
- // const scanningFrame = {
439
- // x: 0.03 * 1080,
440
- // y: 0.35 * 1920,
441
- // width: 0.94 * 1080,
442
- // height: 0.3 * 1920,
443
- // } as Bounds;
444
- // const bounds = {
445
- // x: block.blockFrame.x,
446
- // y: block.blockFrame.y,
447
- // width: block.blockFrame.width,
448
- // height: block.blockFrame.height,
449
- // } as Bounds;
450
-
451
- // if (
452
- // bounds.x >= scanningFrame.x &&
453
- // bounds.y >= scanningFrame.y &&
454
- // bounds.x + bounds.width <= scanningFrame.x + scanningFrame.width &&
455
- // bounds.y + bounds.height <= scanningFrame.y + scanningFrame.height
456
- // ) {
457
- // return true;
458
- // }
459
-
460
- // setBoundingBoxInJS({
461
- // x: (bounds.x / 1080) * windowWidth,
462
- // y: (bounds.y / 1920) * windowHeight,
463
- // width: (bounds.width / 1080) * windowWidth,
464
- // height: (bounds.height / 1920) * windowHeight,
465
- // });
466
-
467
- // return false;
468
- // };
469
-
470
- const applyThreshold = (image: Mat) => {
471
- const gray = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
472
-
473
- // Convert to grayscale
474
- OpenCV.invoke('cvtColor', image, gray, ColorConversionCodes.COLOR_RGB2GRAY);
475
-
476
- // Apply GaussianBlur to reduce noise
477
- const kSize = OpenCV.createObject(ObjectType.Size, 5, 5);
478
- OpenCV.invoke('GaussianBlur', gray, gray, kSize, 0);
479
-
480
- // Apply Otsu's thresholding
481
- OpenCV.invoke(
482
- 'threshold',
483
- gray,
484
- gray,
485
- 0,
486
- 255,
487
- ThresholdTypes.THRESH_BINARY + ThresholdTypes.THRESH_OTSU
488
- );
489
-
490
- return gray;
491
- };
581
+ return [];
582
+ },
583
+ []
584
+ );
492
585
 
493
- const areImagesSimilar = (
586
+ // Native OpenCV: compare two images for similarity
587
+ const areImagesSimilarNative = async (
494
588
  image1: string,
495
589
  image2: string,
496
- threshold = 15000
497
- ) => {
590
+ threshold = 20000 // Relaxed threshold for better tolerance of lighting/angle variations
591
+ ): Promise<boolean> => {
498
592
  try {
499
- if (!image1 || !image2) {
500
- return false;
501
- }
502
- const mat1 = OpenCV.base64ToMat(image1);
503
- const mat2 = OpenCV.base64ToMat(image2);
504
- const diff = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
505
- OpenCV.invoke(
506
- 'absdiff',
507
- applyThreshold(mat1),
508
- applyThreshold(mat2),
509
- diff
510
- );
511
- const count = OpenCV.invoke('countNonZero', diff);
512
-
513
- return count.value < threshold;
593
+ if (!image1 || !image2) return false;
594
+ return await OpenCVModule.areImagesSimilar(image1, image2, threshold);
514
595
  } catch (error) {
515
- // console.log('Error while comparing images:', error);
516
596
  return false;
517
597
  }
518
598
  };
519
599
 
520
-
521
- const detectHologram = (images: string[]) => {
522
- try {
523
- const lowerBound = OpenCV.createObject(ObjectType.Scalar, 40, 90, 90);
524
- const upperBound = OpenCV.createObject(ObjectType.Scalar, 179, 255, 255);
525
- const diffs = [];
526
- const hologram = OpenCV.base64ToMat(images[0]);
527
- for (let i = 0; i < images.length - 1; i++) {
528
- const mat1 = OpenCV.base64ToMat(images[i]);
529
- const mat2 = OpenCV.base64ToMat(images[i + 1]);
530
- let diff = OpenCV.createObject(ObjectType.Mat, 0, 0, DataTypes.CV_8U);
531
- OpenCV.invoke('absdiff', mat1, mat2, diff);
532
- OpenCV.invoke(
533
- 'cvtColor',
534
- diff,
535
- diff,
536
- ColorConversionCodes.COLOR_RGB2HSV
537
- );
538
- OpenCV.invoke('inRange', diff, lowerBound, upperBound, diff);
539
- if (OpenCV.invoke('countNonZero', diff).value > 500) {
540
- OpenCV.invoke('addWeighted', hologram, 0.5, mat2, 0.5, 0, hologram);
541
- diffs.push(diff);
542
- }
543
- }
544
-
545
- const hologramMask = diffs[0];
546
- for (let i = 1; i < diffs.length; i++) {
547
- OpenCV.invoke(
548
- 'addWeighted',
549
- hologramMask,
550
- 0.5,
551
- diffs[i],
552
- 0.5,
553
- 0,
554
- hologramMask
555
- );
556
- }
557
- OpenCV.invoke(
558
- 'adaptiveThreshold',
559
- hologramMask,
560
- hologramMask,
561
- 255,
562
- AdaptiveThresholdTypes.ADAPTIVE_THRESH_GAUSSIAN_C,
563
- ThresholdTypes.THRESH_BINARY_INV,
564
- 21,
565
- 2
566
- );
567
- const count = OpenCV.invoke('countNonZero', hologramMask);
568
- if (count.value > HOLOGRAM_DETECTION_THRESHOLD) {
569
- const hologramMaskJs = OpenCV.toJSValue(hologramMask);
570
- const hologramJs = OpenCV.toJSValue(hologram);
571
- return [hologramMaskJs.base64, hologramJs.base64];
572
- }
573
- } catch (error) {
574
- // console.log('Error while detecting hologram:', error);
575
- }
576
-
577
- return [];
578
- };
579
-
580
- const getFaceImagesOrderedByLocation = (faces: Face[]) => {
581
- return faces.sort((a, b) => {
582
- if (a.bounds.x < b.bounds.x) {
583
- return -1;
584
- } else if (a.bounds.x > b.bounds.x) {
585
- return 1;
586
- }
587
- return 0;
588
- });
589
- };
590
-
600
+ // Native OpenCV: crop face images from full frame
591
601
  const getFaceImages = async (
592
- faces: Face[],
602
+ facesToDetect: Face[],
593
603
  image: string,
594
604
  width: number,
595
605
  height: number
596
- ) => {
597
- if (!faces.length || !image || width <= 0 || height <= 0) {
606
+ ): Promise<string[]> => {
607
+ if (!facesToDetect.length || !image || width <= 0 || height <= 0) {
598
608
  return [];
599
609
  }
600
-
601
- const croppedFaces = [];
602
-
603
610
  try {
604
- for (const face of getFaceImagesOrderedByLocation(faces)) {
605
- const uri = `data:image/jpeg;base64,${image}`;
611
+ const faceBounds = facesToDetect.map((f) => ({
612
+ x: f.bounds.x,
613
+ y: f.bounds.y,
614
+ width: f.bounds.width,
615
+ height: f.bounds.height,
616
+ }));
617
+ const croppedFaces: string[] = await OpenCVModule.cropFaceImages(
618
+ image,
619
+ faceBounds,
620
+ width,
621
+ height
622
+ );
623
+ return croppedFaces ?? [];
624
+ } catch (error) {
625
+ logError('[getFaceImages] Native face crop failed:', error);
626
+ return [];
627
+ }
628
+ };
606
629
 
607
- // Add validation for face bounds to prevent invalid crop operations
608
- if (
609
- face.bounds.x < 0 ||
610
- face.bounds.y < 0 ||
611
- face.bounds.width <= 0 ||
612
- face.bounds.height <= 0 ||
613
- face.bounds.x >= width ||
614
- face.bounds.y >= height
615
- ) {
616
- // console.warn(
617
- // 'Invalid face bounds detected, skipping face:',
618
- // face.bounds
619
- // );
620
- continue;
621
- }
630
+ const setNextStepAndVibrate = useCallback(
631
+ (
632
+ nextStepType:
633
+ | 'SCAN_ID_FRONT_OR_PASSPORT'
634
+ | 'SCAN_ID_BACK'
635
+ | 'SCAN_HOLOGRAM'
636
+ | 'COMPLETED',
637
+ fromStep?: 'SCAN_ID_FRONT_OR_PASSPORT' | 'SCAN_ID_BACK' | 'SCAN_HOLOGRAM'
638
+ ) => {
639
+ if (fromStep) {
640
+ setCompletedStep(fromStep);
641
+ }
622
642
 
623
- // Calculate crop area with bounds checking
624
- const expandedWidth = face.bounds.width * 1.5;
625
- const expandedHeight = face.bounds.height * 1.5;
626
- const offsetX = Math.max(0, face.bounds.x - expandedWidth / 6);
627
- const offsetY =
628
- Platform.OS === 'ios'
629
- ? Math.max(0, face.bounds.y - expandedHeight / 6)
630
- : Math.max(0, width - face.bounds.y - expandedHeight / 1.2);
631
-
632
- const croppedFace = await ImageEditor.cropImage(uri, {
633
- offset: {
634
- x: offsetX,
635
- y: offsetY,
636
- },
637
- size: {
638
- width: expandedWidth,
639
- height: expandedHeight,
640
- },
641
- displaySize: {
642
- width: 240,
643
- height: 320,
644
- },
645
- includeBase64: true,
646
- quality: 1,
647
- });
643
+ // Turn flash on and reset hologram counters when entering SCAN_HOLOGRAM
644
+ if (nextStepType === 'SCAN_HOLOGRAM' && fromStep !== 'SCAN_HOLOGRAM') {
645
+ setIsTorchOn(true);
646
+ // Reset hologram detection counters for fresh start
647
+ hologramDetectionCurrentRetryCount.current = 0;
648
+ secondaryFaceDetectionCurrentRetryCount.current = 0;
649
+ hologramFramesWithoutFace.current = 0;
650
+ faceImages.current = [];
651
+ hologramImageCountRef.current = 0;
652
+ setHologramImageCount(0);
653
+ setLatestHologramFaceImage(undefined);
654
+ }
648
655
 
649
- if (croppedFace.width !== 240 || croppedFace.height !== 320) {
650
- try {
651
- const croppedFaceMat = OpenCV.base64ToMat(croppedFace.base64);
652
-
653
- // Ensure crop dimensions are valid for the matrix
654
- const matCropWidth = Math.min(240, croppedFace.width);
655
- const matCropHeight = Math.min(320, croppedFace.height);
656
-
657
- // Only crop if we have valid dimensions
658
- if (matCropWidth > 0 && matCropHeight > 0) {
659
- OpenCV.invoke(
660
- 'crop',
661
- croppedFaceMat,
662
- croppedFaceMat,
663
- OpenCV.createObject(
664
- ObjectType.Rect,
665
- 0,
666
- 0,
667
- matCropWidth,
668
- matCropHeight
669
- )
670
- );
671
- croppedFaces.push(OpenCV.toJSValue(croppedFaceMat).base64);
672
- } else {
673
- // Fallback to original base64 if crop dimensions are invalid
674
- croppedFaces.push(croppedFace.base64);
675
- }
676
- } catch (cropError) {
677
- console.warn('OpenCV crop operation failed:', cropError);
678
- // Fallback to original image if OpenCV crop fails
679
- croppedFaces.push(croppedFace.base64);
680
- }
681
- } else {
682
- croppedFaces.push(croppedFace.base64);
656
+ // Clean up flash and hologram state when leaving SCAN_HOLOGRAM step
657
+ if (fromStep === 'SCAN_HOLOGRAM' && nextStepType !== 'SCAN_HOLOGRAM') {
658
+ setIsTorchOn(false);
659
+ faceImages.current = [];
660
+ hologramImageCountRef.current = 0;
661
+ setHologramImageCount(0);
662
+ setLatestHologramFaceImage(undefined);
663
+ lastFacePosition.current = null; // Reset document plane reference
664
+ cachedBarcode.current = null; // Clear cached barcode
665
+ setDocumentPlaneBounds(null); // Clear visual overlay
666
+ setSecondaryFaceBounds(null); // Clear secondary face overlay
667
+ if (isDebugEnabled()) {
668
+ console.log(
669
+ '[Flash] Turning off flash and clearing hologram images when leaving step'
670
+ );
683
671
  }
684
672
  }
685
- } catch (error) {
686
- console.warn('Error while cropping face:', error);
687
- }
688
673
 
689
- return croppedFaces;
690
- };
674
+ setNextStep(nextStepType);
675
+ Vibration.vibrate(100);
691
676
 
692
- const setNextStepAndVibrate = (
693
- nextStepType:
694
- | 'SCAN_ID_FRONT_OR_PASSPORT'
695
- | 'SCAN_ID_BACK'
696
- | 'SCAN_HOLOGRAM'
697
- | 'COMPLETED',
698
- fromStep?:
699
- | 'SCAN_ID_FRONT_OR_PASSPORT'
700
- | 'SCAN_ID_BACK'
701
- | 'SCAN_HOLOGRAM'
702
- ) => {
703
- // Track which step was just completed for showing specific message
704
- if (fromStep) {
705
- setCompletedStep(fromStep);
706
- }
707
- setNextStep(nextStepType);
708
- Vibration.vibrate(100);
677
+ // Reset MRZ retry counter for each new step so retries start fresh
678
+ mrzDetectionCurrentRetryCount.current = 0;
679
+ lastValidMRZText.current = null;
680
+ validMRZConsecutiveCount.current = 0;
681
+ cachedBarcode.current = null; // Clear cached barcode on step change
709
682
 
710
- // Reset status after delay to show success animation fully before next step
711
- if (nextStepType !== 'COMPLETED') {
712
- setTimeout(() => {
713
- setStatus('SEARCHING');
714
- setCompletedStep(null);
715
- }, 2000); // Show success checkmark for 2 seconds before transitioning
716
- }
717
- };
718
-
719
- const handleBrightness = useRunOnJS(
720
- (isBright: boolean) => {
721
- setIsBrightnessLow(!isBright);
722
- },
723
- [setIsBrightnessLow]
724
- );
725
-
726
- const handleBlurStatus = useRunOnJS(
727
- (blurry: boolean) => {
728
- setIsFrameBlurry(blurry);
683
+ if (nextStepType !== 'COMPLETED') {
684
+ setTimeout(() => {
685
+ setStatus('SEARCHING');
686
+ setCompletedStep(null);
687
+ }, 1000);
688
+ }
729
689
  },
730
- [setIsFrameBlurry]
690
+ [setIsTorchOn]
731
691
  );
732
692
 
733
- const handleFaceAndText = useRunOnJS(
693
+ const handleFaceAndText = useCallback(
734
694
  async (
735
695
  text: string,
736
696
  faces: Face[],
737
697
  frameWidth: number,
738
698
  frameHeight: number,
739
699
  barcode?: Barcode,
740
- image?: string
700
+ image?: string,
701
+ elementsOutside?: boolean,
702
+ nativeMrzResult?: Frame['mrzResult']
741
703
  ) => {
704
+ const detectDocumentType = (
705
+ facesParam: Face[],
706
+ ocrText: string,
707
+ mrzFields?: MRZFields,
708
+ frameWidthParam?: number,
709
+ mrzTextParam?: string | null
710
+ ) => {
711
+ // Relaxed signature detection: matches signature/imza variants and OCR errors
712
+ const hasSignatureMatch = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i.test(ocrText);
713
+
714
+ if (isDebugEnabled()) {
715
+ console.log(
716
+ '[DocType] faces:',
717
+ facesParam.length,
718
+ 'mrzFields:',
719
+ !!mrzFields,
720
+ 'mrzText:',
721
+ !!mrzTextParam,
722
+ 'textLen:',
723
+ ocrText?.length,
724
+ 'hasSignature:',
725
+ hasSignatureMatch
726
+ );
727
+ }
728
+
729
+ // ID Back: no face + ID MRZ
730
+ if (facesParam.length === 0 && mrzFields?.documentCode === 'I') {
731
+ return 'ID_BACK';
732
+ }
733
+
734
+ // Passport: face + passport MRZ
735
+ if (facesParam.length > 0 && mrzFields?.documentCode === 'P') {
736
+ return 'PASSPORT';
737
+ }
738
+
739
+ // ID Front: face detected with signature text
740
+ if (facesParam.length > 0 && ocrText?.length >= 5) {
741
+ const hasSignature = hasSignatureMatch;
742
+ // Only turn off torch during initial scan step, not during SCAN_HOLOGRAM
743
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
744
+ setIsTorchOn(false);
745
+ }
746
+
747
+ // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
748
+ const cardSizedFaces = frameWidthParam
749
+ ? facesParam.filter(
750
+ (face) =>
751
+ face.bounds.width >= frameWidthParam * 0.05 &&
752
+ face.bounds.height >= frameWidthParam * 0.05
753
+ )
754
+ : facesParam;
755
+
756
+ // CRITICAL: If passport MRZ pattern is detected but not parsed yet,
757
+ // return UNKNOWN instead of ID_FRONT to avoid misclassifying passports
758
+ // Passports always have MRZ visible on front starting with P<TUR or similar
759
+ if (
760
+ cardSizedFaces.length > 0 &&
761
+ !mrzFields?.documentCode &&
762
+ hasSignature
763
+ ) {
764
+ if (
765
+ mrzTextParam &&
766
+ mrzTextParam.length > 20 &&
767
+ /P<[A-Z]{3}/.test(mrzTextParam)
768
+ ) {
769
+ // Passport MRZ pattern detected (P<TUR, P<USA, etc.) but not parsed
770
+ // Could be passport with OCR errors - wait for proper parsing
771
+ if (isDebugEnabled()) {
772
+ console.log(
773
+ '[DocType] Passport MRZ pattern (P<XXX) detected but not parsed - returning UNKNOWN to wait for proper classification'
774
+ );
775
+ }
776
+ return 'UNKNOWN';
777
+ }
778
+ return 'ID_FRONT';
779
+ }
780
+ // Also ensure flash is off when scan is completed
781
+ if (nextStep === 'COMPLETED' && isTorchOn) {
782
+ setIsTorchOn(false);
783
+ }
784
+ }
785
+
786
+ return 'UNKNOWN';
787
+ };
788
+
789
+ // Filter to card-sized faces only (min 5% of frame width to exclude tiny background faces)
790
+ const cardSizedFaces = faces.filter(
791
+ (face) =>
792
+ face.bounds.width >= frameWidth * 0.05 &&
793
+ face.bounds.height >= frameWidth * 0.05
794
+ );
795
+
796
+ // Cache barcode when detected, use cached value if current frame has no barcode
797
+ // This handles inconsistent barcode detection across frames
798
+ if (barcode?.rawValue && nextStep === 'SCAN_ID_BACK') {
799
+ cachedBarcode.current = barcode;
800
+ }
801
+ const barcodeToUse = barcode || cachedBarcode.current;
802
+
803
+ // Store frame dimensions for coordinate conversion
742
804
  if (
743
- device?.hasTorch &&
744
- isTorchOn &&
805
+ frameDimensions?.width !== frameWidth ||
806
+ frameDimensions.height !== frameHeight
807
+ ) {
808
+ setFrameDimensions({ width: frameWidth, height: frameHeight });
809
+ }
810
+
811
+ if (
812
+ nextStep !== 'SCAN_HOLOGRAM' &&
813
+ isTorchOnRef.current &&
745
814
  (currentHologramImage ||
746
- hologramDetectionCurrentRetryCount.value >=
747
- HOLOGRAM_DETECTION_RETRY_COUNT)
815
+ hologramDetectionCurrentRetryCount.current >=
816
+ HOLOGRAM_DETECTION_RETRY_COUNT)
748
817
  ) {
749
818
  setIsTorchOn(false);
750
819
  }
@@ -754,30 +823,450 @@ const IdentityDocumentCamera = ({
754
823
  return;
755
824
  }
756
825
 
757
- // Early wrong side detection for SCAN_ID_BACK: if faces detected, it's the front side
758
- if (nextStep === 'SCAN_ID_BACK' && faces.length > 0) {
826
+ if (elementsOutside) {
827
+ return;
828
+ }
829
+
830
+ if (nextStep === 'SCAN_ID_BACK' && cardSizedFaces.length > 0) {
759
831
  setStatus('INCORRECT');
760
832
  return;
761
833
  }
762
834
 
763
- if (!text || text.length < 10 || !image) {
835
+ // Only crop and lock face when ID_FRONT or PASSPORT is confirmed
836
+ const shouldCropFaces =
837
+ detectedDocumentType === 'ID_FRONT' ||
838
+ detectedDocumentType === 'PASSPORT' ||
839
+ nextStep !== 'SCAN_ID_FRONT_OR_PASSPORT';
840
+ const croppedFaces = shouldCropFaces
841
+ ? await getFaceImages(
842
+ cardSizedFaces,
843
+ image ?? '',
844
+ frameWidth,
845
+ frameHeight
846
+ )
847
+ : [];
848
+
849
+ // Validate document plane consistency across all captures
850
+ let facePositionValid = true;
851
+ if (cardSizedFaces.length > 0 && cardSizedFaces[0]) {
852
+ const currentFaceBounds = cardSizedFaces[0].bounds;
853
+ if (lastFacePosition.current) {
854
+ // Check if face position is within acceptable range
855
+ // Use looser tolerance during hologram step since flash toggling causes position jitter
856
+ const xDiff = Math.abs(
857
+ currentFaceBounds.x - lastFacePosition.current.x
858
+ );
859
+ const yDiff = Math.abs(
860
+ currentFaceBounds.y - lastFacePosition.current.y
861
+ );
862
+ const widthDiff = Math.abs(
863
+ currentFaceBounds.width - lastFacePosition.current.width
864
+ );
865
+ const heightDiff = Math.abs(
866
+ currentFaceBounds.height - lastFacePosition.current.height
867
+ );
868
+
869
+ const tolerance = nextStep === 'SCAN_HOLOGRAM' ? 0.5 : 0.2;
870
+ const xTolerance = lastFacePosition.current.width * tolerance;
871
+ const yTolerance = lastFacePosition.current.height * tolerance;
872
+ const sizeTolerance = lastFacePosition.current.width * tolerance;
873
+
874
+ facePositionValid =
875
+ xDiff <= xTolerance &&
876
+ yDiff <= yTolerance &&
877
+ widthDiff <= sizeTolerance &&
878
+ heightDiff <= sizeTolerance;
879
+
880
+ if (!facePositionValid) {
881
+ if (isDebugEnabled()) {
882
+ console.log(
883
+ `[DocPlane] Face position changed - document moved (xDiff=${xDiff.toFixed(1)}, yDiff=${yDiff.toFixed(1)}) - rejecting frame`
884
+ );
885
+ }
886
+ }
887
+
888
+ // Update reference position to follow gradual movement (sliding window)
889
+ lastFacePosition.current = {
890
+ x: currentFaceBounds.x,
891
+ y: currentFaceBounds.y,
892
+ width: currentFaceBounds.width,
893
+ height: currentFaceBounds.height,
894
+ };
895
+ } else {
896
+ // First capture - store reference position
897
+ lastFacePosition.current = {
898
+ x: currentFaceBounds.x,
899
+ y: currentFaceBounds.y,
900
+ width: currentFaceBounds.width,
901
+ height: currentFaceBounds.height,
902
+ };
903
+ console.log(
904
+ '[DocPlane] Stored reference face position for document plane validation'
905
+ );
906
+ }
907
+
908
+ // Update visual bounds for debug overlay
909
+ // Transform face bounds from image coordinates to screen coordinates
910
+ if (facePositionValid && frameDimensions) {
911
+ const screen = Dimensions.get('window');
912
+
913
+ // Camera uses FILL_CENTER: scale to fill screen while maintaining aspect ratio
914
+ const frameAspect = frameDimensions.width / frameDimensions.height;
915
+ const screenAspect = screen.width / screen.height;
916
+
917
+ let scale: number;
918
+ let offsetX = 0;
919
+ let offsetY = 0;
920
+
921
+ if (frameAspect > screenAspect) {
922
+ // Frame is wider - scale by height, crop width
923
+ scale = screen.height / frameDimensions.height;
924
+ offsetX = (frameDimensions.width * scale - screen.width) / 2;
925
+ } else {
926
+ // Frame is taller - scale by width, crop height
927
+ scale = screen.width / frameDimensions.width;
928
+ offsetY = (frameDimensions.height * scale - screen.height) / 2;
929
+ }
930
+
931
+ const cropPadding = Math.max(
932
+ currentFaceBounds.width * 0.15,
933
+ currentFaceBounds.height * 0.15
934
+ );
935
+ setDocumentPlaneBounds({
936
+ x: currentFaceBounds.x * scale - offsetX,
937
+ y: currentFaceBounds.y * scale - offsetY,
938
+ width: currentFaceBounds.width * scale,
939
+ height: currentFaceBounds.height * scale,
940
+ cropPadding: cropPadding * scale,
941
+ });
942
+ }
943
+ }
944
+
945
+ // Capture and persist face only after document type is confirmed
946
+ // This prevents locking a face before we know what document we're scanning
947
+ let faceImageToUse = currentFaceImage;
948
+ if (
949
+ shouldCropFaces &&
950
+ croppedFaces.length > 0 &&
951
+ croppedFaces[0] &&
952
+ facePositionValid
953
+ ) {
954
+ if (!currentFaceImage) {
955
+ // First face detection after doc type confirmed - lock it for all subsequent steps
956
+ faceImageToUse = croppedFaces[0];
957
+ setCurrentFaceImage(croppedFaces[0]);
958
+ if (isDebugEnabled()) {
959
+ console.log(
960
+ '[DocPlane] Locked primary face from validated document plane (docType: ' +
961
+ detectedDocumentType +
962
+ ')'
963
+ );
964
+ }
965
+ }
966
+ }
967
+
968
+ if (!text || text.length < 5 || !image) {
764
969
  setStatus('SEARCHING');
765
970
  return;
766
971
  }
767
972
 
768
- const { mrzText, parsedResult: parsedMRZData } =
769
- mrzUtils.getMRZData(text);
770
- const croppedFaces = await getFaceImages(
771
- faces,
772
- image,
773
- frameWidth,
774
- frameHeight
775
- );
973
+ const parsedMRZData =
974
+ nativeMrzResult?.valid && nativeMrzResult.documentCode
975
+ ? { valid: true, fields: nativeMrzResult as MRZFields }
976
+ : nativeMrzResult?.documentCode
977
+ ? { valid: false, fields: nativeMrzResult as MRZFields }
978
+ : { valid: false, fields: null };
979
+ const mrzText = parsedMRZData.valid ? nativeMrzResult?.rawLines : null;
980
+
981
+ // MRZ stability check - require consistent valid reads to avoid OCR noise
982
+ // Compare parsed field values instead of raw text to handle OCR variations in filler characters
983
+ // Only proceed with MRZ if it's actually valid and has all required fields
984
+ const mrzHasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
985
+
986
+ if (
987
+ mrzText &&
988
+ parsedMRZData?.valid === true &&
989
+ parsedMRZData?.fields &&
990
+ mrzHasRequiredFields
991
+ ) {
992
+ const currentFields = parsedMRZData.fields;
993
+
994
+ if (areMRZFieldsEqual(lastValidMRZFields.current, currentFields)) {
995
+ // Same MRZ data detected again - increment counter
996
+ validMRZConsecutiveCount.current++;
997
+ } else {
998
+ // Different MRZ data - reset counter and store new data
999
+ if (isDebugEnabled()) {
1000
+ console.log(
1001
+ `[MRZ Stability] New MRZ detected, resetting counter (was ${validMRZConsecutiveCount.current}, doc: ${currentFields.documentNumber?.slice(-4)})`
1002
+ );
1003
+ }
1004
+ lastValidMRZFields.current = currentFields;
1005
+ lastValidMRZText.current = mrzText;
1006
+ validMRZConsecutiveCount.current = 1;
1007
+ }
1008
+ } else {
1009
+ // Invalid or no MRZ - don't reset completely, just skip this frame
1010
+ // This allows temporary OCR noise without losing progress
1011
+ }
1012
+
1013
+ // Check if we have enough consistent valid reads
1014
+ const mrzStableAndValid =
1015
+ validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS &&
1016
+ parsedMRZData?.valid === true &&
1017
+ areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
1018
+
1019
+ // During SCAN_ID_BACK, handle MRZ/barcode directly without full document type detection
1020
+ // This avoids the chicken-and-egg problem where detectDocumentType requires
1021
+ // mrzFields.documentCode === 'I' but MRZ parsing may return different codes
1022
+ if (nextStep === 'SCAN_ID_BACK') {
1023
+ // CRITICAL: Always check if wrong side is shown (front or passport when back is expected)
1024
+ // ID_BACK should have NO faces and NO signature text
1025
+ // Multiple indicators for robust detection:
1026
+ const hasFaces = cardSizedFaces.length > 0;
1027
+ const hasSignature = /signature|imza|İmza/i.test(text);
1028
+ const hasPassportMRZ = parsedMRZData?.fields?.documentCode === 'P';
1029
+ const hasPassportMRZPattern = mrzText && /P<[A-Z]{3}/.test(mrzText);
1030
+
1031
+ if (
1032
+ hasFaces ||
1033
+ hasSignature ||
1034
+ hasPassportMRZ ||
1035
+ hasPassportMRZPattern
1036
+ ) {
1037
+ if (isDebugEnabled()) {
1038
+ console.log(
1039
+ `[ID_BACK Scan] Wrong side detected - faces: ${hasFaces}, signature: ${hasSignature}, passport MRZ: ${hasPassportMRZ}, passport pattern: ${hasPassportMRZPattern}`
1040
+ );
1041
+ }
1042
+ setStatus('INCORRECT');
1043
+ return;
1044
+ }
1045
+
1046
+ // SAFETY CHECK: If we detect passport during ID_BACK scan via state, skip this step
1047
+ // This shouldn't happen but protects against edge cases
1048
+ if (detectedDocumentType === 'PASSPORT') {
1049
+ if (isDebugEnabled()) {
1050
+ console.log(
1051
+ '[ID_BACK Scan] ERROR: Passport detected in ID_BACK step - skipping to COMPLETED'
1052
+ );
1053
+ }
1054
+ setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
1055
+ setTimeout(() => {
1056
+ onIdentityDocumentScanned({
1057
+ image,
1058
+ documentType: 'PASSPORT',
1059
+ mrzText: mrzText ?? undefined,
1060
+ mrzFields: parsedMRZData?.fields,
1061
+ });
1062
+ }, 1000);
1063
+ return;
1064
+ }
1065
+
1066
+ const hasMRZ = !!mrzText;
1067
+ const hasRequiredFields = hasRequiredMRZFields(parsedMRZData?.fields);
1068
+ // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
1069
+ // AND ensure all required fields are present
1070
+ const mrzAccepted =
1071
+ parsedMRZData?.valid === true &&
1072
+ hasRequiredFields &&
1073
+ mrzStableAndValid;
1074
+ const barcodeMatchesMRZ =
1075
+ barcodeToUse?.rawValue?.trim() ===
1076
+ parsedMRZData?.fields?.optional1?.trim();
1077
+ // Require barcode for all documents (no special card fallback)
1078
+ const barcodeAccepted = onlyMRZScan || barcodeMatchesMRZ;
1079
+
1080
+ // CRITICAL: Require all document elements to be in frame before accepting
1081
+ // For ID_BACK: MRZ + barcode (check directly, not via state to avoid timing issues)
1082
+ const hasBarcode = !!barcodeToUse?.rawValue;
1083
+ const allRequiredElementsInFrame =
1084
+ (hasMRZ && hasBarcode) || onlyMRZScan;
1085
+
1086
+ // Don't block based on bounds - just ensure elements are present
1087
+ setElementsOutsideScanArea([]);
1088
+
1089
+ if (!allRequiredElementsInFrame && hasMRZ && mrzAccepted) {
1090
+ if (isDebugEnabled()) {
1091
+ console.log(
1092
+ '[ID_BACK Scan] MRZ valid but waiting for all elements in frame (MRZ + barcode)'
1093
+ );
1094
+ }
1095
+ setStatus('SCANNING');
1096
+ return;
1097
+ }
1098
+
1099
+ if (
1100
+ hasMRZ &&
1101
+ mrzAccepted &&
1102
+ barcodeAccepted &&
1103
+ allRequiredElementsInFrame
1104
+ ) {
1105
+ logMRZDetails(
1106
+ 'ID_BACK Scan',
1107
+ parsedMRZData?.fields,
1108
+ mrzText,
1109
+ validMRZConsecutiveCount.current,
1110
+ isDebugEnabled()
1111
+ );
1112
+ const scannedData: DocumentScannedData = {
1113
+ image,
1114
+ documentType: 'ID_BACK',
1115
+ mrzText: mrzText ?? undefined,
1116
+ mrzFields: parsedMRZData?.fields,
1117
+ barcodeValue: barcodeToUse?.rawValue ?? undefined,
1118
+ };
1119
+ setDetectedDocumentType('ID_BACK');
1120
+ setStatus('SCANNED');
1121
+ setIsTorchOn(false);
1122
+ setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
1123
+ setTimeout(() => {
1124
+ onIdentityDocumentScanned(scannedData);
1125
+ }, 1000);
1126
+ } else {
1127
+ if (hasMRZ && !mrzAccepted) {
1128
+ logMRZValidationFailure(
1129
+ 'ID_BACK Scan',
1130
+ hasRequiredFields,
1131
+ parsedMRZData,
1132
+ mrzDetectionCurrentRetryCount.current,
1133
+ isDebugEnabled()
1134
+ );
1135
+ } else if (hasMRZ && mrzAccepted && !barcodeAccepted) {
1136
+ if (isDebugEnabled()) {
1137
+ console.log(
1138
+ '[ID_BACK Scan] MRZ valid but barcode check failed - retrying',
1139
+ {
1140
+ onlyMRZScan,
1141
+ hasBarcodeValue: !!barcodeToUse?.rawValue,
1142
+ barcodeMatchesMRZ,
1143
+ mrzOptional1: parsedMRZData?.fields?.optional1,
1144
+ barcodeValue: barcodeToUse?.rawValue,
1145
+ barcodeSource:
1146
+ barcodeToUse === cachedBarcode.current
1147
+ ? 'cached'
1148
+ : 'current',
1149
+ }
1150
+ );
1151
+ }
1152
+ }
1153
+ mrzDetectionCurrentRetryCount.current++;
1154
+ setStatus(hasMRZ ? 'SCANNING' : 'SEARCHING');
1155
+ }
1156
+ return;
1157
+ }
1158
+
776
1159
  const documentType = detectDocumentType(
777
- faces,
1160
+ cardSizedFaces,
778
1161
  text,
779
- parsedMRZData?.fields
1162
+ parsedMRZData?.fields,
1163
+ frameWidth,
1164
+ mrzText
780
1165
  );
1166
+
1167
+ // Update detected document type only during initial scan step
1168
+ // CRITICAL: Only set document type from non-blurry, stable frames
1169
+ // Once set to PASSPORT or definitively identified, preserve it to avoid incorrect flow transitions
1170
+ if (
1171
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
1172
+ detectedDocumentType === 'UNKNOWN'
1173
+ ) {
1174
+ // Determine the document type to set based on current frame analysis
1175
+ let docTypeToSet: 'ID_FRONT' | 'ID_BACK' | 'PASSPORT' | 'UNKNOWN' =
1176
+ documentType;
1177
+
1178
+ if (documentType === 'PASSPORT') {
1179
+ // Passport detected definitively - candidate for locking in
1180
+ docTypeToSet = 'PASSPORT';
1181
+ } else if (
1182
+ documentType === 'UNKNOWN' &&
1183
+ cardSizedFaces.length > 0 &&
1184
+ parsedMRZData?.fields?.documentCode === 'P'
1185
+ ) {
1186
+ // Early passport detection: face detected + passport MRZ code (even if not fully valid yet)
1187
+ docTypeToSet = 'PASSPORT';
1188
+ } else if (documentType === 'ID_FRONT') {
1189
+ // Check if this is actually a passport based on MRZ code
1190
+ // Passports can be misdetected as ID_FRONT when signature-like text is visible
1191
+ if (parsedMRZData?.fields?.documentCode === 'P') {
1192
+ if (isDebugEnabled()) {
1193
+ console.log(
1194
+ '[DocType] Correcting misdetection: ID_FRONT → PASSPORT (MRZ code P)'
1195
+ );
1196
+ }
1197
+ docTypeToSet = 'PASSPORT';
1198
+ } else if (parsedMRZData?.fields?.documentCode === 'I') {
1199
+ // MRZ confirms it's an ID card
1200
+ docTypeToSet = 'ID_FRONT';
1201
+ } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
1202
+ // Passport MRZ pattern visible but not parsed yet - wait for proper classification
1203
+ if (isDebugEnabled()) {
1204
+ console.log(
1205
+ '[DocType] Passport MRZ pattern detected but not parsed - waiting instead of locking as ID_FRONT'
1206
+ );
1207
+ }
1208
+ docTypeToSet = 'UNKNOWN';
1209
+ } else {
1210
+ // No MRZ code and no passport pattern - safe to classify as ID_FRONT
1211
+ // ID cards typically don't have MRZ on front (only on back)
1212
+ docTypeToSet = 'ID_FRONT';
1213
+ }
1214
+ } else {
1215
+ docTypeToSet = 'UNKNOWN';
1216
+ }
1217
+
1218
+ // Only update document type state if:
1219
+ // 1. Frame quality is acceptable (not blurry, good brightness)
1220
+ // 2. Document type has been detected consistently for multiple frames
1221
+ if (
1222
+ lastFrameQuality.current.hasAcceptableQuality &&
1223
+ docTypeToSet !== 'UNKNOWN'
1224
+ ) {
1225
+ if (docTypeToSet === lastDetectedDocType.current) {
1226
+ consistentDocTypeCount.current++;
1227
+ if (isDebugEnabled()) {
1228
+ console.log(
1229
+ `[DocType Stability] Consistent detection: ${docTypeToSet} (${consistentDocTypeCount.current}/${REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS})`
1230
+ );
1231
+ }
1232
+
1233
+ if (
1234
+ consistentDocTypeCount.current >=
1235
+ REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS
1236
+ ) {
1237
+ // Stable detection confirmed - lock it in
1238
+ if (isDebugEnabled()) {
1239
+ console.log(
1240
+ `[DocType] Locked document type: ${docTypeToSet} (stable for ${consistentDocTypeCount.current} quality frames)`
1241
+ );
1242
+ }
1243
+ setDetectedDocumentType(docTypeToSet);
1244
+ }
1245
+ } else {
1246
+ // Document type changed - reset counter
1247
+ if (isDebugEnabled()) {
1248
+ console.log(
1249
+ `[DocType Stability] Type changed: ${lastDetectedDocType.current} → ${docTypeToSet}, resetting counter`
1250
+ );
1251
+ }
1252
+ lastDetectedDocType.current = docTypeToSet;
1253
+ consistentDocTypeCount.current = 1;
1254
+ }
1255
+ } else if (
1256
+ !lastFrameQuality.current.hasAcceptableQuality &&
1257
+ docTypeToSet !== 'UNKNOWN'
1258
+ ) {
1259
+ // Poor quality frame - don't use for document type detection
1260
+ if (isDebugEnabled()) {
1261
+ console.log(
1262
+ `[DocType Stability] Skipping poor quality frame (blurry: ${lastFrameQuality.current.isBlurry}, brightness: ${lastFrameQuality.current.brightness.toFixed(0)})`
1263
+ );
1264
+ }
1265
+ }
1266
+ }
1267
+ // Document type is now locked and won't be changed after initial scan
1268
+ // Hologram and subsequent steps use the preserved detectedDocumentType state
1269
+
781
1270
  const scannedData: DocumentScannedData = {
782
1271
  image,
783
1272
  documentType,
@@ -785,420 +1274,1281 @@ const IdentityDocumentCamera = ({
785
1274
  mrzFields: parsedMRZData?.fields,
786
1275
  };
787
1276
 
788
- scannedData.faceImage = croppedFaces[0];
789
- setCurrentFaceImage(croppedFaces[0]);
790
-
791
- // Track detected document type for UI feedback
792
- if (documentType !== 'UNKNOWN') {
793
- setDetectedDocumentType(documentType);
794
- }
795
-
796
- // Detect wrong side based on document type or face presence (works for both normal and eID scan)
797
- // For ID_BACK step: if faces are detected, it's likely the front side (wrong)
798
- // For FRONT step: if ID_BACK is detected, it's the wrong side
799
1277
  const isWrongSide =
800
- (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK') ||
801
- (nextStep === 'SCAN_ID_BACK' && (documentType === 'ID_FRONT' || documentType === 'PASSPORT' || croppedFaces.length > 0));
1278
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK';
802
1279
 
803
1280
  if (isWrongSide) {
804
1281
  setStatus('INCORRECT');
805
1282
  return;
806
1283
  }
807
1284
 
1285
+ // Always use locked face if available
1286
+ if (faceImageToUse) {
1287
+ scannedData.faceImage = faceImageToUse;
1288
+ }
1289
+
808
1290
  if (!onlyMRZScan) {
809
- if (croppedFaces.length > 0 && croppedFaces[0]) {
810
- if (currentFaceImage) {
811
- scannedData.faceImage = currentFaceImage;
1291
+ // Hologram detection during SCAN_HOLOGRAM step - ALWAYS use first/leftmost face ONLY
1292
+ if (nextStep === 'SCAN_HOLOGRAM') {
1293
+ if (isDebugEnabled()) {
1294
+ console.log(
1295
+ `[Hologram] In SCAN_HOLOGRAM step - currentHologramImage: ${!!currentHologramImage}, collected: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT}`
1296
+ );
1297
+ }
1298
+
1299
+ // Always crop to the same face region across all hologram frames so
1300
+ // OpenCV receives consistently-sized images for comparison.
1301
+ // Use current face bounds if available, otherwise fall back to last known position.
1302
+ const hologramFaceBounds =
1303
+ cardSizedFaces.length > 0 && cardSizedFaces[0]
1304
+ ? cardSizedFaces[0].bounds
1305
+ : lastFacePosition.current;
1306
+ let primaryFaceOnly: string | undefined;
1307
+ if (hologramFaceBounds && image) {
1308
+ const hologramCropped = await getFaceImages(
1309
+ [{ bounds: hologramFaceBounds, rollAngle: 0, yawAngle: 0 }],
1310
+ image,
1311
+ frameWidth,
1312
+ frameHeight
1313
+ );
1314
+ primaryFaceOnly = hologramCropped[0] ?? faceImageToUse;
812
1315
  } else {
813
- scannedData.faceImage = croppedFaces[0];
814
- setCurrentFaceImage(croppedFaces[0]);
1316
+ primaryFaceOnly = faceImageToUse;
815
1317
  }
816
1318
 
817
- if (currentHologramImage) {
818
- scannedData.hologramImage = currentHologramImage;
819
- } else if (faceImages.length <= HOLOGRAM_IMAGE_COUNT) {
820
- if (device?.hasTorch) {
821
- setIsTorchOn(true);
1319
+ // Skip face position validation for hologram — flash toggling causes position jitter
1320
+ if (primaryFaceOnly) {
1321
+ // Reset consecutive no-face counter since we have a face
1322
+ hologramFramesWithoutFace.current = 0;
1323
+
1324
+ if (currentHologramImage) {
1325
+ scannedData.hologramImage = currentHologramImage;
1326
+ } else if (faceImages.current.length < HOLOGRAM_IMAGE_COUNT) {
1327
+ // Add timing control to space out captures for better variation
1328
+ const now = Date.now();
1329
+ const timeSinceLastCapture =
1330
+ now - lastHologramCaptureTime.current;
1331
+
1332
+ if (
1333
+ faceImages.current.length === 0 ||
1334
+ timeSinceLastCapture >= HOLOGRAM_CAPTURE_INTERVAL
1335
+ ) {
1336
+ // Collect PRIMARY face image ONLY (always index 0) from same document plane
1337
+ faceImages.current.push(primaryFaceOnly);
1338
+ lastHologramCaptureTime.current = now;
1339
+ hologramImageCountRef.current = faceImages.current.length;
1340
+
1341
+ // Only update state at first and last frame to minimize re-renders
1342
+ if (
1343
+ faceImages.current.length === 1 ||
1344
+ faceImages.current.length === HOLOGRAM_IMAGE_COUNT
1345
+ ) {
1346
+ setHologramImageCount(faceImages.current.length);
1347
+ setLatestHologramFaceImage(primaryFaceOnly);
1348
+ }
1349
+
1350
+ if (isDebugEnabled()) {
1351
+ console.log(
1352
+ `[Hologram] Collected ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} face images`
1353
+ );
1354
+ }
1355
+
1356
+ // Keep flash on during processing - will turn off when step changes
1357
+ }
1358
+ } else if (faceImages.current.length >= HOLOGRAM_IMAGE_COUNT) {
1359
+ // Process collected full document images
1360
+ if (isDebugEnabled()) {
1361
+ console.log(
1362
+ `[Hologram] Processing ${faceImages.current.length} full document images`
1363
+ );
1364
+ }
1365
+ try {
1366
+ const [hologramMask, hologram] = await detectHologramNative(
1367
+ faceImages.current
1368
+ );
1369
+ if (hologram) {
1370
+ setCurrentHologramMaskImage(hologramMask);
1371
+ scannedData.hologramImage = hologram;
1372
+ setCurrentHologramImage(hologram);
1373
+ if (isDebugEnabled()) {
1374
+ console.log('[Hologram] Detection successful');
1375
+ }
1376
+ } else {
1377
+ if (isDebugEnabled()) {
1378
+ console.log('[Hologram] No hologram detected');
1379
+ }
1380
+ }
1381
+ } catch (error) {
1382
+ console.error('[Hologram] Processing error:', error);
1383
+ } finally {
1384
+ // Keep flash on - will turn off when step changes
1385
+ faceImages.current = [];
1386
+ hologramImageCountRef.current = 0;
1387
+ setHologramImageCount(0);
1388
+ setLatestHologramFaceImage(undefined);
1389
+ hologramDetectionCurrentRetryCount.current++;
1390
+ if (isDebugEnabled()) {
1391
+ console.log(
1392
+ `[Hologram] Retry ${hologramDetectionCurrentRetryCount.current}/${HOLOGRAM_DETECTION_RETRY_COUNT}`
1393
+ );
1394
+ }
1395
+ }
822
1396
  }
823
- faceImages.push(croppedFaces[0]);
824
1397
  } else {
825
- const [hologramMask, hologram] = detectHologram(faceImages);
826
- if (
827
- !!currentFaceImage &&
828
- areImagesSimilar(currentFaceImage, hologram, 25000)
829
- ) {
830
- setCurrentHologramMaskImage(hologramMask);
831
- scannedData.hologramImage = hologram;
832
- setCurrentHologramImage(hologram);
1398
+ // No face detected for hologram collection
1399
+ // Track consecutive frames without face for safety timeout
1400
+ hologramFramesWithoutFace.current++;
1401
+ if (isDebugEnabled()) {
1402
+ console.log(
1403
+ `[Hologram] No face detected - frame ${hologramFramesWithoutFace.current}/${HOLOGRAM_MAX_FRAMES_WITHOUT_FACE}`
1404
+ );
833
1405
  }
834
- faceImages = [];
835
- hologramDetectionCurrentRetryCount.value++;
836
1406
  }
1407
+ } else if (currentHologramImage) {
1408
+ scannedData.hologramImage = currentHologramImage;
1409
+ } else if (faceImages.current.length > 0) {
1410
+ // Safety cleanup: not in hologram step but have images collected
1411
+ faceImages.current = [];
1412
+ hologramImageCountRef.current = 0;
1413
+ setHologramImageCount(0);
1414
+ setLatestHologramFaceImage(undefined);
1415
+ if (isDebugEnabled()) {
1416
+ console.log(
1417
+ '[Hologram] Defensive cleanup - cleared images outside hologram step'
1418
+ );
1419
+ }
1420
+ }
837
1421
 
1422
+ // SKIP secondary face detection during SCAN_HOLOGRAM - only focus on primary face
1423
+ // Secondary face was already captured during initial scan (SCAN_ID_FRONT_OR_PASSPORT)
1424
+ // During hologram, we only collect hologram images from primary face
1425
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1426
+ // Capture secondary face - must be similar to main face AND from same document plane
838
1427
  if (currentSecondaryFaceImage) {
839
1428
  scannedData.secondaryFaceImage = currentSecondaryFaceImage;
840
1429
  } else if (
841
1430
  !!scannedData.faceImage &&
842
1431
  croppedFaces.length > 1 &&
843
1432
  !!croppedFaces[1] &&
844
- areImagesSimilar(scannedData.faceImage, croppedFaces[1])
1433
+ facePositionValid
845
1434
  ) {
846
- scannedData.secondaryFaceImage = croppedFaces[1];
847
- setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
1435
+ // Always validate similarity to ensure it's the same person on the same document
1436
+ const isSimilar = await areImagesSimilarNative(
1437
+ scannedData.faceImage,
1438
+ croppedFaces[1],
1439
+ 15000 // Default threshold from main branch
1440
+ );
1441
+
1442
+ if (isSimilar) {
1443
+ scannedData.secondaryFaceImage = croppedFaces[1];
1444
+ setCurrentSecondaryFaceImage(scannedData.secondaryFaceImage);
1445
+
1446
+ // Update secondary face bounds for debug overlay
1447
+ if (faces.length > 1 && faces[1] && frameDimensions) {
1448
+ const screen = Dimensions.get('window');
1449
+ const frameAspect =
1450
+ frameDimensions.width / frameDimensions.height;
1451
+ const screenAspect = screen.width / screen.height;
1452
+
1453
+ let scale: number;
1454
+ let offsetX = 0;
1455
+ let offsetY = 0;
1456
+
1457
+ if (frameAspect > screenAspect) {
1458
+ scale = screen.height / frameDimensions.height;
1459
+ offsetX = (frameDimensions.width * scale - screen.width) / 2;
1460
+ } else {
1461
+ scale = screen.width / frameDimensions.width;
1462
+ offsetY =
1463
+ (frameDimensions.height * scale - screen.height) / 2;
1464
+ }
1465
+
1466
+ const scanLeft = (screen.width * 0.05 + offsetX) / scale;
1467
+ const scanTop = (screen.height * 0.36 + offsetY) / scale;
1468
+ const scanRight = (screen.width * 0.95 + offsetX) / scale;
1469
+ const scanBottom = (screen.height * 0.64 + offsetY) / scale;
1470
+ const isInsideScan = (
1471
+ x: number,
1472
+ y: number,
1473
+ w: number,
1474
+ h: number
1475
+ ) =>
1476
+ x >= scanLeft &&
1477
+ y >= scanTop &&
1478
+ x + w <= scanRight &&
1479
+ y + h <= scanBottom;
1480
+
1481
+ const secondaryBounds = faces[1].bounds;
1482
+ if (
1483
+ isInsideScan(
1484
+ secondaryBounds.x,
1485
+ secondaryBounds.y,
1486
+ secondaryBounds.width,
1487
+ secondaryBounds.height
1488
+ )
1489
+ ) {
1490
+ setSecondaryFaceBounds({
1491
+ x: secondaryBounds.x * scale - offsetX,
1492
+ y: secondaryBounds.y * scale - offsetY,
1493
+ width: secondaryBounds.width * scale,
1494
+ height: secondaryBounds.height * scale,
1495
+ });
1496
+ } else {
1497
+ setSecondaryFaceBounds(null);
1498
+ }
1499
+ }
1500
+
1501
+ if (isDebugEnabled()) {
1502
+ console.log(
1503
+ '[SecondaryFace] ✓ Captured and validated as similar to main face (same document plane)'
1504
+ );
1505
+ }
1506
+ } else {
1507
+ secondaryFaceDetectionCurrentRetryCount.current++;
1508
+ if (isDebugEnabled()) {
1509
+ console.log(
1510
+ '[SecondaryFace] ✗ Rejected - not similar enough to main face'
1511
+ );
1512
+ }
1513
+ }
848
1514
  } else {
849
- secondaryFaceDetectionCurrentRetryCount.value++;
1515
+ secondaryFaceDetectionCurrentRetryCount.current++;
1516
+ if (!facePositionValid && croppedFaces.length > 1) {
1517
+ if (isDebugEnabled()) {
1518
+ console.log(
1519
+ '[SecondaryFace] ✗ Rejected - document plane changed'
1520
+ );
1521
+ }
1522
+ }
850
1523
  }
1524
+ } else if (currentSecondaryFaceImage) {
1525
+ // Already have secondary face from earlier - just use it
1526
+ scannedData.secondaryFaceImage = currentSecondaryFaceImage;
851
1527
  }
852
1528
  }
853
1529
 
1530
+ // UNIFIED SCAN_HOLOGRAM completion - ONLY check primary face and hologram collection
1531
+ // Document type is already definitively determined before entering this step
1532
+ if (nextStep === 'SCAN_HOLOGRAM') {
1533
+ // CRITICAL: Verify correct side is shown - hologram is ALWAYS on the front side with photo
1534
+ // If wrong side detected, warn user immediately
1535
+ const hasFaces = cardSizedFaces.length > 0;
1536
+ const hasBarcode = !!barcode?.rawValue; // ID back has barcode, front doesn't
1537
+
1538
+ // For passport: back side has no photo and different text pattern
1539
+ // For ID card: back side has no photo, has barcode
1540
+ const isWrongSideForHologram = !hasFaces || hasBarcode;
1541
+
1542
+ if (isWrongSideForHologram) {
1543
+ if (isDebugEnabled()) {
1544
+ console.log(
1545
+ `[SCAN_HOLOGRAM] Wrong side detected - no faces: ${!hasFaces}, has barcode: ${hasBarcode} - expecting front side with photo`
1546
+ );
1547
+ }
1548
+ setStatus('INCORRECT');
1549
+ return;
1550
+ }
1551
+
1552
+ // Safety timeout: if we can't detect face for too many consecutive frames, give up
1553
+ const faceDetectionTimeout =
1554
+ hologramFramesWithoutFace.current >= HOLOGRAM_MAX_FRAMES_WITHOUT_FACE;
1555
+
1556
+ // Don't skip if actively collecting images
1557
+ const isActivelyCollecting =
1558
+ faceImages.current.length > 0 &&
1559
+ faceImages.current.length < HOLOGRAM_IMAGE_COUNT;
1560
+
1561
+ const hologramConditionMet =
1562
+ !!scannedData.hologramImage ||
1563
+ (hologramDetectionCurrentRetryCount.current >=
1564
+ HOLOGRAM_DETECTION_RETRY_COUNT &&
1565
+ !isActivelyCollecting) || // Don't skip if mid-collection
1566
+ (faceDetectionTimeout && !isActivelyCollecting); // Don't timeout if mid-collection
1567
+
1568
+ // During hologram scan, we ONLY care about hologram collection - no other checks
1569
+ // Secondary face, MRZ, document type checks are all skipped
1570
+ // Document type was already definitively determined in the initial scan phase
1571
+
1572
+ // Log detailed state for debugging
1573
+ if (isActivelyCollecting && isDebugEnabled()) {
1574
+ console.log(
1575
+ `[SCAN_HOLOGRAM] Actively collecting: ${faceImages.current.length}/${HOLOGRAM_IMAGE_COUNT} - continuing collection`
1576
+ );
1577
+ }
1578
+
1579
+ if (hologramConditionMet) {
1580
+ if (faceDetectionTimeout && isDebugEnabled()) {
1581
+ console.log(
1582
+ '[SCAN_HOLOGRAM] Face detection timeout - proceeding without hologram'
1583
+ );
1584
+ }
1585
+ setStatus('SCANNED');
1586
+ if (nextStep !== 'SCAN_HOLOGRAM') {
1587
+ setIsTorchOn(false);
1588
+ }
1589
+ // Route based on PRESERVED detectedDocumentType state (set during initial scan)
1590
+ // Also check current frame's documentType and MRZ code as fallback
1591
+ // Passport has no back side - go directly to COMPLETED
1592
+ const isPassport =
1593
+ detectedDocumentType === 'PASSPORT' ||
1594
+ documentType === 'PASSPORT' ||
1595
+ parsedMRZData?.fields?.documentCode === 'P';
1596
+ if (isDebugEnabled()) {
1597
+ console.log('[SCAN_HOLOGRAM] Document type check:', {
1598
+ detectedDocumentType,
1599
+ documentType,
1600
+ mrzCode: parsedMRZData?.fields?.documentCode,
1601
+ isPassport,
1602
+ });
1603
+ }
1604
+ if (isPassport) {
1605
+ if (isDebugEnabled()) {
1606
+ console.log(
1607
+ '[SCAN_HOLOGRAM] Passport detected - completing scan (no back side)'
1608
+ );
1609
+ }
1610
+ setNextStepAndVibrate('COMPLETED', 'SCAN_HOLOGRAM');
1611
+ } else {
1612
+ if (isDebugEnabled()) {
1613
+ console.log(
1614
+ '[SCAN_HOLOGRAM] ID card detected - proceeding to back scan'
1615
+ );
1616
+ }
1617
+ setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_HOLOGRAM');
1618
+ }
1619
+ setTimeout(() => {
1620
+ onIdentityDocumentScanned(scannedData);
1621
+ }, 1000);
1622
+ return;
1623
+ }
1624
+ // Still collecting or conditions not met - stay in SCAN_HOLOGRAM
1625
+ // Don't fall through to document type branching
1626
+ setStatus('SCANNING');
1627
+ return;
1628
+ }
1629
+
854
1630
  if (documentType === 'ID_FRONT') {
855
1631
  if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1632
+ // CRITICAL: Verify this is actually an ID card, not a passport misdetected as ID_FRONT
1633
+ // Passports can show signature-like text and be temporarily classified as ID_FRONT
1634
+ if (parsedMRZData?.fields?.documentCode === 'P') {
1635
+ if (isDebugEnabled()) {
1636
+ console.log(
1637
+ '[ID_FRONT Scan] Detected as ID_FRONT but MRZ shows passport (code P) - waiting for passport branch'
1638
+ );
1639
+ }
1640
+ setStatus('SCANNING');
1641
+ return;
1642
+ }
1643
+
1644
+ const hasFace = cardSizedFaces.length > 0;
1645
+ const hasSignature = /signature|imza|İmza/i.test(text);
1646
+ const retryThreshold = 60;
1647
+ const allowFaceOnly =
1648
+ mrzDetectionCurrentRetryCount.current > retryThreshold;
1649
+ const allRequiredElementsInFrame =
1650
+ hasFace && (hasSignature || allowFaceOnly);
1651
+
1652
+ setElementsOutsideScanArea([]);
1653
+
1654
+ if (!allRequiredElementsInFrame) {
1655
+ console.log(
1656
+ '[ID_FRONT Scan] Valid but waiting for all elements in frame (face + signature)'
1657
+ );
1658
+ mrzDetectionCurrentRetryCount.current++;
1659
+ setStatus('SCANNING');
1660
+ return;
1661
+ }
1662
+
1663
+ // CRITICAL: Final verification that this is definitively an ID card before proceeding
1664
+ // Check if we have MRZ and if it indicates ID card (not passport)
1665
+ if (parsedMRZData?.fields?.documentCode) {
1666
+ if (parsedMRZData.fields.documentCode === 'I') {
1667
+ if (isDebugEnabled()) {
1668
+ console.log('[ID_FRONT Scan] MRZ confirms ID card (code I)');
1669
+ }
1670
+ } else if (parsedMRZData.fields.documentCode === 'P') {
1671
+ if (isDebugEnabled()) {
1672
+ console.log(
1673
+ '[ID_FRONT Scan] MRZ shows passport (code P) - rejecting as ID_FRONT'
1674
+ );
1675
+ }
1676
+ setStatus('SCANNING');
1677
+ return;
1678
+ }
1679
+ } else if (mrzText && /P<[A-Z]{3}/.test(mrzText)) {
1680
+ // No parsed MRZ BUT passport MRZ pattern visible (P<TUR, P<USA, etc.)
1681
+ // This is likely a passport with OCR errors - wait for proper parsing
1682
+ if (isDebugEnabled()) {
1683
+ console.log(
1684
+ '[ID_FRONT Scan] Passport MRZ pattern (P<XXX) visible but not parsed - waiting for passport classification'
1685
+ );
1686
+ }
1687
+ mrzDetectionCurrentRetryCount.current++;
1688
+ setStatus('SCANNING');
1689
+ return;
1690
+ }
1691
+ // No MRZ or no passport pattern - proceed as ID card
1692
+ // ID cards typically don't have MRZ on front side (only on back)
1693
+
1694
+ // CRITICAL: Lock document type state to ID_FRONT before proceeding
1695
+ // This ensures hologram completion knows it's an ID card (needs ID_BACK step)
1696
+ setDetectedDocumentType('ID_FRONT');
856
1697
  setStatus('SCANNED');
1698
+ setIsTorchOn(false);
857
1699
  if (onlyMRZScan) {
858
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_ID_FRONT_OR_PASSPORT');
859
- onIdentityDocumentScanned(scannedData);
1700
+ // Passport has no back side - go directly to COMPLETED
1701
+ // At this point detectedDocumentType is definitively set
1702
+ if (detectedDocumentType === 'PASSPORT') {
1703
+ setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
1704
+ } else {
1705
+ setNextStepAndVibrate(
1706
+ 'SCAN_ID_BACK',
1707
+ 'SCAN_ID_FRONT_OR_PASSPORT'
1708
+ );
1709
+ }
1710
+ setTimeout(() => {
1711
+ onIdentityDocumentScanned(scannedData);
1712
+ }, 1000);
860
1713
  } else {
1714
+ if (isDebugEnabled()) {
1715
+ console.log(
1716
+ '[ID_FRONT Scan] Confirmed as ID card - proceeding to hologram'
1717
+ );
1718
+ }
861
1719
  setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
862
- }
863
- } else if (
864
- nextStep === 'SCAN_HOLOGRAM' &&
865
- (!!scannedData.hologramImage ||
866
- hologramDetectionCurrentRetryCount.value >=
867
- HOLOGRAM_DETECTION_RETRY_COUNT) &&
868
- (!!scannedData.secondaryFaceImage ||
869
- secondaryFaceDetectionCurrentRetryCount.value >=
870
- SECOND_FACE_DETECTION_RETRY_COUNT)
871
- ) {
872
- setStatus('SCANNED');
873
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_HOLOGRAM');
874
- onIdentityDocumentScanned(scannedData);
1720
+ setTimeout(() => {
1721
+ onIdentityDocumentScanned(scannedData);
1722
+ }, 1000);
1723
+ }
875
1724
  }
1725
+ // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
876
1726
  } else if (documentType === 'PASSPORT') {
877
1727
  if (
878
1728
  nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
879
1729
  !scannedData.hologramImage
880
1730
  ) {
881
- // For passport, require valid MRZ before proceeding
882
1731
  if (onlyMRZScan) {
883
- // eID scan: require valid MRZ
1732
+ const hasRequiredFields = hasRequiredMRZFields(
1733
+ parsedMRZData?.fields
1734
+ );
1735
+ // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
884
1736
  if (
885
1737
  !!scannedData.mrzText &&
886
- (parsedMRZData?.valid ||
887
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
1738
+ hasRequiredFields &&
1739
+ mrzStableAndValid
888
1740
  ) {
1741
+ const hasFace = cardSizedFaces.length > 0;
1742
+ const hasMRZ = !!mrzText;
1743
+ const allRequiredElementsInFrame = hasFace && hasMRZ;
1744
+
1745
+ setElementsOutsideScanArea([]);
1746
+
1747
+ if (!allRequiredElementsInFrame) {
1748
+ console.log(
1749
+ '[Passport Scan] MRZ valid but waiting for all elements in frame (face + MRZ)'
1750
+ );
1751
+ setStatus('SCANNING');
1752
+ return;
1753
+ }
1754
+ logMRZDetails(
1755
+ 'Passport Scan',
1756
+ parsedMRZData?.fields,
1757
+ mrzText,
1758
+ validMRZConsecutiveCount.current,
1759
+ isDebugEnabled()
1760
+ );
1761
+ setDetectedDocumentType('PASSPORT');
889
1762
  setStatus('SCANNED');
1763
+ setIsTorchOn(false);
890
1764
  setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
891
- onIdentityDocumentScanned(scannedData);
892
- } else if (!parsedMRZData?.valid) {
893
- mrzDetectionCurrentRetryCount.value++;
1765
+ setTimeout(() => {
1766
+ onIdentityDocumentScanned(scannedData);
1767
+ }, 1000);
1768
+ return; // CRITICAL: Exit after MRZ-only scan completes to prevent fall-through
1769
+ } else {
1770
+ if (!!scannedData.mrzText && !mrzStableAndValid) {
1771
+ logMRZValidationFailure(
1772
+ 'Passport Scan',
1773
+ hasRequiredFields,
1774
+ parsedMRZData,
1775
+ mrzDetectionCurrentRetryCount.current,
1776
+ isDebugEnabled()
1777
+ );
1778
+ }
1779
+ mrzDetectionCurrentRetryCount.current++;
894
1780
  setStatus('SCANNING');
1781
+ return; // Don't fall through to else-if
895
1782
  }
896
1783
  } else {
897
- // Normal scan: proceed to hologram check (MRZ validated later)
1784
+ // Normal passport scan (with hologram) - require MRZ to be detected before proceeding
1785
+ const hasFace = cardSizedFaces.length > 0;
1786
+ const hasMRZ = !!mrzText;
1787
+ const allRequiredElementsInFrame = hasFace && hasMRZ;
1788
+
1789
+ setElementsOutsideScanArea([]);
1790
+
1791
+ if (!allRequiredElementsInFrame) {
1792
+ console.log(
1793
+ '[Passport Scan] Valid but waiting for all elements in frame (face + MRZ)'
1794
+ );
1795
+ setStatus('SCANNING');
1796
+ return;
1797
+ }
1798
+
1799
+ // CRITICAL: Final verification - ensure MRZ definitively identifies this as passport
1800
+ // This must pass before we can proceed to hologram
1801
+ if (
1802
+ !parsedMRZData?.fields?.documentCode ||
1803
+ parsedMRZData.fields.documentCode !== 'P'
1804
+ ) {
1805
+ console.log(
1806
+ '[Passport Scan] MRZ detected but not confirmed as passport (code:',
1807
+ parsedMRZData?.fields?.documentCode || 'none',
1808
+ ') - waiting for valid passport MRZ'
1809
+ );
1810
+ setStatus('SCANNING');
1811
+ return;
1812
+ }
1813
+
1814
+ console.log(
1815
+ '[Passport Scan] MRZ confirmed passport (code P) - definitively identified, proceeding to hologram'
1816
+ );
1817
+ // CRITICAL: Lock document type state to PASSPORT before proceeding to hologram
1818
+ // This ensures hologram completion knows it's a passport (no ID_BACK step)
1819
+ setDetectedDocumentType('PASSPORT');
898
1820
  setStatus('SCANNED');
1821
+ setIsTorchOn(false);
899
1822
  setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1823
+ setTimeout(() => {
1824
+ onIdentityDocumentScanned(scannedData);
1825
+ }, 1000);
900
1826
  }
901
- } else if (
902
- ((nextStep === 'SCAN_HOLOGRAM' &&
903
- (!!scannedData.hologramImage ||
904
- hologramDetectionCurrentRetryCount.value >=
905
- HOLOGRAM_DETECTION_RETRY_COUNT) &&
906
- (!!scannedData.secondaryFaceImage ||
907
- secondaryFaceDetectionCurrentRetryCount.value >=
908
- SECOND_FACE_DETECTION_RETRY_COUNT)) ||
909
- onlyMRZScan) &&
910
- !!scannedData.mrzText &&
911
- (parsedMRZData?.valid ||
912
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
913
- ) {
914
- setStatus('SCANNED');
915
- setNextStepAndVibrate('COMPLETED', 'SCAN_HOLOGRAM');
916
- onIdentityDocumentScanned(scannedData);
917
- } else if (!parsedMRZData?.valid) {
918
- mrzDetectionCurrentRetryCount.value++;
919
1827
  }
1828
+ // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
920
1829
  } else if (documentType === 'ID_BACK') {
921
- if (
922
- ((parsedMRZData?.fields?.issuingState === 'TUR' &&
923
- barcode?.value?.trim() ===
924
- parsedMRZData?.fields?.optional1?.trim()) ||
925
- parsedMRZData?.fields?.issuingState !== 'TUR' ||
926
- onlyMRZScan) &&
927
- nextStep === 'SCAN_ID_BACK' &&
928
- !!scannedData.mrzText &&
929
- (parsedMRZData?.valid ||
930
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
931
- ) {
932
- scannedData.barcodeValue = barcode?.value ?? undefined;
933
- setStatus('SCANNED');
934
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
935
- onIdentityDocumentScanned(scannedData);
936
- } else if (!parsedMRZData?.valid) {
937
- mrzDetectionCurrentRetryCount.value++;
938
- }
1830
+ // ID_BACK is now handled in the early-return path above (SCAN_ID_BACK)
1831
+ // This branch only triggers if somehow ID_BACK is detected during a non-back-scan step
1832
+ mrzDetectionCurrentRetryCount.current++;
1833
+ setStatus('SCANNING');
939
1834
  } else {
1835
+ // Document type UNKNOWN - continue scanning until we can classify it
1836
+ if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1837
+ console.log(
1838
+ '[Document Scan] Type UNKNOWN - waiting for clearer detection (faces:',
1839
+ cardSizedFaces.length,
1840
+ 'mrzCode:',
1841
+ parsedMRZData?.fields?.documentCode || 'none',
1842
+ 'text length:',
1843
+ text.length,
1844
+ ')'
1845
+ );
1846
+ }
940
1847
  setStatus('SCANNING');
941
1848
  }
942
-
943
- // Clear OpenCV buffers to prevent memory leaks
944
- try {
945
- OpenCV.clearBuffers();
946
- } catch (bufferError) {
947
- // Ignore buffer cleanup errors
948
- console.warn('Buffer cleanup error:', bufferError);
949
- }
950
1849
  },
951
1850
  [
952
- currentFaceImage,
953
- currentHologramImage,
954
- currentSecondaryFaceImage,
955
- device,
956
1851
  nextStep,
1852
+ frameDimensions,
1853
+ currentHologramImage,
1854
+ currentFaceImage,
1855
+ hasRequiredMRZFields,
1856
+ areMRZFieldsEqual,
1857
+ detectedDocumentType,
957
1858
  onlyMRZScan,
1859
+ isTorchOn,
1860
+ setIsTorchOn,
1861
+ setNextStepAndVibrate,
1862
+ onIdentityDocumentScanned,
1863
+ logMRZDetails,
1864
+ logMRZValidationFailure,
1865
+ currentSecondaryFaceImage,
1866
+ detectHologramNative,
958
1867
  ]
959
1868
  );
960
1869
 
961
- const handleExposureValue = useRunOnJS(
962
- (value: number) => {
963
- setExposure(value);
964
- },
965
- [exposure]
966
- );
967
-
968
- // Focus trigger for when blur is detected (called from worklet)
969
- const triggerFocus = useRunOnJS(
970
- async () => {
971
- if (!cameraRef.current || !device?.supportsFocus) {
972
- return;
973
- }
974
- try {
975
- const width = format?.videoWidth ?? 1920;
976
- const height = format?.videoHeight ?? 1080;
977
- const centerPoint = getScanAreaCenterPoint(width, height);
978
- await cameraRef.current.focus({
979
- x: centerPoint.x,
980
- y: centerPoint.y,
981
- });
982
- } catch (error) {
983
- // Ignore focus errors
984
- }
985
- },
986
- [device, format]
987
- );
988
-
989
- const handleExposureAndBrightness = (frame: Frame) => {
990
- 'worklet';
991
- const averageBrightness = getAverageBrightness(frame);
992
- const minExposure = device?.minExposure ?? 0;
993
- const maxExposure = device?.maxExposure ?? 0;
994
-
995
- // Dynamic thresholds based on scanning state using config values
996
- // Face detection requires higher minimum brightness for reliable detection
997
- const isFrontOrPassport = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT';
998
- const isBack = nextStep === 'SCAN_ID_BACK';
999
-
1000
- // Using config values: faceDetection { low: 50, high: 110, target: 85 }, mrzScanning { low: 45, high: 130, target: 80 }
1001
- const lowerBrightnessBound = isFrontOrPassport ? 50 : 40;
1002
- const upperBrightnessBound = isBack ? 130 : 120;
1003
- const targetBrightness = isFrontOrPassport ? 85 : 80;
1004
-
1005
- // Smooth exposure adjustment with hysteresis to prevent oscillation
1006
- // Only adjust if brightness is significantly outside the acceptable range
1007
- const hysteresis = 5; // Dead zone to prevent jitter
1008
-
1009
- if (
1010
- averageBrightness < (lowerBrightnessBound - hysteresis) &&
1011
- exposureValue.value < maxExposure
1012
- ) {
1013
- // Increase exposure smoothly when too dark
1014
- const step = calculateExposureStep(averageBrightness, targetBrightness);
1015
- exposureValue.value = Math.min(maxExposure, exposureValue.value + step);
1016
- } else if (
1017
- averageBrightness > (upperBrightnessBound + hysteresis) &&
1018
- exposureValue.value > minExposure
1019
- ) {
1020
- // Decrease exposure smoothly when too bright
1021
- const step = calculateExposureStep(averageBrightness, targetBrightness);
1022
- exposureValue.value = Math.max(minExposure, exposureValue.value - step);
1023
- }
1024
- // When within acceptable range (with hysteresis), don't adjust - prevents oscillation
1025
-
1026
- const isBright = averageBrightness > lowerBrightnessBound;
1027
- handleExposureValue(exposureValue.value);
1028
- handleBrightness(isBright);
1029
-
1030
- return isBright;
1031
- };
1032
-
1033
- const handleWorklet = (frame: Frame) => {
1034
- 'worklet';
1035
- try {
1036
- const isBright = handleExposureAndBrightness(frame);
1037
- if (!isBright) {
1038
- return;
1039
- }
1040
-
1041
-
1042
-
1043
- // Check for blur before processing - skip blurry frames
1044
- // Use different thresholds: 25 for front (face detection), 30 for back (MRZ/text)
1045
- // Higher thresholds with improved Laplacian algorithm using H+V gradients
1046
- const isFront = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT';
1047
- const blurThreshold = isFront ? 25 : 30;
1048
- const blurry = checkBlurry(frame, blurThreshold);
1049
- handleBlurStatus(blurry);
1050
- if (blurry) {
1051
- consecutiveBlurCount.value++;
1052
- // Only trigger focus after 2 consecutive blurry frames (matching Flutter)
1053
- if (consecutiveBlurCount.value >= 2) {
1054
- triggerFocus();
1055
- consecutiveBlurCount.value = 0;
1056
- }
1870
+ const handleFrame = useCallback(
1871
+ async (event: NativeSyntheticEvent<{ frame: Frame }>) => {
1872
+ if (!isCameraInitialized.current) {
1057
1873
  return;
1058
1874
  }
1059
- // Reset blur count on sharp frame
1060
- consecutiveBlurCount.value = 0;
1061
1875
 
1876
+ const { frame } = event.nativeEvent;
1062
1877
 
1063
- // Validate frame dimensions before processing
1064
1878
  if (
1065
1879
  !frame.width ||
1066
1880
  !frame.height ||
1067
1881
  frame.width <= 0 ||
1068
1882
  frame.height <= 0
1069
1883
  ) {
1070
- console.warn('Invalid frame dimensions:', {
1071
- width: frame.width,
1072
- height: frame.height,
1073
- });
1074
1884
  return;
1075
1885
  }
1076
1886
 
1077
- // Detect faces first with error handling
1078
- let detectedFaces: Face[] = [];
1079
- try {
1080
- if (faceDetectionEnabled) {
1081
- detectedFaces = detectFaces(frame);
1082
- // Reset error count on successful detection
1083
- faceDetectionErrorCount.value = 0;
1084
- }
1085
- } catch (faceError) {
1086
- console.warn('Face detection failed:', faceError);
1087
- faceDetectionErrorCount.value += 1;
1088
-
1089
- // Disable face detection temporarily after 5 consecutive errors
1090
- if (faceDetectionErrorCount.value >= 5) {
1091
- setFaceDetectionEnabled(false);
1092
- // Re-enable after 10 seconds
1093
- setTimeout(() => {
1094
- setFaceDetectionEnabled(true);
1095
- faceDetectionErrorCount.value = 0;
1096
- }, 10000);
1097
- }
1887
+ const base64Image = frame.base64Image;
1888
+ if (!base64Image) return;
1098
1889
 
1099
- detectedFaces = []; // Continue without face detection
1890
+ const frameBrightness = frame.brightness ?? 128;
1891
+ brightnessHistory.current.push(frameBrightness);
1892
+ if (brightnessHistory.current.length > 5) {
1893
+ brightnessHistory.current.shift();
1100
1894
  }
1895
+ const avgBrightness =
1896
+ brightnessHistory.current.reduce((a, b) => a + b, 0) /
1897
+ brightnessHistory.current.length;
1898
+ const isOverallBright = avgBrightness >= MIN_BRIGHTNESS_THRESHOLD;
1101
1899
 
1102
- // Create a copy of the frame for cropping to avoid buffer conflicts
1103
- let image: CropResult;
1900
+ setIsBrightnessLow(!isOverallBright);
1901
+
1902
+ // Check blur only in center region (area of interest) to avoid false positives
1903
+ // from iOS depth-of-field background blur
1904
+ let isNotBlurry = true;
1905
+ let isBlurry = false; // Track blur state for quality metrics
1104
1906
  try {
1105
- image = crop(frame, {
1106
- cropRegion: {
1107
- top: 0,
1108
- left: 0,
1109
- width: 100,
1110
- height: 100,
1111
- },
1112
- includeImageBase64: true,
1113
- saveAsFile: false,
1114
- });
1115
- } catch (cropError) {
1116
- console.warn('Crop operation failed:', cropError);
1117
- return;
1907
+ // Check blur in center 60% of frame (0.6 width x 0.6 height)
1908
+ // Center position: 50% x, 50% y
1909
+ isBlurry = await OpenCVModule.checkBlurryInRegion(
1910
+ base64Image,
1911
+ 0.5, // centerXPercent
1912
+ 0.5, // centerYPercent
1913
+ 0.6, // widthPercent
1914
+ 0.6, // heightPercent
1915
+ 60 // threshold
1916
+ );
1917
+ isNotBlurry = !isBlurry;
1918
+ setIsFrameBlurry(isBlurry);
1919
+ } catch (error) {
1920
+ setIsFrameBlurry(false);
1118
1921
  }
1119
1922
 
1120
- // Text recognition with error handling
1121
- // Note: CLAHE enhancement is applied to captured images, not live frames
1122
- // ML Kit plugins work directly on Frame objects and don't support Mat input
1123
- let scannedText: BlockText;
1124
- try {
1125
- scannedText = scanText(frame) as any as BlockText;
1126
- } catch (textError) {
1127
- console.warn('Text recognition failed:', textError);
1128
- scannedText = { blocks: [], resultText: '' };
1923
+ // Only proceed if image quality is acceptable
1924
+ const hasAcceptableQuality = isOverallBright && isNotBlurry;
1925
+
1926
+ // Store quality metrics in ref for access in handleFaceAndText callback
1927
+ lastFrameQuality.current = {
1928
+ hasAcceptableQuality,
1929
+ isBlurry, // Use local variable, not state (which is from previous frame)
1930
+ brightness: avgBrightness,
1931
+ };
1932
+
1933
+ if (!hasAcceptableQuality) {
1934
+ consecutiveQualityFailures.current++;
1935
+ // After max failures, allow capture to prevent indefinite waiting
1936
+ if (
1937
+ consecutiveQualityFailures.current < MAX_CONSECUTIVE_QUALITY_FAILURES
1938
+ ) {
1939
+ return;
1940
+ }
1941
+ console.warn(
1942
+ 'Max quality failures reached, proceeding with current frame'
1943
+ );
1944
+ } else {
1945
+ consecutiveQualityFailures.current = 0;
1129
1946
  }
1130
1947
 
1131
- // Barcode scanning with error handling
1132
- let barcodes: any[] = [];
1133
1948
  try {
1134
- barcodes = scanCodes(frame, {
1135
- barcodeTypes: ['code-128', 'code-39', 'code-93', 'ean-13', 'qr'],
1136
- });
1137
- } catch (barcodeError) {
1138
- console.warn('Barcode scanning failed:', barcodeError);
1139
- barcodes = [];
1140
- }
1949
+ // Read faces directly from native ML Kit results
1950
+ let detectedFaces: Face[] = [];
1951
+ if (faceDetectionEnabled && frame.faces) {
1952
+ detectedFaces = frame.faces.map((f) => ({
1953
+ bounds: {
1954
+ x: f.bounds.x,
1955
+ y: f.bounds.y,
1956
+ width: f.bounds.width,
1957
+ height: f.bounds.height,
1958
+ },
1959
+ rollAngle: f.rollAngle,
1960
+ yawAngle: f.yawAngle,
1961
+ }));
1962
+ faceDetectionErrorCount.current = 0;
1963
+ }
1141
1964
 
1142
- handleFaceAndText(
1143
- scannedText.resultText ?? '',
1144
- detectedFaces,
1145
- frame.width,
1146
- frame.height,
1147
- barcodes.length ? barcodes[0] : undefined,
1148
- image?.base64
1149
- );
1150
- } catch (error: any) {
1151
- console.warn('Frame processing error:', error?.message);
1152
- }
1153
- };
1965
+ // Read text directly from native ML Kit results
1966
+ const textBlocks = frame.textBlocks ?? [];
1967
+ const resultText = textBlocks.map((b) => b.text).join('\n');
1968
+ const scannedText: BlockText = {
1969
+ resultText,
1970
+ blocks: textBlocks.map((block) => ({
1971
+ blockText: block.text || '',
1972
+ blockFrame: block.blockFrame ?? {
1973
+ x: 0,
1974
+ y: 0,
1975
+ width: 0,
1976
+ height: 0,
1977
+ boundingCenterX: 0,
1978
+ boundingCenterY: 0,
1979
+ },
1980
+ blockCornerPoints: [] as unknown as CornerPointsType,
1981
+ lines: [] as unknown as LinesData,
1982
+ blockLanguages: [],
1983
+ })),
1984
+ };
1985
+
1986
+ // Read barcodes directly from native ML Kit results
1987
+ let barcodes: Barcode[] = [];
1988
+ if (frame.barcodes) {
1989
+ barcodes = frame.barcodes.map((b) => ({
1990
+ rawValue: b.rawValue,
1991
+ displayValue: b.displayValue,
1992
+ format: b.format,
1993
+ boundingBox: b.boundingBox ?? {
1994
+ left: 0,
1995
+ top: 0,
1996
+ right: 0,
1997
+ bottom: 0,
1998
+ },
1999
+ cornerPoints: b.cornerPoints ?? [],
2000
+ }));
2001
+
2002
+ // Log barcode detection for debugging (only when scanning ID back)
2003
+ if (
2004
+ barcodes.length > 0 &&
2005
+ nextStep === 'SCAN_ID_BACK' &&
2006
+ isDebugEnabled()
2007
+ ) {
2008
+ console.log(`[Barcode JS] Detected ${barcodes.length} barcode(s):`);
2009
+ barcodes.forEach((b, idx) => {
2010
+ const formatNames: { [key: number]: string } = {
2011
+ 5: 'PDF417',
2012
+ 64: 'QR_CODE',
2013
+ 1: 'CODE_128',
2014
+ 2: 'CODE_39',
2015
+ 13: 'EAN_13',
2016
+ 8: 'EAN_8',
2017
+ 4096: 'AZTEC',
2018
+ 16: 'DATA_MATRIX',
2019
+ };
2020
+ const formatName =
2021
+ formatNames[b.format] || `UNKNOWN(${b.format})`;
2022
+ console.log(
2023
+ ` [${idx + 1}] ${formatName}: ${b.rawValue.substring(0, 50)}`
2024
+ );
2025
+ });
2026
+ }
2027
+ }
1154
2028
 
1155
- const frameProcessor = useFrameProcessor(
1156
- (frame) => {
1157
- 'worklet';
2029
+ // Update all debug overlay bounds continuously when debug mode is enabled
2030
+ if (isDebugEnabled() && frameDimensions) {
2031
+ const screen = Dimensions.get('window');
2032
+ const frameAspect = frameDimensions.width / frameDimensions.height;
2033
+ const screenAspect = screen.width / screen.height;
1158
2034
 
1159
- if (!isCameraInitialized.value) {
1160
- return;
1161
- }
2035
+ let scale: number;
2036
+ let offsetX = 0;
2037
+ let offsetY = 0;
1162
2038
 
1163
- if (Platform.OS === 'ios') {
1164
- // iOS: Run at target 6 FPS using runAtTargetFps
1165
- runAtTargetFps(6, () => {
1166
- 'worklet';
1167
- try {
1168
- handleWorklet(frame);
1169
- } catch (error: any) {
1170
- console.warn('iOS Frame processor error:', error?.message || error?.name || String(error));
2039
+ if (frameAspect > screenAspect) {
2040
+ scale = screen.height / frameDimensions.height;
2041
+ offsetX = (frameDimensions.width * scale - screen.width) / 2;
2042
+ } else {
2043
+ scale = screen.width / frameDimensions.width;
2044
+ offsetY = (frameDimensions.height * scale - screen.height) / 2;
1171
2045
  }
1172
- });
1173
- } else {
1174
- // Android: Run async without throttling
1175
- runAsync(frame, () => {
1176
- 'worklet';
1177
- try {
1178
- handleWorklet(frame);
1179
- } catch (workletError: any) {
1180
- console.warn('[Android workletCallback] Worklet execution error:', workletError?.message || workletError?.name || String(workletError));
2046
+
2047
+ const scanLeft = (screen.width * 0.05 + offsetX) / scale;
2048
+ const scanTop = (screen.height * 0.36 + offsetY) / scale;
2049
+ const scanRight = (screen.width * 0.95 + offsetX) / scale;
2050
+ const scanBottom = (screen.height * 0.64 + offsetY) / scale;
2051
+ const isInsideScan = (x: number, y: number, w: number, h: number) =>
2052
+ x >= scanLeft &&
2053
+ y >= scanTop &&
2054
+ x + w <= scanRight &&
2055
+ y + h <= scanBottom;
2056
+
2057
+ // Update barcode bounds
2058
+ if (barcodes.length > 0 && barcodes[0]) {
2059
+ const bbox = barcodes[0].boundingBox;
2060
+ const corners = barcodes[0].cornerPoints;
2061
+ let angle = 0;
2062
+
2063
+ // Calculate angle from corner points if available
2064
+ if (corners && corners.length >= 2) {
2065
+ const transformedCorners = corners.map((c) => ({
2066
+ x: c.x * scale - offsetX,
2067
+ y: c.y * scale - offsetY,
2068
+ }));
2069
+ // Calculate angle from first two corners (bottom edge)
2070
+ const dx = transformedCorners[1].x - transformedCorners[0].x;
2071
+ const dy = transformedCorners[1].y - transformedCorners[0].y;
2072
+ angle = Math.atan2(dy, dx) * (180 / Math.PI);
2073
+ }
2074
+
2075
+ if (isDebugEnabled()) {
2076
+ console.log('[Debug] Barcode detected:', { bbox, angle });
2077
+ }
2078
+ setBarcodeBounds({
2079
+ x: bbox.left * scale - offsetX,
2080
+ y: bbox.top * scale - offsetY,
2081
+ width: (bbox.right - bbox.left) * scale,
2082
+ height: (bbox.bottom - bbox.top) * scale,
2083
+ angle,
2084
+ corners: corners?.map((c) => ({
2085
+ x: c.x * scale - offsetX,
2086
+ y: c.y * scale - offsetY,
2087
+ })),
2088
+ });
2089
+ } else {
2090
+ setBarcodeBounds(null);
1181
2091
  }
1182
- });
2092
+
2093
+ // Update face bounds continuously
2094
+ if (detectedFaces.length > 0 && detectedFaces[0]) {
2095
+ const faceBounds = detectedFaces[0].bounds;
2096
+ const rollAngle = detectedFaces[0].rollAngle;
2097
+ const faceWidth = faceBounds.width * scale;
2098
+ const faceHeight = faceBounds.height * scale;
2099
+ const cropPadding = Math.max(faceWidth * 0.15, faceHeight * 0.15);
2100
+ setDocumentPlaneBounds({
2101
+ x: faceBounds.x * scale - offsetX,
2102
+ y: faceBounds.y * scale - offsetY,
2103
+ width: faceWidth,
2104
+ height: faceHeight,
2105
+ rollAngle,
2106
+ cropPadding,
2107
+ });
2108
+ } else {
2109
+ setDocumentPlaneBounds(null);
2110
+ }
2111
+
2112
+ // Update secondary face bounds
2113
+ if (detectedFaces.length > 1 && detectedFaces[1]) {
2114
+ const secondaryBounds = detectedFaces[1].bounds;
2115
+ const rollAngle = detectedFaces[1].rollAngle;
2116
+ const secondaryWidth = secondaryBounds.width * scale;
2117
+ const secondaryHeight = secondaryBounds.height * scale;
2118
+ const cropPadding = Math.max(
2119
+ secondaryWidth * 0.15,
2120
+ secondaryHeight * 0.15
2121
+ );
2122
+ if (
2123
+ isInsideScan(
2124
+ secondaryBounds.x,
2125
+ secondaryBounds.y,
2126
+ secondaryBounds.width,
2127
+ secondaryBounds.height
2128
+ )
2129
+ ) {
2130
+ setSecondaryFaceBounds({
2131
+ x: secondaryBounds.x * scale - offsetX,
2132
+ y: secondaryBounds.y * scale - offsetY,
2133
+ width: secondaryWidth,
2134
+ height: secondaryHeight,
2135
+ rollAngle,
2136
+ cropPadding,
2137
+ });
2138
+ } else {
2139
+ setSecondaryFaceBounds(null);
2140
+ }
2141
+ } else {
2142
+ setSecondaryFaceBounds(null);
2143
+ }
2144
+
2145
+ // Detect MRZ and signature text areas continuously
2146
+ if (textBlocks.length > 0) {
2147
+ console.log('[Debug] Text blocks count:', textBlocks.length);
2148
+ // Find MRZ-like text blocks (bottom area, contains MRZ-like characters)
2149
+ // More strict pattern: look for blocks with 8+ consecutive uppercase/numbers/< characters AND
2150
+ // must contain at least one '<' character (true MRZ characteristic)
2151
+ const mrzPattern = /[A-Z0-9<]{8,}.*</i;
2152
+ const bottomHalf = frame.height * 0.5; // Increased from 0.67 to catch more MRZ blocks
2153
+
2154
+ // Log bottom area blocks for debugging
2155
+ const bottomBlocks = textBlocks.filter(
2156
+ (block) => block.blockFrame && block.blockFrame.y > bottomHalf
2157
+ );
2158
+ if (bottomBlocks.length > 0) {
2159
+ console.log(
2160
+ '[Debug] Bottom area blocks:',
2161
+ bottomBlocks.map((b) => b.text.substring(0, 30))
2162
+ );
2163
+ }
2164
+
2165
+ const mrzBlocks = textBlocks.filter(
2166
+ (block) =>
2167
+ block.blockFrame &&
2168
+ block.blockFrame.y > bottomHalf &&
2169
+ mrzPattern.test(block.text)
2170
+ );
2171
+
2172
+ console.log('[Debug] MRZ blocks found:', mrzBlocks.length);
2173
+ if (mrzBlocks.length > 0) {
2174
+ const minX = Math.min(...mrzBlocks.map((b) => b.blockFrame!.x));
2175
+ const minY = Math.min(...mrzBlocks.map((b) => b.blockFrame!.y));
2176
+ const maxX = Math.max(
2177
+ ...mrzBlocks.map((b) => b.blockFrame!.x + b.blockFrame!.width)
2178
+ );
2179
+ const maxY = Math.max(
2180
+ ...mrzBlocks.map((b) => b.blockFrame!.y + b.blockFrame!.height)
2181
+ );
2182
+
2183
+ // Collect all corner points from MRZ blocks
2184
+ const allCornerPoints = mrzBlocks
2185
+ .flatMap((b) => b.cornerPoints || [])
2186
+ .map((c) => ({
2187
+ x: c.x * scale - offsetX,
2188
+ y: c.y * scale - offsetY,
2189
+ }));
2190
+
2191
+ let angle = 0;
2192
+ if (allCornerPoints.length >= 2) {
2193
+ // Calculate angle from first two points
2194
+ const dx = allCornerPoints[1].x - allCornerPoints[0].x;
2195
+ const dy = allCornerPoints[1].y - allCornerPoints[0].y;
2196
+ angle = Math.atan2(dy, dx) * (180 / Math.PI);
2197
+ }
2198
+
2199
+ console.log('[Debug] MRZ bounds:', {
2200
+ minX,
2201
+ minY,
2202
+ maxX,
2203
+ maxY,
2204
+ angle,
2205
+ });
2206
+ setMrzBounds({
2207
+ x: minX * scale - offsetX,
2208
+ y: minY * scale - offsetY,
2209
+ width: (maxX - minX) * scale,
2210
+ height: (maxY - minY) * scale,
2211
+ angle,
2212
+ corners:
2213
+ allCornerPoints.length > 0 ? allCornerPoints : undefined,
2214
+ });
2215
+ } else {
2216
+ setMrzBounds(null);
2217
+ }
2218
+
2219
+ // Detect signature area
2220
+ const signaturePattern = /signature|imza|İmza/i;
2221
+ const signatureBlocks = textBlocks.filter(
2222
+ (block) => block.blockFrame && signaturePattern.test(block.text)
2223
+ );
2224
+
2225
+ if (textBlocks.length > 0 && signatureBlocks.length === 0) {
2226
+ console.log(
2227
+ `[Signature Debug] No signature blocks found. All blocks (${textBlocks.length}):`,
2228
+ textBlocks.map((b) => b.text).join(' | ')
2229
+ );
2230
+ }
2231
+
2232
+ if (signatureBlocks.length > 0) {
2233
+ const minX = Math.min(
2234
+ ...signatureBlocks.map((b) => b.blockFrame!.x)
2235
+ );
2236
+ const minY = Math.min(
2237
+ ...signatureBlocks.map((b) => b.blockFrame!.y)
2238
+ );
2239
+ const maxX = Math.max(
2240
+ ...signatureBlocks.map(
2241
+ (b) => b.blockFrame!.x + b.blockFrame!.width
2242
+ )
2243
+ );
2244
+ const maxY = Math.max(
2245
+ ...signatureBlocks.map(
2246
+ (b) => b.blockFrame!.y + b.blockFrame!.height
2247
+ )
2248
+ );
2249
+
2250
+ // Collect all corner points from signature blocks
2251
+ const allCornerPoints = signatureBlocks
2252
+ .flatMap((b) => b.cornerPoints || [])
2253
+ .map((c) => ({
2254
+ x: c.x * scale - offsetX,
2255
+ y: c.y * scale - offsetY,
2256
+ }));
2257
+
2258
+ let angle = 0;
2259
+ if (allCornerPoints.length >= 2) {
2260
+ // Calculate angle from first two points
2261
+ const dx = allCornerPoints[1].x - allCornerPoints[0].x;
2262
+ const dy = allCornerPoints[1].y - allCornerPoints[0].y;
2263
+ angle = Math.atan2(dy, dx) * (180 / Math.PI);
2264
+ }
2265
+
2266
+ setSignatureBounds({
2267
+ x: minX * scale - offsetX,
2268
+ y: minY * scale - offsetY,
2269
+ width: (maxX - minX) * scale,
2270
+ height: (maxY - minY) * scale,
2271
+ angle,
2272
+ corners:
2273
+ allCornerPoints.length > 0 ? allCornerPoints : undefined,
2274
+ });
2275
+ } else {
2276
+ setSignatureBounds(null);
2277
+ }
2278
+
2279
+ // Check if all required elements are detected based on document type
2280
+ if (nextStep === 'SCAN_ID_BACK') {
2281
+ // ID Back: MRZ + barcode (barcode optional but preferred)
2282
+ const hasMRZ = mrzBlocks.length > 0;
2283
+ const hasBarcode =
2284
+ barcodes.length > 0 || cachedBarcode.current !== null;
2285
+ const allPresent = hasMRZ && hasBarcode;
2286
+ setAllElementsDetected(allPresent);
2287
+
2288
+ // Don't block based on bounds - allow elements even if slightly outside
2289
+ setElementsOutsideScanArea([]);
2290
+
2291
+ if (!allPresent) {
2292
+ const missing = [];
2293
+ if (!hasMRZ) missing.push('MRZ');
2294
+ if (!hasBarcode) missing.push('Barcode');
2295
+ console.log(
2296
+ `[Frame Check] Missing elements: ${missing.join(', ')}`
2297
+ );
2298
+ } else {
2299
+ console.log('[Frame Check] ✓ All elements detected in frame');
2300
+ }
2301
+ } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
2302
+ // Check if it's passport (has MRZ) or ID front (no MRZ)
2303
+ const hasMRZ = mrzBlocks.length > 0;
2304
+ const hasFace = detectedFaces.length > 0;
2305
+ const hasSignature = signatureBlocks.length > 0;
2306
+
2307
+ // Don't block based on bounds - allow elements even if slightly outside
2308
+ setElementsOutsideScanArea([]);
2309
+
2310
+ let allPresent = false;
2311
+ if (hasMRZ) {
2312
+ // Passport: face + MRZ
2313
+ allPresent = hasFace && hasMRZ;
2314
+ if (!allPresent) {
2315
+ const missing = [];
2316
+ if (!hasFace) missing.push('Face');
2317
+ if (!hasMRZ) missing.push('MRZ');
2318
+ console.log(
2319
+ `[Frame Check] Passport - Missing elements: ${missing.join(', ')}`
2320
+ );
2321
+ } else {
2322
+ console.log(
2323
+ '[Frame Check] ✓ Passport - All elements detected (face + MRZ)'
2324
+ );
2325
+ }
2326
+ } else {
2327
+ // ID Front: face + signature
2328
+ allPresent = hasFace && hasSignature;
2329
+ if (!allPresent) {
2330
+ const missing = [];
2331
+ if (!hasFace) missing.push('Face');
2332
+ if (!hasSignature) missing.push('Signature');
2333
+ console.log(
2334
+ `[Frame Check] ID Front - Missing elements: ${missing.join(', ')}`
2335
+ );
2336
+ } else {
2337
+ console.log(
2338
+ '[Frame Check] ✓ ID Front - All elements detected (face + signature)'
2339
+ );
2340
+ }
2341
+ }
2342
+
2343
+ setAllElementsDetected(allPresent);
2344
+ } else {
2345
+ setAllElementsDetected(false);
2346
+ setElementsOutsideScanArea([]);
2347
+ }
2348
+ } else {
2349
+ setMrzBounds(null);
2350
+ setSignatureBounds(null);
2351
+ setAllElementsDetected(false);
2352
+ setElementsOutsideScanArea([]);
2353
+ }
2354
+ } else if (!isDebugEnabled()) {
2355
+ // Clear all bounds when debug mode is disabled
2356
+ setBarcodeBounds(null);
2357
+ setDocumentPlaneBounds(null);
2358
+ setSecondaryFaceBounds(null);
2359
+ setMrzBounds(null);
2360
+ setSignatureBounds(null);
2361
+ }
2362
+
2363
+ // Update allElementsDetected for status text display (regardless of debug mode)
2364
+ if (nextStep === 'SCAN_ID_BACK') {
2365
+ const hasMRZ = textBlocks.some((b) =>
2366
+ /[A-Z0-9<]{8,}.*</i.test(b.text)
2367
+ );
2368
+ const hasBarcode =
2369
+ barcodes.length > 0 || cachedBarcode.current !== null;
2370
+ setAllElementsDetected(hasMRZ && hasBarcode);
2371
+ } else if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
2372
+ const hasMRZ = textBlocks.some((b) =>
2373
+ /[A-Z0-9<]{8,}.*</i.test(b.text)
2374
+ );
2375
+ const hasFace = detectedFaces.length > 0;
2376
+ const hasSignature = textBlocks.some((b) =>
2377
+ /signature|imza|İmza/i.test(b.text)
2378
+ );
2379
+ setAllElementsDetected(
2380
+ hasMRZ ? hasFace && hasMRZ : hasFace && hasSignature
2381
+ );
2382
+ } else {
2383
+ setAllElementsDetected(false);
2384
+ }
2385
+
2386
+ // Check if detected elements are inside the scan area
2387
+ const scanScreen = Dimensions.get('window');
2388
+ const scanFrameAspect = frame.width / frame.height;
2389
+ const scanScreenAspect = scanScreen.width / scanScreen.height;
2390
+ let scanScale: number;
2391
+ let scanOffsetX = 0;
2392
+ let scanOffsetY = 0;
2393
+ if (scanFrameAspect > scanScreenAspect) {
2394
+ scanScale = scanScreen.height / frame.height;
2395
+ scanOffsetX = (frame.width * scanScale - scanScreen.width) / 2;
2396
+ } else {
2397
+ scanScale = scanScreen.width / frame.width;
2398
+ scanOffsetY = (frame.height * scanScale - scanScreen.height) / 2;
2399
+ }
2400
+ const scanLeft = (scanScreen.width * 0.05 + scanOffsetX) / scanScale;
2401
+ const scanTop = (scanScreen.height * 0.36 + scanOffsetY) / scanScale;
2402
+ const scanRight = (scanScreen.width * 0.95 + scanOffsetX) / scanScale;
2403
+ const scanBottom = (scanScreen.height * 0.64 + scanOffsetY) / scanScale;
2404
+
2405
+ const isInsideScan = (x: number, y: number, w: number, h: number) =>
2406
+ x >= scanLeft &&
2407
+ y >= scanTop &&
2408
+ x + w <= scanRight &&
2409
+ y + h <= scanBottom;
2410
+
2411
+ const outsideElements: string[] = [];
2412
+
2413
+ // Collect all detected element bounds
2414
+ const allBounds: Array<{
2415
+ x: number;
2416
+ y: number;
2417
+ x2: number;
2418
+ y2: number;
2419
+ }> = [];
2420
+ const primaryFace = detectedFaces[0];
2421
+ if (primaryFace) {
2422
+ if (
2423
+ primaryFace.bounds.width >= frame.width * 0.05 &&
2424
+ primaryFace.bounds.height >= frame.width * 0.05
2425
+ ) {
2426
+ allBounds.push({
2427
+ x: primaryFace.bounds.x,
2428
+ y: primaryFace.bounds.y,
2429
+ x2: primaryFace.bounds.x + primaryFace.bounds.width,
2430
+ y2: primaryFace.bounds.y + primaryFace.bounds.height,
2431
+ });
2432
+ if (
2433
+ !isInsideScan(
2434
+ primaryFace.bounds.x,
2435
+ primaryFace.bounds.y,
2436
+ primaryFace.bounds.width,
2437
+ primaryFace.bounds.height
2438
+ )
2439
+ ) {
2440
+ outsideElements.push('face');
2441
+ }
2442
+ }
2443
+ }
2444
+ for (const block of textBlocks) {
2445
+ if (block.blockFrame) {
2446
+ const bf = block.blockFrame;
2447
+ if (bf.width > 0 && bf.height > 0) {
2448
+ allBounds.push({
2449
+ x: bf.x,
2450
+ y: bf.y,
2451
+ x2: bf.x + bf.width,
2452
+ y2: bf.y + bf.height,
2453
+ });
2454
+ }
2455
+ const isMRZ = /[A-Z0-9<]{8,}.*</i.test(block.text);
2456
+ const isSignature = /signature|imza|İmza/i.test(block.text);
2457
+ if (
2458
+ (isMRZ || isSignature) &&
2459
+ !isInsideScan(bf.x, bf.y, bf.width, bf.height)
2460
+ ) {
2461
+ outsideElements.push('text');
2462
+ }
2463
+ }
2464
+ }
2465
+ for (const bc of barcodes) {
2466
+ if (bc.boundingBox) {
2467
+ const bb = bc.boundingBox;
2468
+ if (
2469
+ !isInsideScan(
2470
+ bb.left,
2471
+ bb.top,
2472
+ bb.right - bb.left,
2473
+ bb.bottom - bb.top
2474
+ )
2475
+ ) {
2476
+ outsideElements.push('barcode');
2477
+ }
2478
+ }
2479
+ }
2480
+
2481
+ // Check that detected content spans enough of the scan area horizontally and vertically
2482
+ // This catches cases where one side of the card is off-screen (elements on that side won't be detected)
2483
+ if (allBounds.length > 0 && outsideElements.length === 0) {
2484
+ const minX = Math.min(...allBounds.map((b) => b.x));
2485
+ const maxX = Math.max(...allBounds.map((b) => b.x2));
2486
+ const minY = Math.min(...allBounds.map((b) => b.y));
2487
+ const maxY = Math.max(...allBounds.map((b) => b.y2));
2488
+ const spanWidth = maxX - minX;
2489
+ const spanHeight = maxY - minY;
2490
+ const scanWidth = scanRight - scanLeft;
2491
+ const scanHeight = scanBottom - scanTop;
2492
+ // Require content to span at least 55% of scan area in both dimensions
2493
+ if (spanWidth < scanWidth * 0.55 || spanHeight < scanHeight * 0.55) {
2494
+ outsideElements.push('span');
2495
+ }
2496
+ }
2497
+
2498
+ setElementsOutsideScanArea(outsideElements);
2499
+
2500
+ handleFaceAndText(
2501
+ scannedText.resultText ?? '',
2502
+ detectedFaces,
2503
+ frame.width,
2504
+ frame.height,
2505
+ barcodes.length ? barcodes[0] : undefined,
2506
+ base64Image,
2507
+ outsideElements.length > 0,
2508
+ frame.mrzResult
2509
+ );
2510
+ } catch (error: any) {
2511
+ console.warn('Frame processing error:', error?.message);
1183
2512
  }
1184
2513
  },
1185
- [handleFaceAndText, isCameraInitialized]
2514
+ [faceDetectionEnabled, frameDimensions, handleFaceAndText, nextStep]
2515
+ );
2516
+
2517
+ const handleCameraReady = useCallback(
2518
+ (
2519
+ _event: NativeSyntheticEvent<{
2520
+ minExposureOffset: number;
2521
+ maxExposureOffset: number;
2522
+ }>
2523
+ ) => {
2524
+ isCameraInitialized.current = true;
2525
+ },
2526
+ []
2527
+ );
2528
+
2529
+ const handleCameraError = useCallback(
2530
+ (event: NativeSyntheticEvent<{ error: string }>) => {
2531
+ console.error('Camera error:', event.nativeEvent.error);
2532
+ },
2533
+ []
1186
2534
  );
1187
2535
 
1188
2536
  if (!permissionsRequested) {
1189
2537
  return (
1190
2538
  <SafeAreaView style={styles.permissionContainer}>
2539
+ <StatusBar barStyle="dark-content" />
1191
2540
  <ActivityIndicator size="large" color={theme.colors.primary} />
1192
2541
  </SafeAreaView>
1193
2542
  );
1194
2543
  }
1195
2544
 
1196
- if (!cameraPermission.hasPermission) {
2545
+ if (!hasPermission) {
1197
2546
  return (
1198
2547
  <SafeAreaView style={styles.permissionContainer}>
1199
- <Text style={styles.permissionText}>
2548
+ <StatusBar barStyle="dark-content" />
2549
+ <TextView style={styles.permissionText}>
1200
2550
  {t('general.noCameraPermissionGiven')}
1201
- </Text>
2551
+ </TextView>
1202
2552
  <StyledButton
1203
2553
  mode="contained"
1204
2554
  onPress={() => {
@@ -1211,32 +2561,13 @@ const IdentityDocumentCamera = ({
1211
2561
  );
1212
2562
  }
1213
2563
 
1214
- if (device == null) {
1215
- return (
1216
- <SafeAreaView style={styles.permissionContainer}>
1217
- <TextView style={styles.permissionText}>
1218
- {t('general.noCameraDetected')}
1219
- </TextView>
1220
- </SafeAreaView>
1221
- );
1222
- }
1223
-
1224
- const handleFocus = async (event: GestureResponderEvent) => {
1225
- if (cameraRef.current && device.supportsFocus) {
1226
- try {
1227
- const { locationX, locationY } = event.nativeEvent;
1228
- await cameraRef.current.focus({
1229
- x: locationX,
1230
- y: locationY,
1231
- });
1232
- } catch (error) {
1233
- // console.log('Error while focusing:', error);
1234
- }
1235
- }
1236
- };
1237
-
1238
2564
  return (
1239
2565
  <View style={StyleSheet.absoluteFill}>
2566
+ <StatusBar
2567
+ barStyle="light-content"
2568
+ backgroundColor="transparent"
2569
+ translucent
2570
+ />
1240
2571
  {!hasGuideShown ? (
1241
2572
  <SafeAreaView style={styles.guide}>
1242
2573
  <LottieView
@@ -1273,52 +2604,513 @@ const IdentityDocumentCamera = ({
1273
2604
  </SafeAreaView>
1274
2605
  ) : (
1275
2606
  <>
1276
- <Camera
2607
+ <TrustchexCamera
1277
2608
  ref={cameraRef}
1278
- frameProcessor={frameProcessor}
1279
- style={StyleSheet.absoluteFill}
1280
- device={device}
1281
- format={format}
1282
- isActive={isActive}
1283
- photo={false}
1284
- video={false}
1285
- audio={false}
1286
- torch={isTorchOn ? 'on' : 'off'}
1287
- fps={Math.max(format?.minFps ?? 0, 30)}
1288
- exposure={exposure}
1289
- onInitialized={() => {
1290
- isCameraInitialized.value = true;
1291
- }}
2609
+ style={StyleSheet.absoluteFill as ViewStyle}
2610
+ cameraType="back"
2611
+ enableFrameProcessing={isActive}
2612
+ enableFaceDetection={isActive && faceDetectionEnabled}
2613
+ enableTextRecognition={isActive}
2614
+ enableMrzValidation={isActive}
2615
+ enableBarcodeScanning={isActive && nextStep === 'SCAN_ID_BACK'}
2616
+ includeBase64={isActive}
2617
+ targetFps={10}
2618
+ torchEnabled={isTorchOn}
2619
+ onFrameAvailable={handleFrame}
2620
+ onCameraReady={handleCameraReady}
2621
+ onCameraError={handleCameraError}
1292
2622
  />
1293
- <View style={[styles.topZone, { paddingTop: insets.top }]}>
1294
- {/* Step Progress Indicator - show only after document type detected and not completed/scanned */}
1295
- {nextStep !== 'COMPLETED' && status !== 'SCANNED' && detectedDocumentType !== 'UNKNOWN' && (
1296
- <TextView style={styles.stepIndicator}>
1297
- {nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1298
- ? `${t('identityDocumentCamera.frontSide')} ${t('identityDocumentCamera.stepProgress', {
1299
- current: 1,
1300
- total: onlyMRZScan
1301
- ? (detectedDocumentType === 'PASSPORT' ? 1 : 2)
1302
- : (detectedDocumentType === 'PASSPORT' ? 2 : 3)
1303
- })}`
1304
- : nextStep === 'SCAN_HOLOGRAM'
1305
- ? `${t('identityDocumentCamera.hologramCheck')} ${t('identityDocumentCamera.stepProgress', {
1306
- current: 2,
1307
- total: detectedDocumentType === 'PASSPORT' ? 2 : 3
1308
- })}`
1309
- : nextStep === 'SCAN_ID_BACK'
1310
- ? `${t('identityDocumentCamera.backSide')} • ${t('identityDocumentCamera.stepProgress', { current: 3, total: 3 })}`
1311
- : ''}
1312
- </TextView>
2623
+ {isDebugEnabled() &&
2624
+ documentPlaneBounds &&
2625
+ nextStep !== 'COMPLETED' && (
2626
+ <>
2627
+ {/* Crop area border (with padding) */}
2628
+ {!!documentPlaneBounds.cropPadding && (
2629
+ <View
2630
+ style={{
2631
+ position: 'absolute',
2632
+ left:
2633
+ documentPlaneBounds.x - documentPlaneBounds.cropPadding,
2634
+ top:
2635
+ documentPlaneBounds.y - documentPlaneBounds.cropPadding,
2636
+ width:
2637
+ documentPlaneBounds.width +
2638
+ 2 * documentPlaneBounds.cropPadding,
2639
+ height:
2640
+ documentPlaneBounds.height +
2641
+ 2 * documentPlaneBounds.cropPadding,
2642
+ borderWidth: 2,
2643
+ borderColor: 'rgba(76, 175, 80, 0.5)',
2644
+ borderStyle: 'dashed',
2645
+ borderRadius: 8,
2646
+ backgroundColor: 'transparent',
2647
+ transform: [
2648
+ {
2649
+ rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg`,
2650
+ },
2651
+ ],
2652
+ transformOrigin: 'center',
2653
+ }}
2654
+ />
2655
+ )}
2656
+ {/* Actual face border */}
2657
+ <View
2658
+ style={{
2659
+ position: 'absolute',
2660
+ left: documentPlaneBounds.x,
2661
+ top: documentPlaneBounds.y,
2662
+ width: documentPlaneBounds.width,
2663
+ height: documentPlaneBounds.height,
2664
+ borderWidth: 3,
2665
+ borderColor: '#4CAF50',
2666
+ borderRadius: 8,
2667
+ backgroundColor: 'transparent',
2668
+ transform: [
2669
+ { rotate: `${-(documentPlaneBounds.rollAngle || 0)}deg` },
2670
+ ],
2671
+ transformOrigin: 'center',
2672
+ }}
2673
+ >
2674
+ {!!documentPlaneBounds.rollAngle &&
2675
+ Math.abs(documentPlaneBounds.rollAngle) > 5 && (
2676
+ <TextView
2677
+ style={{
2678
+ position: 'absolute',
2679
+ top: -20,
2680
+ left: 0,
2681
+ color: '#4CAF50',
2682
+ fontSize: 10,
2683
+ fontWeight: 'bold',
2684
+ backgroundColor: 'rgba(0,0,0,0.7)',
2685
+ paddingHorizontal: 4,
2686
+ paddingVertical: 2,
2687
+ borderRadius: 2,
2688
+ }}
2689
+ >
2690
+ {documentPlaneBounds.rollAngle.toFixed(1)}°
2691
+ </TextView>
2692
+ )}
2693
+ </View>
2694
+ </>
2695
+ )}
2696
+ {isDebugEnabled() &&
2697
+ secondaryFaceBounds &&
2698
+ nextStep !== 'COMPLETED' && (
2699
+ <>
2700
+ {/* Crop area border (with padding) */}
2701
+ {!!secondaryFaceBounds.cropPadding && (
2702
+ <View
2703
+ style={{
2704
+ position: 'absolute',
2705
+ left:
2706
+ secondaryFaceBounds.x - secondaryFaceBounds.cropPadding,
2707
+ top:
2708
+ secondaryFaceBounds.y - secondaryFaceBounds.cropPadding,
2709
+ width:
2710
+ secondaryFaceBounds.width +
2711
+ 2 * secondaryFaceBounds.cropPadding,
2712
+ height:
2713
+ secondaryFaceBounds.height +
2714
+ 2 * secondaryFaceBounds.cropPadding,
2715
+ borderWidth: 2,
2716
+ borderColor: 'rgba(33, 150, 243, 0.5)',
2717
+ borderStyle: 'dashed',
2718
+ borderRadius: 8,
2719
+ backgroundColor: 'transparent',
2720
+ transform: [
2721
+ {
2722
+ rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg`,
2723
+ },
2724
+ ],
2725
+ transformOrigin: 'center',
2726
+ }}
2727
+ />
2728
+ )}
2729
+ {/* Actual face border */}
2730
+ <View
2731
+ style={{
2732
+ position: 'absolute',
2733
+ left: secondaryFaceBounds.x,
2734
+ top: secondaryFaceBounds.y,
2735
+ width: secondaryFaceBounds.width,
2736
+ height: secondaryFaceBounds.height,
2737
+ borderWidth: 3,
2738
+ borderColor: '#2196F3',
2739
+ borderRadius: 8,
2740
+ backgroundColor: 'transparent',
2741
+ transform: [
2742
+ { rotate: `${-(secondaryFaceBounds.rollAngle || 0)}deg` },
2743
+ ],
2744
+ transformOrigin: 'center',
2745
+ }}
2746
+ >
2747
+ {!!secondaryFaceBounds.rollAngle &&
2748
+ Math.abs(secondaryFaceBounds.rollAngle) > 5 && (
2749
+ <TextView
2750
+ style={{
2751
+ position: 'absolute',
2752
+ top: -20,
2753
+ left: 0,
2754
+ color: '#2196F3',
2755
+ fontSize: 10,
2756
+ fontWeight: 'bold',
2757
+ backgroundColor: 'rgba(0,0,0,0.7)',
2758
+ paddingHorizontal: 4,
2759
+ paddingVertical: 2,
2760
+ borderRadius: 2,
2761
+ }}
2762
+ >
2763
+ {secondaryFaceBounds.rollAngle.toFixed(1)}°
2764
+ </TextView>
2765
+ )}
2766
+ </View>
2767
+ </>
1313
2768
  )}
1314
- {/* Status-based guidance text */}
1315
- <TextView style={[
1316
- styles.topZoneText,
1317
- status === 'SCANNING' && styles.topZoneTextScanning,
1318
- status === 'SCANNED' && styles.topZoneTextSuccess,
1319
- status === 'INCORRECT' && styles.topZoneTextError,
1320
- (isBrightnessLow || isFrameBlurry) && styles.topZoneTextWarning,
1321
- ]}>
2769
+ {isDebugEnabled() && barcodeBounds && nextStep !== 'COMPLETED' && (
2770
+ <>
2771
+ {barcodeBounds.corners && barcodeBounds.corners.length >= 4 ? (
2772
+ // Render using corner points for precise rotated border
2773
+ <>
2774
+ {/* Draw border lines between corners */}
2775
+ {[0, 1, 2, 3].map((i) => {
2776
+ const start = barcodeBounds.corners![i];
2777
+ const end = barcodeBounds.corners![(i + 1) % 4];
2778
+ const dx = end.x - start.x;
2779
+ const dy = end.y - start.y;
2780
+ const length = Math.sqrt(dx * dx + dy * dy);
2781
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2782
+
2783
+ return (
2784
+ <View
2785
+ key={i}
2786
+ style={{
2787
+ position: 'absolute',
2788
+ left: start.x,
2789
+ top: start.y,
2790
+ width: length,
2791
+ height: 3,
2792
+ backgroundColor: '#FF9800',
2793
+ transform: [{ rotate: `${angle}deg` }],
2794
+ transformOrigin: 'top left',
2795
+ }}
2796
+ />
2797
+ );
2798
+ })}
2799
+ {/* Draw corner markers */}
2800
+ {barcodeBounds.corners.map((corner, idx) => (
2801
+ <View
2802
+ key={`corner-${idx}`}
2803
+ style={{
2804
+ position: 'absolute',
2805
+ left: corner.x - 4,
2806
+ top: corner.y - 4,
2807
+ width: 8,
2808
+ height: 8,
2809
+ borderRadius: 4,
2810
+ backgroundColor: '#FF9800',
2811
+ }}
2812
+ />
2813
+ ))}
2814
+ {/* Angle indicator */}
2815
+ {!!barcodeBounds.angle &&
2816
+ Math.abs(barcodeBounds.angle) > 5 && (
2817
+ <TextView
2818
+ style={{
2819
+ position: 'absolute',
2820
+ left: barcodeBounds.x,
2821
+ top: barcodeBounds.y - 20,
2822
+ color: '#FF9800',
2823
+ fontSize: 10,
2824
+ fontWeight: 'bold',
2825
+ backgroundColor: 'rgba(0,0,0,0.7)',
2826
+ paddingHorizontal: 4,
2827
+ paddingVertical: 2,
2828
+ borderRadius: 2,
2829
+ }}
2830
+ >
2831
+ {barcodeBounds.angle.toFixed(1)}°
2832
+ </TextView>
2833
+ )}
2834
+ </>
2835
+ ) : (
2836
+ // Fallback to rotated rectangle if corners not available
2837
+ <View
2838
+ style={{
2839
+ position: 'absolute',
2840
+ left: barcodeBounds.x + barcodeBounds.width / 2,
2841
+ top: barcodeBounds.y + barcodeBounds.height / 2,
2842
+ width: barcodeBounds.width,
2843
+ height: barcodeBounds.height,
2844
+ marginLeft: -barcodeBounds.width / 2,
2845
+ marginTop: -barcodeBounds.height / 2,
2846
+ borderWidth: 3,
2847
+ borderColor: '#FF9800',
2848
+ borderRadius: 8,
2849
+ backgroundColor: 'transparent',
2850
+ transform: [{ rotate: `${barcodeBounds.angle || 0}deg` }],
2851
+ }}
2852
+ >
2853
+ {!!barcodeBounds.angle &&
2854
+ Math.abs(barcodeBounds.angle) > 5 && (
2855
+ <TextView
2856
+ style={{
2857
+ position: 'absolute',
2858
+ top: -20,
2859
+ left: 0,
2860
+ color: '#FF9800',
2861
+ fontSize: 10,
2862
+ fontWeight: 'bold',
2863
+ backgroundColor: 'rgba(0,0,0,0.7)',
2864
+ paddingHorizontal: 4,
2865
+ paddingVertical: 2,
2866
+ borderRadius: 2,
2867
+ }}
2868
+ >
2869
+ {barcodeBounds.angle.toFixed(1)}°
2870
+ </TextView>
2871
+ )}
2872
+ </View>
2873
+ )}
2874
+ </>
2875
+ )}
2876
+ {isDebugEnabled() && mrzBounds && nextStep !== 'COMPLETED' && (
2877
+ <>
2878
+ {mrzBounds.corners && mrzBounds.corners.length >= 2 ? (
2879
+ // Render using corner points for precise rotated border
2880
+ <>
2881
+ {/* Draw border lines between consecutive corners */}
2882
+ {mrzBounds.corners.map((corner, idx) => {
2883
+ const nextCorner =
2884
+ mrzBounds.corners![(idx + 1) % mrzBounds.corners!.length];
2885
+ const dx = nextCorner.x - corner.x;
2886
+ const dy = nextCorner.y - corner.y;
2887
+ const length = Math.sqrt(dx * dx + dy * dy);
2888
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2889
+
2890
+ return (
2891
+ <View
2892
+ key={idx}
2893
+ style={{
2894
+ position: 'absolute',
2895
+ left: corner.x,
2896
+ top: corner.y,
2897
+ width: length,
2898
+ height: 3,
2899
+ backgroundColor: '#9C27B0',
2900
+ transform: [{ rotate: `${angle}deg` }],
2901
+ transformOrigin: 'top left',
2902
+ }}
2903
+ />
2904
+ );
2905
+ })}
2906
+ {/* Angle indicator */}
2907
+ {!!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && (
2908
+ <TextView
2909
+ style={{
2910
+ position: 'absolute',
2911
+ left: mrzBounds.x,
2912
+ top: mrzBounds.y - 20,
2913
+ color: '#9C27B0',
2914
+ fontSize: 10,
2915
+ fontWeight: 'bold',
2916
+ backgroundColor: 'rgba(0,0,0,0.7)',
2917
+ paddingHorizontal: 4,
2918
+ paddingVertical: 2,
2919
+ borderRadius: 2,
2920
+ }}
2921
+ >
2922
+ {mrzBounds.angle.toFixed(1)}°
2923
+ </TextView>
2924
+ )}
2925
+ </>
2926
+ ) : (
2927
+ // Fallback to rotated rectangle if corners not available
2928
+ <View
2929
+ style={{
2930
+ position: 'absolute',
2931
+ left: mrzBounds.x + mrzBounds.width / 2,
2932
+ top: mrzBounds.y + mrzBounds.height / 2,
2933
+ width: mrzBounds.width,
2934
+ height: mrzBounds.height,
2935
+ marginLeft: -mrzBounds.width / 2,
2936
+ marginTop: -mrzBounds.height / 2,
2937
+ borderWidth: 3,
2938
+ borderColor: '#9C27B0',
2939
+ borderRadius: 8,
2940
+ backgroundColor: 'transparent',
2941
+ transform: [{ rotate: `${mrzBounds.angle || 0}deg` }],
2942
+ }}
2943
+ >
2944
+ {!!mrzBounds.angle && Math.abs(mrzBounds.angle) > 5 && (
2945
+ <TextView
2946
+ style={{
2947
+ position: 'absolute',
2948
+ top: -20,
2949
+ left: 0,
2950
+ color: '#9C27B0',
2951
+ fontSize: 10,
2952
+ fontWeight: 'bold',
2953
+ backgroundColor: 'rgba(0,0,0,0.7)',
2954
+ paddingHorizontal: 4,
2955
+ paddingVertical: 2,
2956
+ borderRadius: 2,
2957
+ }}
2958
+ >
2959
+ {mrzBounds.angle.toFixed(1)}°
2960
+ </TextView>
2961
+ )}
2962
+ </View>
2963
+ )}
2964
+ </>
2965
+ )}
2966
+ {isDebugEnabled() && signatureBounds && nextStep !== 'COMPLETED' && (
2967
+ <>
2968
+ {signatureBounds.corners &&
2969
+ signatureBounds.corners.length >= 2 ? (
2970
+ // Render using corner points for precise rotated border
2971
+ <>
2972
+ {/* Draw border lines between consecutive corners */}
2973
+ {signatureBounds.corners.map((corner, idx) => {
2974
+ const nextCorner =
2975
+ signatureBounds.corners![
2976
+ (idx + 1) % signatureBounds.corners!.length
2977
+ ];
2978
+ const dx = nextCorner.x - corner.x;
2979
+ const dy = nextCorner.y - corner.y;
2980
+ const length = Math.sqrt(dx * dx + dy * dy);
2981
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI);
2982
+
2983
+ return (
2984
+ <View
2985
+ key={idx}
2986
+ style={{
2987
+ position: 'absolute',
2988
+ left: corner.x,
2989
+ top: corner.y,
2990
+ width: length,
2991
+ height: 3,
2992
+ backgroundColor: '#00BCD4',
2993
+ transform: [{ rotate: `${angle}deg` }],
2994
+ transformOrigin: 'top left',
2995
+ }}
2996
+ />
2997
+ );
2998
+ })}
2999
+ {/* Angle indicator */}
3000
+ {!!signatureBounds.angle &&
3001
+ Math.abs(signatureBounds.angle) > 5 && (
3002
+ <TextView
3003
+ style={{
3004
+ position: 'absolute',
3005
+ left: signatureBounds.x,
3006
+ top: signatureBounds.y - 20,
3007
+ color: '#00BCD4',
3008
+ fontSize: 10,
3009
+ fontWeight: 'bold',
3010
+ backgroundColor: 'rgba(0,0,0,0.7)',
3011
+ paddingHorizontal: 4,
3012
+ paddingVertical: 2,
3013
+ borderRadius: 2,
3014
+ }}
3015
+ >
3016
+ {signatureBounds.angle.toFixed(1)}°
3017
+ </TextView>
3018
+ )}
3019
+ </>
3020
+ ) : (
3021
+ // Fallback to rotated rectangle if corners not available
3022
+ <View
3023
+ style={{
3024
+ position: 'absolute',
3025
+ left: signatureBounds.x + signatureBounds.width / 2,
3026
+ top: signatureBounds.y + signatureBounds.height / 2,
3027
+ width: signatureBounds.width,
3028
+ height: signatureBounds.height,
3029
+ marginLeft: -signatureBounds.width / 2,
3030
+ marginTop: -signatureBounds.height / 2,
3031
+ borderWidth: 3,
3032
+ borderColor: '#00BCD4',
3033
+ borderRadius: 8,
3034
+ backgroundColor: 'transparent',
3035
+ transform: [{ rotate: `${signatureBounds.angle || 0}deg` }],
3036
+ }}
3037
+ >
3038
+ {!!signatureBounds.angle &&
3039
+ Math.abs(signatureBounds.angle) > 5 && (
3040
+ <TextView
3041
+ style={{
3042
+ position: 'absolute',
3043
+ top: -20,
3044
+ left: 0,
3045
+ color: '#00BCD4',
3046
+ fontSize: 10,
3047
+ fontWeight: 'bold',
3048
+ backgroundColor: 'rgba(0,0,0,0.7)',
3049
+ paddingHorizontal: 4,
3050
+ paddingVertical: 2,
3051
+ borderRadius: 2,
3052
+ }}
3053
+ >
3054
+ {signatureBounds.angle.toFixed(1)}°
3055
+ </TextView>
3056
+ )}
3057
+ </View>
3058
+ )}
3059
+ </>
3060
+ )}
3061
+ <View style={[styles.topZone, { paddingTop: insets.top }]}>
3062
+ {nextStep !== 'COMPLETED' &&
3063
+ status !== 'SCANNED' &&
3064
+ detectedDocumentType !== 'UNKNOWN' && (
3065
+ <TextView style={styles.stepIndicator}>
3066
+ {nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3067
+ ? `${t('identityDocumentCamera.frontSide')} • ${t(
3068
+ 'identityDocumentCamera.stepProgress',
3069
+ {
3070
+ current: 1,
3071
+ total: onlyMRZScan
3072
+ ? detectedDocumentType === 'PASSPORT'
3073
+ ? 1
3074
+ : 2
3075
+ : detectedDocumentType === 'PASSPORT'
3076
+ ? 2
3077
+ : 3,
3078
+ }
3079
+ )}`
3080
+ : nextStep === 'SCAN_HOLOGRAM'
3081
+ ? `${t('identityDocumentCamera.hologramCheck')} • ${t(
3082
+ 'identityDocumentCamera.stepProgress',
3083
+ {
3084
+ current: 2,
3085
+ total: detectedDocumentType === 'PASSPORT' ? 2 : 3,
3086
+ }
3087
+ )}`
3088
+ : nextStep === 'SCAN_ID_BACK'
3089
+ ? `${t('identityDocumentCamera.backSide')} • ${t('identityDocumentCamera.stepProgress', { current: 3, total: 3 })}`
3090
+ : ''}
3091
+ </TextView>
3092
+ )}
3093
+
3094
+ <TextView
3095
+ style={[
3096
+ styles.topZoneText,
3097
+ // Priority order for coloring (later styles override earlier ones)
3098
+ // 1. Success (green) - scan completed
3099
+ status === 'SCANNED' && styles.topZoneTextSuccess,
3100
+ // 2. Error (red) - wrong side
3101
+ status === 'INCORRECT' && styles.topZoneTextError,
3102
+ // 3. Warning (yellow) - quality issues
3103
+ (isBrightnessLow || isFrameBlurry) && styles.topZoneTextWarning,
3104
+ // 4. Scanning (green) - all elements detected AND inside scan area
3105
+ status === 'SCANNING' &&
3106
+ allElementsDetected &&
3107
+ elementsOutsideScanArea.length === 0 &&
3108
+ !isBrightnessLow &&
3109
+ !isFrameBlurry &&
3110
+ styles.topZoneTextScanning,
3111
+ // 5. Default (white) - aligning (not all detected OR elements outside scan area)
3112
+ ]}
3113
+ >
1322
3114
  {status === 'SCANNED'
1323
3115
  ? completedStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1324
3116
  ? detectedDocumentType === 'PASSPORT'
@@ -1334,109 +3126,272 @@ const IdentityDocumentCamera = ({
1334
3126
  ? t('identityDocumentCamera.wrongSideFront')
1335
3127
  : nextStep === 'SCAN_ID_BACK'
1336
3128
  ? t('identityDocumentCamera.wrongSideBack')
1337
- : t('identityDocumentCamera.alignPhotoSide')
3129
+ : nextStep === 'SCAN_HOLOGRAM'
3130
+ ? t('identityDocumentCamera.wrongSideFront') // Hologram is on front, show same message as front scan
3131
+ : t('identityDocumentCamera.alignPhotoSide')
1338
3132
  : isBrightnessLow
1339
3133
  ? t('identityDocumentCamera.lowBrightness')
1340
3134
  : isFrameBlurry
1341
3135
  ? t('identityDocumentCamera.avoidBlur')
1342
- : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1343
- ? status === 'SCANNING'
1344
- ? currentFaceImage
1345
- ? detectedDocumentType === 'PASSPORT'
1346
- ? t('identityDocumentCamera.passportDetected')
1347
- : detectedDocumentType === 'ID_FRONT'
1348
- ? t('identityDocumentCamera.idCardFrontDetected')
3136
+ : status === 'SCANNING' &&
3137
+ allElementsDetected &&
3138
+ elementsOutsideScanArea.length === 0
3139
+ ? nextStep === 'SCAN_ID_BACK'
3140
+ ? t('identityDocumentCamera.idCardBackDetected')
3141
+ : detectedDocumentType === 'PASSPORT'
3142
+ ? t('identityDocumentCamera.passportDetected')
3143
+ : detectedDocumentType === 'ID_FRONT'
3144
+ ? t('identityDocumentCamera.idCardFrontDetected')
3145
+ : nextStep === 'SCAN_HOLOGRAM'
3146
+ ? t('identityDocumentCamera.alignHologram')
1349
3147
  : t('identityDocumentCamera.readingDocument')
1350
- : t('identityDocumentCamera.readingDocument')
1351
- : t('identityDocumentCamera.alignPhotoSide')
1352
- : nextStep === 'SCAN_HOLOGRAM'
1353
- ? t('identityDocumentCamera.alignHologram')
1354
- : nextStep === 'SCAN_ID_BACK'
1355
- ? status === 'SCANNING'
1356
- ? t('identityDocumentCamera.readingDocument')
1357
- : t('identityDocumentCamera.alignIDBackSide')
1358
- : nextStep === 'COMPLETED'
1359
- ? t('identityDocumentCamera.scanCompleted')
1360
- : ''}
3148
+ : elementsOutsideScanArea.length > 0
3149
+ ? t('identityDocumentCamera.centerDocument')
3150
+ : (status === 'SCANNING' || status === 'SEARCHING') &&
3151
+ !allElementsDetected
3152
+ ? nextStep === 'SCAN_ID_BACK'
3153
+ ? t('identityDocumentCamera.alignIDBack')
3154
+ : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3155
+ ? detectedDocumentType === 'PASSPORT'
3156
+ ? t('identityDocumentCamera.alignPassport')
3157
+ : detectedDocumentType === 'ID_FRONT'
3158
+ ? t('identityDocumentCamera.alignIDFront')
3159
+ : t('identityDocumentCamera.alignPhotoSide')
3160
+ : nextStep === 'SCAN_HOLOGRAM'
3161
+ ? t('identityDocumentCamera.alignHologram')
3162
+ : t('identityDocumentCamera.readingDocument')
3163
+ : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
3164
+ ? status === 'SCANNING'
3165
+ ? t('identityDocumentCamera.readingDocument')
3166
+ : t('identityDocumentCamera.alignPhotoSide')
3167
+ : nextStep === 'SCAN_HOLOGRAM'
3168
+ ? t('identityDocumentCamera.alignHologram')
3169
+ : nextStep === 'SCAN_ID_BACK'
3170
+ ? status === 'SCANNING'
3171
+ ? t(
3172
+ 'identityDocumentCamera.readingDocument'
3173
+ )
3174
+ : t(
3175
+ 'identityDocumentCamera.alignIDBackSide'
3176
+ )
3177
+ : nextStep === 'COMPLETED'
3178
+ ? t('identityDocumentCamera.scanCompleted')
3179
+ : ''}
1361
3180
  </TextView>
1362
3181
  </View>
1363
3182
  <View style={styles.leftZone} />
1364
3183
  <View style={styles.rightZone} />
1365
3184
  <View style={styles.bottomZone}>
1366
- {showDebugImages && currentFaceImage && (
1367
- <View style={styles.imageContainer}>
1368
- <Image
1369
- source={{
1370
- uri: `data:image/jpeg;base64,${currentFaceImage}`,
1371
- }}
1372
- style={styles.faceImage}
1373
- />
1374
- <TextView style={styles.imageContainerText}>Face</TextView>
1375
- </View>
1376
- )}
1377
-
1378
- {showDebugImages && currentSecondaryFaceImage && (
1379
- <View style={styles.imageContainer}>
1380
- <Image
1381
- source={{
1382
- uri: `data:image/jpeg;base64,${currentSecondaryFaceImage}`,
1383
- }}
1384
- style={styles.faceImage}
1385
- />
1386
- <TextView style={styles.imageContainerText}>
1387
- Secondary Face
1388
- </TextView>
1389
- </View>
1390
- )}
1391
-
1392
- {showDebugImages && _currentHologramMaskImage && (
1393
- <View style={styles.imageContainer}>
1394
- <Image
1395
- source={{
1396
- uri: `data:image/jpeg;base64,${_currentHologramMaskImage}`,
1397
- }}
1398
- style={styles.faceImage}
1399
- />
1400
- <TextView style={styles.imageContainerText}>
1401
- Hologram Mask
1402
- </TextView>
1403
- </View>
1404
- )}
1405
-
1406
- {showDebugImages && currentHologramImage && (
1407
- <View style={styles.imageContainer}>
1408
- <Image
1409
- source={{
1410
- uri: `data:image/jpeg;base64,${currentHologramImage}`,
1411
- }}
1412
- style={styles.faceImage}
1413
- />
1414
- <TextView style={styles.imageContainerText}>Hologram</TextView>
1415
- </View>
1416
- )}
1417
-
1418
- {showDebugImages && (
1419
- <View style={styles.debugInfoContainer}>
1420
- <TextView style={styles.debugInfoText}>
1421
- Step: {nextStep}
1422
- </TextView>
1423
- <TextView style={styles.debugInfoText}>
1424
- Status: {status}
1425
- </TextView>
1426
- <TextView style={styles.debugInfoText}>
1427
- {`Face: ${currentFaceImage ? '✓' : '✗'} ${!faceDetectionEnabled ? '(DISABLED)' : ''
1428
- }`}
1429
- </TextView>
1430
- <TextView style={styles.debugInfoText}>
1431
- {`Hologram: ${currentHologramImage ? '✓' : '✗'} (${hologramDetectionCurrentRetryCount.value
1432
- }/${HOLOGRAM_DETECTION_RETRY_COUNT})`}
1433
- </TextView>
1434
- <TextView style={styles.debugInfoText}>
1435
- {`2nd Face: ${currentSecondaryFaceImage ? '✓' : '✗'} (${secondaryFaceDetectionCurrentRetryCount.value
1436
- }/${SECOND_FACE_DETECTION_RETRY_COUNT})`}
1437
- </TextView>
1438
- </View>
1439
- )}
3185
+ <View style={styles.debugImagesRow}>
3186
+ {isDebugEnabled() && (
3187
+ <View style={styles.imageContainer}>
3188
+ {currentFaceImage ? (
3189
+ <Image
3190
+ source={{
3191
+ uri: `data:image/jpeg;base64,${currentFaceImage}`,
3192
+ }}
3193
+ style={styles.faceImage}
3194
+ />
3195
+ ) : (
3196
+ <View
3197
+ style={[
3198
+ styles.faceImage,
3199
+ { backgroundColor: '#333', justifyContent: 'center' },
3200
+ ]}
3201
+ >
3202
+ <TextView
3203
+ style={{
3204
+ color: '#666',
3205
+ fontSize: 10,
3206
+ textAlign: 'center',
3207
+ }}
3208
+ >
3209
+ Waiting...
3210
+ </TextView>
3211
+ </View>
3212
+ )}
3213
+ <TextView
3214
+ style={[
3215
+ styles.imageContainerText,
3216
+ currentFaceImage && { color: '#4CAF50' },
3217
+ ]}
3218
+ >
3219
+ {`${currentFaceImage ? '✓ ' : ''}Face`}
3220
+ </TextView>
3221
+ </View>
3222
+ )}
3223
+ {isDebugEnabled() && (
3224
+ <View style={styles.imageContainer}>
3225
+ {currentSecondaryFaceImage ? (
3226
+ <Image
3227
+ source={{
3228
+ uri: `data:image/jpeg;base64,${currentSecondaryFaceImage}`,
3229
+ }}
3230
+ style={styles.faceImage}
3231
+ />
3232
+ ) : (
3233
+ <View
3234
+ style={[
3235
+ styles.faceImage,
3236
+ { backgroundColor: '#333', justifyContent: 'center' },
3237
+ ]}
3238
+ >
3239
+ <TextView
3240
+ style={{
3241
+ color: '#666',
3242
+ fontSize: 10,
3243
+ textAlign: 'center',
3244
+ }}
3245
+ >
3246
+ Waiting...
3247
+ </TextView>
3248
+ </View>
3249
+ )}
3250
+ <TextView
3251
+ style={[
3252
+ styles.imageContainerText,
3253
+ currentSecondaryFaceImage && { color: '#4CAF50' },
3254
+ ]}
3255
+ >
3256
+ {`${currentSecondaryFaceImage ? '✓ ' : ''}2nd Face`}
3257
+ </TextView>
3258
+ </View>
3259
+ )}
3260
+ {isDebugEnabled() && (
3261
+ <View style={styles.imageContainer}>
3262
+ {_currentHologramMaskImage ? (
3263
+ <Image
3264
+ source={{
3265
+ uri: `data:image/jpeg;base64,${_currentHologramMaskImage}`,
3266
+ }}
3267
+ style={styles.faceImage}
3268
+ />
3269
+ ) : (
3270
+ <View
3271
+ style={[
3272
+ styles.faceImage,
3273
+ { backgroundColor: '#333', justifyContent: 'center' },
3274
+ ]}
3275
+ >
3276
+ <TextView
3277
+ style={{
3278
+ color: '#666',
3279
+ fontSize: 10,
3280
+ textAlign: 'center',
3281
+ }}
3282
+ >
3283
+ Waiting...
3284
+ </TextView>
3285
+ </View>
3286
+ )}
3287
+ <TextView
3288
+ style={[
3289
+ styles.imageContainerText,
3290
+ _currentHologramMaskImage && { color: '#4CAF50' },
3291
+ ]}
3292
+ >
3293
+ {`${_currentHologramMaskImage ? '✓ ' : ''}Mask`}
3294
+ </TextView>
3295
+ </View>
3296
+ )}
3297
+ {isDebugEnabled() && (
3298
+ <View style={styles.imageContainer}>
3299
+ {currentHologramImage ? (
3300
+ <Image
3301
+ source={{
3302
+ uri: `data:image/jpeg;base64,${currentHologramImage}`,
3303
+ }}
3304
+ style={styles.faceImage}
3305
+ />
3306
+ ) : latestHologramFaceImage && hologramImageCount > 0 ? (
3307
+ <View style={{ position: 'relative' }}>
3308
+ <Image
3309
+ source={{
3310
+ uri: `data:image/jpeg;base64,${latestHologramFaceImage}`,
3311
+ }}
3312
+ style={[styles.faceImage, { opacity: 0.7 }]}
3313
+ />
3314
+ <View
3315
+ style={{
3316
+ position: 'absolute',
3317
+ bottom: 0,
3318
+ left: 0,
3319
+ right: 0,
3320
+ backgroundColor: 'rgba(0,0,0,0.7)',
3321
+ padding: 2,
3322
+ }}
3323
+ >
3324
+ <TextView
3325
+ style={{
3326
+ color: '#FFA500',
3327
+ fontSize: 8,
3328
+ textAlign: 'center',
3329
+ fontWeight: 'bold',
3330
+ }}
3331
+ >
3332
+ {hologramImageCount}/{HOLOGRAM_IMAGE_COUNT}
3333
+ </TextView>
3334
+ </View>
3335
+ </View>
3336
+ ) : (
3337
+ <View
3338
+ style={[
3339
+ styles.faceImage,
3340
+ { backgroundColor: '#333', justifyContent: 'center' },
3341
+ ]}
3342
+ >
3343
+ <TextView
3344
+ style={{
3345
+ color: '#666',
3346
+ fontSize: 10,
3347
+ textAlign: 'center',
3348
+ }}
3349
+ >
3350
+ Waiting...
3351
+ </TextView>
3352
+ </View>
3353
+ )}
3354
+ <TextView
3355
+ style={[
3356
+ styles.imageContainerText,
3357
+ currentHologramImage && { color: '#4CAF50' },
3358
+ latestHologramFaceImage &&
3359
+ !currentHologramImage && { color: '#FFA500' },
3360
+ ]}
3361
+ >
3362
+ {`${currentHologramImage ? '✓ ' : latestHologramFaceImage ? '⏳ ' : ''}Hologram`}
3363
+ </TextView>
3364
+ </View>
3365
+ )}
3366
+ {isDebugEnabled() && (
3367
+ <View style={styles.debugInfoContainer}>
3368
+ <TextView style={styles.debugInfoText}>
3369
+ {`Step: ${nextStep}`}
3370
+ </TextView>
3371
+ <TextView style={styles.debugInfoText}>
3372
+ {`Status: ${status}`}
3373
+ </TextView>
3374
+ <TextView style={styles.debugInfoText}>
3375
+ {`Face: ${currentFaceImage ? '✓ CAPTURED' : '✗ WAITING'} ${!faceDetectionEnabled ? '(DISABLED)' : ''}`}
3376
+ </TextView>
3377
+ <TextView style={styles.debugInfoText}>
3378
+ {`Hologram: ${currentHologramImage ? '✓ CAPTURED' : `${hologramImageCount}/${HOLOGRAM_IMAGE_COUNT} imgs`} (retry ${hologramDetectionCurrentRetryCount.current}/${HOLOGRAM_DETECTION_RETRY_COUNT})`}
3379
+ </TextView>
3380
+ <TextView style={styles.debugInfoText}>
3381
+ {`2nd Face: ${currentSecondaryFaceImage ? '✓ CAPTURED' : '✗ WAITING'} (retry ${secondaryFaceDetectionCurrentRetryCount.current}/${SECOND_FACE_DETECTION_RETRY_COUNT})`}
3382
+ </TextView>
3383
+ <TextView style={styles.debugInfoText}>
3384
+ {`Flash: ${isTorchOn ? '🔦 ON' : '🔦 OFF'}`}
3385
+ </TextView>
3386
+ <TextView style={styles.debugInfoText}>
3387
+ {`Brightness: ${isBrightnessLow ? '⚠️ LOW' : '✓ OK'}`}
3388
+ </TextView>
3389
+ <TextView style={styles.debugInfoText}>
3390
+ {`Blur: ${isFrameBlurry ? '⚠️ BLURRY' : '✓ OK'}`}
3391
+ </TextView>
3392
+ </View>
3393
+ )}
3394
+ </View>
1440
3395
  </View>
1441
3396
  <View
1442
3397
  style={[
@@ -1444,19 +3399,18 @@ const IdentityDocumentCamera = ({
1444
3399
  {
1445
3400
  borderColor:
1446
3401
  status === 'SCANNED' || nextStep === 'COMPLETED'
1447
- ? '#4CAF50' // Green - success
3402
+ ? '#4CAF50'
1448
3403
  : status === 'INCORRECT'
1449
- ? '#f44336' // Red - error
3404
+ ? '#f44336'
1450
3405
  : status === 'SCANNING'
1451
- ? '#2196F3' // Blue - processing
3406
+ ? '#2196F3'
1452
3407
  : isBrightnessLow || isFrameBlurry
1453
- ? '#FFC107' // Yellow - warning
3408
+ ? '#FFC107'
1454
3409
  : 'white',
1455
3410
  borderWidth: status === 'SCANNING' ? 3 : 2,
1456
3411
  },
1457
3412
  ]}
1458
3413
  >
1459
- {/* Only show ONE animation at a time - priority order: completed/scanned > brightness > hologram > scanning */}
1460
3414
  {nextStep === 'COMPLETED' || status === 'SCANNED' ? (
1461
3415
  <LottieView
1462
3416
  source={require('../../Shared/Animations/success.json')}
@@ -1487,11 +3441,84 @@ const IdentityDocumentCamera = ({
1487
3441
  />
1488
3442
  ) : null}
1489
3443
  </View>
1490
- <TouchableOpacity
1491
- onPress={handleFocus}
1492
- style={styles.focusArea}
1493
- activeOpacity={1}
1494
- />
3444
+ {isDebugEnabled() && (
3445
+ <View
3446
+ style={{
3447
+ position: 'absolute',
3448
+ top: 10,
3449
+ right: 10,
3450
+ backgroundColor: 'rgba(0, 0, 0, 0.85)',
3451
+ padding: 10,
3452
+ borderRadius: 8,
3453
+ borderWidth: 1,
3454
+ borderColor: '#FF6B6B',
3455
+ maxWidth: 200,
3456
+ }}
3457
+ >
3458
+ <TextView
3459
+ style={{
3460
+ color: '#FF6B6B',
3461
+ fontSize: 11,
3462
+ fontWeight: 'bold',
3463
+ marginBottom: 6,
3464
+ }}
3465
+ >
3466
+ 🐛 DEBUG MODE
3467
+ </TextView>
3468
+ <TextView
3469
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3470
+ >
3471
+ {`Step: ${nextStep}`}
3472
+ </TextView>
3473
+ <TextView
3474
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3475
+ >
3476
+ {`Status: ${status}`}
3477
+ </TextView>
3478
+ <TextView
3479
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3480
+ >
3481
+ {`Doc Type: ${detectedDocumentType}`}
3482
+ </TextView>
3483
+ <TextView
3484
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3485
+ >
3486
+ {`Face: ${currentFaceImage ? '✓' : '✗'}`}
3487
+ </TextView>
3488
+ {!onlyMRZScan && (
3489
+ <>
3490
+ <TextView
3491
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3492
+ >
3493
+ {`2nd Face: ${currentSecondaryFaceImage ? '✓' : '✗'}`}
3494
+ </TextView>
3495
+ <TextView
3496
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3497
+ >
3498
+ {`Hologram: ${hologramImageCount}/${HOLOGRAM_IMAGE_COUNT}`}
3499
+ </TextView>
3500
+ </>
3501
+ )}
3502
+ <TextView
3503
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3504
+ >
3505
+ {`Brightness: ${isBrightnessLow ? '⚠️ LOW' : '✓'}`}
3506
+ </TextView>
3507
+ <TextView
3508
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3509
+ >
3510
+ {`Blur: ${isFrameBlurry ? '⚠️' : '✓'}`}
3511
+ </TextView>
3512
+ <TextView
3513
+ style={{ color: '#88D8B0', fontSize: 9, marginBottom: 2 }}
3514
+ >
3515
+ {`Flash: ${isTorchOn ? '🔦' : '○'}`}
3516
+ </TextView>
3517
+ <TextView style={{ color: '#88D8B0', fontSize: 9 }}>
3518
+ {`Face Detection: ${faceDetectionEnabled ? '✓' : '✗'}`}
3519
+ </TextView>
3520
+ </View>
3521
+ )}
1495
3522
  </>
1496
3523
  )}
1497
3524
  </View>
@@ -1528,14 +3555,6 @@ const styles = StyleSheet.create({
1528
3555
  alignItems: 'center',
1529
3556
  paddingHorizontal: 5,
1530
3557
  },
1531
- focusArea: {
1532
- position: 'absolute',
1533
- top: 0,
1534
- left: 0,
1535
- width: '100%',
1536
- height: '100%',
1537
- zIndex: 2,
1538
- },
1539
3558
  animation: {
1540
3559
  width: '100%',
1541
3560
  height: '100%',
@@ -1566,16 +3585,16 @@ const styles = StyleSheet.create({
1566
3585
  padding: 20,
1567
3586
  },
1568
3587
  topZoneTextScanning: {
1569
- color: '#2196F3', // Blue when scanning
3588
+ color: '#2196F3',
1570
3589
  },
1571
3590
  topZoneTextSuccess: {
1572
- color: '#4CAF50', // Green for success
3591
+ color: '#4CAF50',
1573
3592
  },
1574
3593
  topZoneTextWarning: {
1575
- color: '#FFC107', // Yellow for warnings
3594
+ color: '#FFC107',
1576
3595
  },
1577
3596
  topZoneTextError: {
1578
- color: '#f44336', // Red for errors
3597
+ color: '#f44336',
1579
3598
  },
1580
3599
  leftZone: {
1581
3600
  position: 'absolute',
@@ -1601,12 +3620,24 @@ const styles = StyleSheet.create({
1601
3620
  bottom: 0,
1602
3621
  backgroundColor: '#00000099',
1603
3622
  padding: 20,
3623
+ display: 'flex',
3624
+ flexDirection: 'column',
3625
+ gap: 10,
3626
+ justifyContent: 'flex-start',
3627
+ },
3628
+ debugImagesRow: {
1604
3629
  display: 'flex',
1605
3630
  flexDirection: 'row',
1606
3631
  gap: 10,
1607
3632
  justifyContent: 'flex-start',
1608
3633
  flexWrap: 'wrap',
1609
3634
  },
3635
+ cardDetectionRow: {
3636
+ display: 'flex',
3637
+ flexDirection: 'row',
3638
+ justifyContent: 'center',
3639
+ marginTop: 5,
3640
+ },
1610
3641
  imageContainer: {
1611
3642
  display: 'flex',
1612
3643
  flexDirection: 'column',
@@ -1626,6 +3657,18 @@ const styles = StyleSheet.create({
1626
3657
  borderWidth: 1,
1627
3658
  borderColor: 'white',
1628
3659
  },
3660
+ cardDetectionImage: {
3661
+ width: 160,
3662
+ height: 120,
3663
+ borderRadius: 8,
3664
+ borderWidth: 2,
3665
+ borderColor: '#FF9800',
3666
+ },
3667
+ cardDetectionContainer: {
3668
+ display: 'flex',
3669
+ flexDirection: 'column',
3670
+ alignItems: 'center',
3671
+ },
1629
3672
  debugInfoContainer: {
1630
3673
  flex: 1,
1631
3674
  paddingLeft: 10,