@trustchex/react-native-sdk 1.354.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 (256) 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 -13
  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 +2149 -778
  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/http-client.js +1 -0
  45. package/lib/module/Shared/Libs/mrz.utils.js +1 -176
  46. package/lib/module/Shared/Libs/native-device-info.utils.js +12 -6
  47. package/lib/module/Shared/Libs/tts.utils.js +40 -6
  48. package/lib/module/Shared/Services/AnalyticsService.js +9 -8
  49. package/lib/module/Shared/Types/mrzFields.js +1 -0
  50. package/lib/module/Translation/Resources/en.js +87 -88
  51. package/lib/module/Translation/Resources/tr.js +84 -85
  52. package/lib/module/Trustchex.js +10 -19
  53. package/lib/module/index.js +1 -0
  54. package/lib/module/version.js +1 -1
  55. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  56. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  57. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  58. package/lib/typescript/src/Screens/Static/OTPVerificationScreen.d.ts.map +1 -1
  59. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  60. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  61. package/lib/typescript/src/Screens/Static/VerificationSessionCheckScreen.d.ts.map +1 -1
  62. package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts +2 -2
  64. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  65. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts +18 -4
  66. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
  67. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts +3 -4
  68. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  69. package/lib/typescript/src/Shared/Components/QrCodeScannerCamera.d.ts +2 -1
  70. package/lib/typescript/src/Shared/Components/QrCodeScannerCamera.d.ts.map +1 -1
  71. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +124 -0
  72. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -0
  73. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.helpers.d.ts +11 -0
  74. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.helpers.d.ts.map +1 -0
  75. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.utils.d.ts +2 -39
  76. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.utils.d.ts.map +1 -1
  77. package/lib/typescript/src/Shared/Libs/analytics.utils.d.ts.map +1 -1
  78. package/lib/typescript/src/Shared/Libs/debug.utils.d.ts +42 -0
  79. package/lib/typescript/src/Shared/Libs/debug.utils.d.ts.map +1 -0
  80. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  81. package/lib/typescript/src/Shared/Libs/demo.utils.d.ts.map +1 -1
  82. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  83. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +0 -4
  84. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  85. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  86. package/lib/typescript/src/Shared/Libs/tts.utils.d.ts +4 -3
  87. package/lib/typescript/src/Shared/Libs/tts.utils.d.ts.map +1 -1
  88. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  89. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +2 -2
  90. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
  91. package/lib/typescript/src/Shared/Types/mrzFields.d.ts +11 -0
  92. package/lib/typescript/src/Shared/Types/mrzFields.d.ts.map +1 -0
  93. package/lib/typescript/src/Translation/Resources/en.d.ts +4 -5
  94. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  95. package/lib/typescript/src/Translation/Resources/tr.d.ts +4 -5
  96. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  97. package/lib/typescript/src/Trustchex.d.ts +2 -0
  98. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  99. package/lib/typescript/src/index.d.ts +1 -0
  100. package/lib/typescript/src/index.d.ts.map +1 -1
  101. package/lib/typescript/src/version.d.ts +1 -1
  102. package/package.json +13 -36
  103. package/src/Screens/Dynamic/ContractAcceptanceScreen.tsx +1 -1
  104. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +7 -5
  105. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +2 -3
  106. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +498 -216
  107. package/src/Screens/Static/OTPVerificationScreen.tsx +37 -31
  108. package/src/Screens/Static/QrCodeScanningScreen.tsx +8 -1
  109. package/src/Screens/Static/ResultScreen.tsx +136 -104
  110. package/src/Screens/Static/VerificationSessionCheckScreen.tsx +46 -13
  111. package/src/Shared/Components/DebugNavigationPanel.tsx +290 -34
  112. package/src/Shared/Components/EIDScanner.tsx +94 -16
  113. package/src/Shared/Components/FaceCamera.tsx +236 -203
  114. package/src/Shared/Components/IdentityDocumentCamera.tsx +3070 -1036
  115. package/src/Shared/Components/QrCodeScannerCamera.tsx +133 -127
  116. package/src/Shared/Components/TrustchexCamera.tsx +289 -0
  117. package/src/Shared/Config/camera-enhancement.config.ts +2 -2
  118. package/src/Shared/EIDReader/tlv/tlv.helpers.ts +96 -0
  119. package/src/Shared/EIDReader/tlv/tlv.utils.ts +2 -125
  120. package/src/Shared/EIDReader/tlv/tlvInputStream.ts +4 -4
  121. package/src/Shared/EIDReader/tlv/tlvOutputState.ts +4 -4
  122. package/src/Shared/EIDReader/tlv/tlvOutputStream.ts +4 -4
  123. package/src/Shared/Libs/analytics.utils.ts +48 -20
  124. package/src/Shared/Libs/debounce.utils.ts +1 -1
  125. package/src/Shared/Libs/debug.utils.ts +149 -0
  126. package/src/Shared/Libs/deeplink.utils.ts +7 -5
  127. package/src/Shared/Libs/demo.utils.ts +4 -0
  128. package/src/Shared/Libs/http-client.ts +13 -8
  129. package/src/Shared/Libs/mrz.utils.ts +1 -164
  130. package/src/Shared/Libs/native-device-info.utils.ts +12 -6
  131. package/src/Shared/Libs/tts.utils.ts +48 -6
  132. package/src/Shared/Services/AnalyticsService.ts +69 -24
  133. package/src/Shared/Types/identificationInfo.ts +2 -2
  134. package/src/Shared/Types/mrzFields.ts +29 -0
  135. package/src/Translation/Resources/en.ts +90 -100
  136. package/src/Translation/Resources/tr.ts +89 -97
  137. package/src/Translation/index.ts +1 -1
  138. package/src/Trustchex.tsx +22 -17
  139. package/src/index.tsx +14 -0
  140. package/src/version.ts +1 -1
  141. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/barcodescanner/BarcodeScannerFrameProcessorPlugin.kt +0 -301
  142. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/BitmapUtils.kt +0 -205
  143. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/CropperPlugin.kt +0 -72
  144. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/cropper/FrameMetadata.kt +0 -4
  145. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/facedetector/FaceDetectorFrameProcessorPlugin.kt +0 -303
  146. package/android/src/main/java/com/trustchex/reactnativesdk/visioncameraplugins/textrecognition/TextRecognitionFrameProcessorPlugin.kt +0 -115
  147. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin-Bridging-Header.h +0 -9
  148. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin.mm +0 -22
  149. package/ios/VisionCameraPlugins/BarcodeScanner/BarcodeScannerFrameProcessorPlugin.swift +0 -188
  150. package/ios/VisionCameraPlugins/Cropper/Cropper-Bridging-Header.h +0 -13
  151. package/ios/VisionCameraPlugins/Cropper/Cropper.h +0 -20
  152. package/ios/VisionCameraPlugins/Cropper/Cropper.mm +0 -22
  153. package/ios/VisionCameraPlugins/Cropper/Cropper.swift +0 -145
  154. package/ios/VisionCameraPlugins/Cropper/CropperUtils.swift +0 -49
  155. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin-Bridging-Header.h +0 -4
  156. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin.mm +0 -22
  157. package/ios/VisionCameraPlugins/FaceDetector/FaceDetectorFrameProcessorPlugin.swift +0 -320
  158. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin-Bridging-Header.h +0 -4
  159. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin.mm +0 -27
  160. package/ios/VisionCameraPlugins/TextRecognition/TextRecognitionFrameProcessorPlugin.swift +0 -144
  161. package/lib/module/Shared/Libs/camera.utils.js +0 -308
  162. package/lib/module/Shared/Libs/frame-enhancement.utils.js +0 -133
  163. package/lib/module/Shared/Libs/opencv.utils.js +0 -21
  164. package/lib/module/Shared/Libs/worklet.utils.js +0 -53
  165. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.js +0 -46
  166. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.js +0 -35
  167. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/index.js +0 -19
  168. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.js +0 -26
  169. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/types.js +0 -3
  170. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.js +0 -197
  171. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.js +0 -101
  172. package/lib/module/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.js +0 -60
  173. package/lib/module/Shared/VisionCameraPlugins/Cropper/index.js +0 -47
  174. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/Camera.js +0 -42
  175. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/detectFaces.js +0 -35
  176. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/index.js +0 -4
  177. package/lib/module/Shared/VisionCameraPlugins/FaceDetector/types.js +0 -3
  178. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/Camera.js +0 -56
  179. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.js +0 -20
  180. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.js +0 -9
  181. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/index.js +0 -6
  182. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/scanText.js +0 -20
  183. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/translateText.js +0 -19
  184. package/lib/module/Shared/VisionCameraPlugins/TextRecognition/types.js +0 -3
  185. package/lib/typescript/src/Shared/Libs/camera.utils.d.ts +0 -87
  186. package/lib/typescript/src/Shared/Libs/camera.utils.d.ts.map +0 -1
  187. package/lib/typescript/src/Shared/Libs/frame-enhancement.utils.d.ts +0 -25
  188. package/lib/typescript/src/Shared/Libs/frame-enhancement.utils.d.ts.map +0 -1
  189. package/lib/typescript/src/Shared/Libs/opencv.utils.d.ts +0 -3
  190. package/lib/typescript/src/Shared/Libs/opencv.utils.d.ts.map +0 -1
  191. package/lib/typescript/src/Shared/Libs/worklet.utils.d.ts +0 -3
  192. package/lib/typescript/src/Shared/Libs/worklet.utils.d.ts.map +0 -1
  193. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.d.ts +0 -13
  194. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.d.ts.map +0 -1
  195. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.d.ts +0 -6
  196. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.d.ts.map +0 -1
  197. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/index.d.ts +0 -12
  198. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/index.d.ts.map +0 -1
  199. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.d.ts +0 -3
  200. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.d.ts.map +0 -1
  201. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/types.d.ts +0 -52
  202. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/types.d.ts.map +0 -1
  203. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.d.ts +0 -62
  204. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.d.ts.map +0 -1
  205. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.d.ts +0 -34
  206. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.d.ts.map +0 -1
  207. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.d.ts +0 -32
  208. package/lib/typescript/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.d.ts.map +0 -1
  209. package/lib/typescript/src/Shared/VisionCameraPlugins/Cropper/index.d.ts +0 -23
  210. package/lib/typescript/src/Shared/VisionCameraPlugins/Cropper/index.d.ts.map +0 -1
  211. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/Camera.d.ts +0 -9
  212. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/Camera.d.ts.map +0 -1
  213. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.d.ts +0 -3
  214. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.d.ts.map +0 -1
  215. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/index.d.ts +0 -3
  216. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/index.d.ts.map +0 -1
  217. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/types.d.ts +0 -79
  218. package/lib/typescript/src/Shared/VisionCameraPlugins/FaceDetector/types.d.ts.map +0 -1
  219. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/Camera.d.ts +0 -6
  220. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/Camera.d.ts.map +0 -1
  221. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.d.ts +0 -3
  222. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.d.ts.map +0 -1
  223. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.d.ts +0 -3
  224. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.d.ts.map +0 -1
  225. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/index.d.ts +0 -5
  226. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/index.d.ts.map +0 -1
  227. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/scanText.d.ts +0 -3
  228. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/scanText.d.ts.map +0 -1
  229. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/translateText.d.ts +0 -3
  230. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/translateText.d.ts.map +0 -1
  231. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/types.d.ts +0 -67
  232. package/lib/typescript/src/Shared/VisionCameraPlugins/TextRecognition/types.d.ts.map +0 -1
  233. package/src/Shared/Libs/camera.utils.ts +0 -345
  234. package/src/Shared/Libs/frame-enhancement.utils.ts +0 -217
  235. package/src/Shared/Libs/opencv.utils.ts +0 -40
  236. package/src/Shared/Libs/worklet.utils.ts +0 -58
  237. package/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useBarcodeScanner.ts +0 -79
  238. package/src/Shared/VisionCameraPlugins/BarcodeScanner/hooks/useCameraPermissions.ts +0 -46
  239. package/src/Shared/VisionCameraPlugins/BarcodeScanner/index.ts +0 -60
  240. package/src/Shared/VisionCameraPlugins/BarcodeScanner/scanCodes.ts +0 -32
  241. package/src/Shared/VisionCameraPlugins/BarcodeScanner/types.ts +0 -82
  242. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/convert.ts +0 -195
  243. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/geometry.ts +0 -135
  244. package/src/Shared/VisionCameraPlugins/BarcodeScanner/utils/highlights.ts +0 -84
  245. package/src/Shared/VisionCameraPlugins/Cropper/index.ts +0 -78
  246. package/src/Shared/VisionCameraPlugins/FaceDetector/Camera.tsx +0 -63
  247. package/src/Shared/VisionCameraPlugins/FaceDetector/detectFaces.ts +0 -44
  248. package/src/Shared/VisionCameraPlugins/FaceDetector/index.ts +0 -3
  249. package/src/Shared/VisionCameraPlugins/FaceDetector/types.ts +0 -99
  250. package/src/Shared/VisionCameraPlugins/TextRecognition/Camera.tsx +0 -76
  251. package/src/Shared/VisionCameraPlugins/TextRecognition/PhotoRecognizer.ts +0 -18
  252. package/src/Shared/VisionCameraPlugins/TextRecognition/RemoveLanguageModel.ts +0 -7
  253. package/src/Shared/VisionCameraPlugins/TextRecognition/index.ts +0 -7
  254. package/src/Shared/VisionCameraPlugins/TextRecognition/scanText.ts +0 -27
  255. package/src/Shared/VisionCameraPlugins/TextRecognition/translateText.ts +0 -21
  256. 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
804
+ if (
805
+ frameDimensions?.width !== frameWidth ||
806
+ frameDimensions.height !== frameHeight
807
+ ) {
808
+ setFrameDimensions({ width: frameWidth, height: frameHeight });
809
+ }
810
+
742
811
  if (
743
- device?.hasTorch &&
744
- isTorchOn &&
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,35 +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) {
759
- console.log('[WRONG_SIDE] Back side expected but faces detected:', faces.length);
826
+ if (elementsOutside) {
827
+ return;
828
+ }
829
+
830
+ if (nextStep === 'SCAN_ID_BACK' && cardSizedFaces.length > 0) {
760
831
  setStatus('INCORRECT');
761
832
  return;
762
833
  }
763
834
 
764
- if (!text || text.length < 10 || !image) {
765
- // Log when searching to help debug
766
- if (nextStep === 'SCAN_ID_BACK') {
767
- console.log('[SCAN_ID_BACK] Searching... faces:', faces.length, 'text length:', text?.length || 0);
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
+ });
768
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) {
769
969
  setStatus('SEARCHING');
770
970
  return;
771
971
  }
772
972
 
773
- const { mrzText, parsedResult: parsedMRZData } =
774
- mrzUtils.getMRZData(text);
775
- const croppedFaces = await getFaceImages(
776
- faces,
777
- image,
778
- frameWidth,
779
- frameHeight
780
- );
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
+
781
1159
  const documentType = detectDocumentType(
782
- faces,
1160
+ cardSizedFaces,
783
1161
  text,
784
- parsedMRZData?.fields
1162
+ parsedMRZData?.fields,
1163
+ frameWidth,
1164
+ mrzText
785
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
+
786
1270
  const scannedData: DocumentScannedData = {
787
1271
  image,
788
1272
  documentType,
@@ -790,424 +1274,1281 @@ const IdentityDocumentCamera = ({
790
1274
  mrzFields: parsedMRZData?.fields,
791
1275
  };
792
1276
 
793
- scannedData.faceImage = croppedFaces[0];
794
- setCurrentFaceImage(croppedFaces[0]);
795
-
796
- // Track detected document type for UI feedback
797
- if (documentType !== 'UNKNOWN') {
798
- setDetectedDocumentType(documentType);
799
- }
800
-
801
- // Detect wrong side based on document type or face presence (works for both normal and eID scan)
802
- // For ID_BACK step: if faces are detected, it's likely the front side (wrong)
803
- // For FRONT step: if ID_BACK is detected, it's the wrong side
804
1277
  const isWrongSide =
805
- (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK') ||
806
- (nextStep === 'SCAN_ID_BACK' && (documentType === 'ID_FRONT' || documentType === 'PASSPORT' || croppedFaces.length > 0));
1278
+ nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' && documentType === 'ID_BACK';
807
1279
 
808
1280
  if (isWrongSide) {
809
1281
  setStatus('INCORRECT');
810
1282
  return;
811
1283
  }
812
1284
 
1285
+ // Always use locked face if available
1286
+ if (faceImageToUse) {
1287
+ scannedData.faceImage = faceImageToUse;
1288
+ }
1289
+
813
1290
  if (!onlyMRZScan) {
814
- if (croppedFaces.length > 0 && croppedFaces[0]) {
815
- if (currentFaceImage) {
816
- 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;
817
1315
  } else {
818
- scannedData.faceImage = croppedFaces[0];
819
- setCurrentFaceImage(croppedFaces[0]);
1316
+ primaryFaceOnly = faceImageToUse;
820
1317
  }
821
1318
 
822
- if (currentHologramImage) {
823
- scannedData.hologramImage = currentHologramImage;
824
- } else if (faceImages.length <= HOLOGRAM_IMAGE_COUNT) {
825
- if (device?.hasTorch) {
826
- 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
+ }
827
1396
  }
828
- faceImages.push(croppedFaces[0]);
829
1397
  } else {
830
- const [hologramMask, hologram] = detectHologram(faceImages);
831
- if (
832
- !!currentFaceImage &&
833
- areImagesSimilar(currentFaceImage, hologram, 25000)
834
- ) {
835
- setCurrentHologramMaskImage(hologramMask);
836
- scannedData.hologramImage = hologram;
837
- 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
+ );
838
1405
  }
839
- faceImages = [];
840
- hologramDetectionCurrentRetryCount.value++;
841
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
+ }
842
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
843
1427
  if (currentSecondaryFaceImage) {
844
1428
  scannedData.secondaryFaceImage = currentSecondaryFaceImage;
845
1429
  } else if (
846
1430
  !!scannedData.faceImage &&
847
1431
  croppedFaces.length > 1 &&
848
1432
  !!croppedFaces[1] &&
849
- areImagesSimilar(scannedData.faceImage, croppedFaces[1])
1433
+ facePositionValid
850
1434
  ) {
851
- scannedData.secondaryFaceImage = croppedFaces[1];
852
- 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
+ }
1514
+ } else {
1515
+ secondaryFaceDetectionCurrentRetryCount.current++;
1516
+ if (!facePositionValid && croppedFaces.length > 1) {
1517
+ if (isDebugEnabled()) {
1518
+ console.log(
1519
+ '[SecondaryFace] ✗ Rejected - document plane changed'
1520
+ );
1521
+ }
1522
+ }
1523
+ }
1524
+ } else if (currentSecondaryFaceImage) {
1525
+ // Already have secondary face from earlier - just use it
1526
+ scannedData.secondaryFaceImage = currentSecondaryFaceImage;
1527
+ }
1528
+ }
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');
853
1611
  } else {
854
- secondaryFaceDetectionCurrentRetryCount.value++;
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');
855
1618
  }
1619
+ setTimeout(() => {
1620
+ onIdentityDocumentScanned(scannedData);
1621
+ }, 1000);
1622
+ return;
856
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;
857
1628
  }
858
1629
 
859
1630
  if (documentType === 'ID_FRONT') {
860
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');
861
1697
  setStatus('SCANNED');
1698
+ setIsTorchOn(false);
862
1699
  if (onlyMRZScan) {
863
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_ID_FRONT_OR_PASSPORT');
864
- 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);
865
1713
  } else {
1714
+ if (isDebugEnabled()) {
1715
+ console.log(
1716
+ '[ID_FRONT Scan] Confirmed as ID card - proceeding to hologram'
1717
+ );
1718
+ }
866
1719
  setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1720
+ setTimeout(() => {
1721
+ onIdentityDocumentScanned(scannedData);
1722
+ }, 1000);
867
1723
  }
868
- } else if (
869
- nextStep === 'SCAN_HOLOGRAM' &&
870
- (!!scannedData.hologramImage ||
871
- hologramDetectionCurrentRetryCount.value >=
872
- HOLOGRAM_DETECTION_RETRY_COUNT) &&
873
- (!!scannedData.secondaryFaceImage ||
874
- secondaryFaceDetectionCurrentRetryCount.value >=
875
- SECOND_FACE_DETECTION_RETRY_COUNT)
876
- ) {
877
- setStatus('SCANNED');
878
- setNextStepAndVibrate('SCAN_ID_BACK', 'SCAN_HOLOGRAM');
879
- onIdentityDocumentScanned(scannedData);
880
1724
  }
1725
+ // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
881
1726
  } else if (documentType === 'PASSPORT') {
882
1727
  if (
883
1728
  nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' &&
884
1729
  !scannedData.hologramImage
885
1730
  ) {
886
- // For passport, require valid MRZ before proceeding
887
1731
  if (onlyMRZScan) {
888
- // eID scan: require valid MRZ
1732
+ const hasRequiredFields = hasRequiredMRZFields(
1733
+ parsedMRZData?.fields
1734
+ );
1735
+ // CRITICAL: Only accept MRZ with valid checksums AND consistent reads
889
1736
  if (
890
1737
  !!scannedData.mrzText &&
891
- (parsedMRZData?.valid ||
892
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
1738
+ hasRequiredFields &&
1739
+ mrzStableAndValid
893
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');
894
1762
  setStatus('SCANNED');
1763
+ setIsTorchOn(false);
895
1764
  setNextStepAndVibrate('COMPLETED', 'SCAN_ID_FRONT_OR_PASSPORT');
896
- onIdentityDocumentScanned(scannedData);
897
- } else if (!parsedMRZData?.valid) {
898
- 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++;
899
1780
  setStatus('SCANNING');
1781
+ return; // Don't fall through to else-if
900
1782
  }
901
1783
  } else {
902
- // 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');
903
1820
  setStatus('SCANNED');
1821
+ setIsTorchOn(false);
904
1822
  setNextStepAndVibrate('SCAN_HOLOGRAM', 'SCAN_ID_FRONT_OR_PASSPORT');
1823
+ setTimeout(() => {
1824
+ onIdentityDocumentScanned(scannedData);
1825
+ }, 1000);
905
1826
  }
906
- } else if (
907
- ((nextStep === 'SCAN_HOLOGRAM' &&
908
- (!!scannedData.hologramImage ||
909
- hologramDetectionCurrentRetryCount.value >=
910
- HOLOGRAM_DETECTION_RETRY_COUNT) &&
911
- (!!scannedData.secondaryFaceImage ||
912
- secondaryFaceDetectionCurrentRetryCount.value >=
913
- SECOND_FACE_DETECTION_RETRY_COUNT)) ||
914
- onlyMRZScan) &&
915
- !!scannedData.mrzText &&
916
- (parsedMRZData?.valid ||
917
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
918
- ) {
919
- setStatus('SCANNED');
920
- setNextStepAndVibrate('COMPLETED', 'SCAN_HOLOGRAM');
921
- onIdentityDocumentScanned(scannedData);
922
- } else if (!parsedMRZData?.valid) {
923
- mrzDetectionCurrentRetryCount.value++;
924
1827
  }
1828
+ // Note: SCAN_HOLOGRAM completion is now handled in the unified block above
925
1829
  } else if (documentType === 'ID_BACK') {
926
- if (
927
- ((parsedMRZData?.fields?.issuingState === 'TUR' &&
928
- barcode?.value?.trim() ===
929
- parsedMRZData?.fields?.optional1?.trim()) ||
930
- parsedMRZData?.fields?.issuingState !== 'TUR' ||
931
- onlyMRZScan) &&
932
- nextStep === 'SCAN_ID_BACK' &&
933
- !!scannedData.mrzText &&
934
- (parsedMRZData?.valid ||
935
- mrzDetectionCurrentRetryCount.value >= MRZ_VALIDATION_RETRY_COUNT)
936
- ) {
937
- scannedData.barcodeValue = barcode?.value ?? undefined;
938
- setStatus('SCANNED');
939
- setNextStepAndVibrate('COMPLETED', 'SCAN_ID_BACK');
940
- onIdentityDocumentScanned(scannedData);
941
- } else if (!parsedMRZData?.valid) {
942
- mrzDetectionCurrentRetryCount.value++;
943
- }
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');
944
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
+ }
945
1847
  setStatus('SCANNING');
946
1848
  }
947
-
948
- // Clear OpenCV buffers to prevent memory leaks
949
- try {
950
- OpenCV.clearBuffers();
951
- } catch (bufferError) {
952
- // Ignore buffer cleanup errors
953
- console.warn('Buffer cleanup error:', bufferError);
954
- }
955
1849
  },
956
1850
  [
957
- currentFaceImage,
958
- currentHologramImage,
959
- currentSecondaryFaceImage,
960
- device,
961
1851
  nextStep,
1852
+ frameDimensions,
1853
+ currentHologramImage,
1854
+ currentFaceImage,
1855
+ hasRequiredMRZFields,
1856
+ areMRZFieldsEqual,
1857
+ detectedDocumentType,
962
1858
  onlyMRZScan,
1859
+ isTorchOn,
1860
+ setIsTorchOn,
1861
+ setNextStepAndVibrate,
1862
+ onIdentityDocumentScanned,
1863
+ logMRZDetails,
1864
+ logMRZValidationFailure,
1865
+ currentSecondaryFaceImage,
1866
+ detectHologramNative,
963
1867
  ]
964
1868
  );
965
1869
 
966
- const handleExposureValue = useRunOnJS(
967
- (value: number) => {
968
- setExposure(value);
969
- },
970
- [exposure]
971
- );
972
-
973
- // Focus trigger for when blur is detected (called from worklet)
974
- const triggerFocus = useRunOnJS(
975
- async () => {
976
- if (!cameraRef.current || !device?.supportsFocus) {
977
- return;
978
- }
979
- try {
980
- const width = format?.videoWidth ?? 1920;
981
- const height = format?.videoHeight ?? 1080;
982
- const centerPoint = getScanAreaCenterPoint(width, height);
983
- await cameraRef.current.focus({
984
- x: centerPoint.x,
985
- y: centerPoint.y,
986
- });
987
- } catch (error) {
988
- // Ignore focus errors
989
- }
990
- },
991
- [device, format]
992
- );
993
-
994
- const handleExposureAndBrightness = (frame: Frame) => {
995
- 'worklet';
996
- const averageBrightness = getAverageBrightness(frame);
997
- const minExposure = device?.minExposure ?? 0;
998
- const maxExposure = device?.maxExposure ?? 0;
999
-
1000
- // Dynamic thresholds based on scanning state using config values
1001
- // Face detection requires higher minimum brightness for reliable detection
1002
- const isFrontOrPassport = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT';
1003
- const isBack = nextStep === 'SCAN_ID_BACK';
1004
-
1005
- // Using config values: faceDetection { low: 50, high: 110, target: 85 }, mrzScanning { low: 45, high: 130, target: 80 }
1006
- const lowerBrightnessBound = isFrontOrPassport ? 50 : 40;
1007
- const upperBrightnessBound = isBack ? 130 : 120;
1008
- const targetBrightness = isFrontOrPassport ? 85 : 80;
1009
-
1010
- // Smooth exposure adjustment with hysteresis to prevent oscillation
1011
- // Only adjust if brightness is significantly outside the acceptable range
1012
- const hysteresis = 5; // Dead zone to prevent jitter
1013
-
1014
- if (
1015
- averageBrightness < (lowerBrightnessBound - hysteresis) &&
1016
- exposureValue.value < maxExposure
1017
- ) {
1018
- // Increase exposure smoothly when too dark
1019
- const step = calculateExposureStep(averageBrightness, targetBrightness);
1020
- exposureValue.value = Math.min(maxExposure, exposureValue.value + step);
1021
- } else if (
1022
- averageBrightness > (upperBrightnessBound + hysteresis) &&
1023
- exposureValue.value > minExposure
1024
- ) {
1025
- // Decrease exposure smoothly when too bright
1026
- const step = calculateExposureStep(averageBrightness, targetBrightness);
1027
- exposureValue.value = Math.max(minExposure, exposureValue.value - step);
1028
- }
1029
- // When within acceptable range (with hysteresis), don't adjust - prevents oscillation
1030
-
1031
- const isBright = averageBrightness > lowerBrightnessBound;
1032
- handleExposureValue(exposureValue.value);
1033
- handleBrightness(isBright);
1034
-
1035
- return isBright;
1036
- };
1037
-
1038
- const handleWorklet = (frame: Frame) => {
1039
- 'worklet';
1040
- try {
1041
- const isBright = handleExposureAndBrightness(frame);
1042
- if (!isBright) {
1043
- return;
1044
- }
1045
-
1046
-
1047
-
1048
- // Check for blur before processing - skip blurry frames
1049
- // Use different thresholds: 25 for front (face detection), 30 for back (MRZ/text)
1050
- // Higher thresholds with improved Laplacian algorithm using H+V gradients
1051
- const isFront = nextStep === 'SCAN_ID_FRONT_OR_PASSPORT';
1052
- const blurThreshold = isFront ? 25 : 30;
1053
- const blurry = checkBlurry(frame, blurThreshold);
1054
- handleBlurStatus(blurry);
1055
- if (blurry) {
1056
- consecutiveBlurCount.value++;
1057
- // Only trigger focus after 2 consecutive blurry frames (matching Flutter)
1058
- if (consecutiveBlurCount.value >= 2) {
1059
- triggerFocus();
1060
- consecutiveBlurCount.value = 0;
1061
- }
1870
+ const handleFrame = useCallback(
1871
+ async (event: NativeSyntheticEvent<{ frame: Frame }>) => {
1872
+ if (!isCameraInitialized.current) {
1062
1873
  return;
1063
1874
  }
1064
- // Reset blur count on sharp frame
1065
- consecutiveBlurCount.value = 0;
1066
1875
 
1876
+ const { frame } = event.nativeEvent;
1067
1877
 
1068
- // Validate frame dimensions before processing
1069
1878
  if (
1070
1879
  !frame.width ||
1071
1880
  !frame.height ||
1072
1881
  frame.width <= 0 ||
1073
1882
  frame.height <= 0
1074
1883
  ) {
1075
- console.warn('Invalid frame dimensions:', {
1076
- width: frame.width,
1077
- height: frame.height,
1078
- });
1079
1884
  return;
1080
1885
  }
1081
1886
 
1082
- // Detect faces first with error handling
1083
- let detectedFaces: Face[] = [];
1084
- try {
1085
- if (faceDetectionEnabled) {
1086
- detectedFaces = detectFaces(frame);
1087
- // Reset error count on successful detection
1088
- faceDetectionErrorCount.value = 0;
1089
- }
1090
- } catch (faceError) {
1091
- console.warn('Face detection failed:', faceError);
1092
- faceDetectionErrorCount.value += 1;
1093
-
1094
- // Disable face detection temporarily after 5 consecutive errors
1095
- if (faceDetectionErrorCount.value >= 5) {
1096
- setFaceDetectionEnabled(false);
1097
- // Re-enable after 10 seconds
1098
- setTimeout(() => {
1099
- setFaceDetectionEnabled(true);
1100
- faceDetectionErrorCount.value = 0;
1101
- }, 10000);
1102
- }
1887
+ const base64Image = frame.base64Image;
1888
+ if (!base64Image) return;
1103
1889
 
1104
- 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();
1105
1894
  }
1895
+ const avgBrightness =
1896
+ brightnessHistory.current.reduce((a, b) => a + b, 0) /
1897
+ brightnessHistory.current.length;
1898
+ const isOverallBright = avgBrightness >= MIN_BRIGHTNESS_THRESHOLD;
1899
+
1900
+ setIsBrightnessLow(!isOverallBright);
1106
1901
 
1107
- // Create a copy of the frame for cropping to avoid buffer conflicts
1108
- let image: CropResult;
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
1109
1906
  try {
1110
- image = crop(frame, {
1111
- cropRegion: {
1112
- top: 0,
1113
- left: 0,
1114
- width: 100,
1115
- height: 100,
1116
- },
1117
- includeImageBase64: true,
1118
- saveAsFile: false,
1119
- });
1120
- } catch (cropError) {
1121
- console.warn('Crop operation failed:', cropError);
1122
- 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);
1123
1921
  }
1124
1922
 
1125
- // Text recognition with error handling
1126
- // Note: CLAHE enhancement is applied to captured images, not live frames
1127
- // ML Kit plugins work directly on Frame objects and don't support Mat input
1128
- let scannedText: BlockText;
1129
- try {
1130
- scannedText = scanText(frame) as any as BlockText;
1131
- } catch (textError) {
1132
- console.warn('Text recognition failed:', textError);
1133
- 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;
1134
1946
  }
1135
1947
 
1136
- // Barcode scanning with error handling
1137
- let barcodes: any[] = [];
1138
1948
  try {
1139
- barcodes = scanCodes(frame, {
1140
- barcodeTypes: ['code-128', 'code-39', 'code-93', 'ean-13', 'qr'],
1141
- });
1142
- } catch (barcodeError) {
1143
- console.warn('Barcode scanning failed:', barcodeError);
1144
- barcodes = [];
1145
- }
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
+ }
1146
1964
 
1147
- handleFaceAndText(
1148
- scannedText.resultText ?? '',
1149
- detectedFaces,
1150
- frame.width,
1151
- frame.height,
1152
- barcodes.length ? barcodes[0] : undefined,
1153
- image?.base64
1154
- );
1155
- } catch (error) {
1156
- console.warn('Frame processing error:', error);
1157
- }
1158
- };
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
+ }
1159
2028
 
1160
- const frameProcessor = useFrameProcessor(
1161
- (frame) => {
1162
- '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;
1163
2034
 
1164
- if (!isCameraInitialized.value) {
1165
- return;
1166
- }
2035
+ let scale: number;
2036
+ let offsetX = 0;
2037
+ let offsetY = 0;
1167
2038
 
1168
- if (Platform.OS === 'ios') {
1169
- // iOS: Run at target 6 FPS using runAtTargetFps
1170
- runAtTargetFps(6, () => {
1171
- 'worklet';
1172
- try {
1173
- handleWorklet(frame);
1174
- } catch (error) {
1175
- console.warn('Frame processor error:', 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;
1176
2045
  }
1177
- });
1178
- } else {
1179
- // Android: Run async without throttling
1180
- try {
1181
- runAsync(frame, () => {
1182
- 'worklet';
1183
- try {
1184
- handleWorklet(frame);
1185
- } catch (workletError) {
1186
- console.warn('Worklet execution error:', 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);
1187
2073
  }
1188
- });
1189
- } catch (error) {
1190
- console.warn('Frame processor error:', error);
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);
2091
+ }
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
+ }
1191
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);
1192
2512
  }
1193
2513
  },
1194
- [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
+ []
1195
2534
  );
1196
2535
 
1197
2536
  if (!permissionsRequested) {
1198
2537
  return (
1199
2538
  <SafeAreaView style={styles.permissionContainer}>
2539
+ <StatusBar barStyle="dark-content" />
1200
2540
  <ActivityIndicator size="large" color={theme.colors.primary} />
1201
2541
  </SafeAreaView>
1202
2542
  );
1203
2543
  }
1204
2544
 
1205
- if (!cameraPermission.hasPermission) {
2545
+ if (!hasPermission) {
1206
2546
  return (
1207
2547
  <SafeAreaView style={styles.permissionContainer}>
1208
- <Text style={styles.permissionText}>
2548
+ <StatusBar barStyle="dark-content" />
2549
+ <TextView style={styles.permissionText}>
1209
2550
  {t('general.noCameraPermissionGiven')}
1210
- </Text>
2551
+ </TextView>
1211
2552
  <StyledButton
1212
2553
  mode="contained"
1213
2554
  onPress={() => {
@@ -1220,32 +2561,13 @@ const IdentityDocumentCamera = ({
1220
2561
  );
1221
2562
  }
1222
2563
 
1223
- if (device == null) {
1224
- return (
1225
- <SafeAreaView style={styles.permissionContainer}>
1226
- <TextView style={styles.permissionText}>
1227
- {t('general.noCameraDetected')}
1228
- </TextView>
1229
- </SafeAreaView>
1230
- );
1231
- }
1232
-
1233
- const handleFocus = async (event: GestureResponderEvent) => {
1234
- if (cameraRef.current && device.supportsFocus) {
1235
- try {
1236
- const { locationX, locationY } = event.nativeEvent;
1237
- await cameraRef.current.focus({
1238
- x: locationX,
1239
- y: locationY,
1240
- });
1241
- } catch (error) {
1242
- // console.log('Error while focusing:', error);
1243
- }
1244
- }
1245
- };
1246
-
1247
2564
  return (
1248
2565
  <View style={StyleSheet.absoluteFill}>
2566
+ <StatusBar
2567
+ barStyle="light-content"
2568
+ backgroundColor="transparent"
2569
+ translucent
2570
+ />
1249
2571
  {!hasGuideShown ? (
1250
2572
  <SafeAreaView style={styles.guide}>
1251
2573
  <LottieView
@@ -1282,52 +2604,513 @@ const IdentityDocumentCamera = ({
1282
2604
  </SafeAreaView>
1283
2605
  ) : (
1284
2606
  <>
1285
- <Camera
2607
+ <TrustchexCamera
1286
2608
  ref={cameraRef}
1287
- frameProcessor={frameProcessor}
1288
- style={StyleSheet.absoluteFill}
1289
- device={device}
1290
- format={format}
1291
- isActive={isActive}
1292
- photo={false}
1293
- video={false}
1294
- audio={false}
1295
- torch={isTorchOn ? 'on' : 'off'}
1296
- fps={Math.max(format?.minFps ?? 0, 30)}
1297
- exposure={exposure}
1298
- onInitialized={() => {
1299
- isCameraInitialized.value = true;
1300
- }}
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}
1301
2622
  />
1302
- <View style={[styles.topZone, { paddingTop: insets.top }]}>
1303
- {/* Step Progress Indicator - show only after document type detected and not completed/scanned */}
1304
- {nextStep !== 'COMPLETED' && status !== 'SCANNED' && detectedDocumentType !== 'UNKNOWN' && (
1305
- <TextView style={styles.stepIndicator}>
1306
- {nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1307
- ? `${t('identityDocumentCamera.frontSide')} ${t('identityDocumentCamera.stepProgress', {
1308
- current: 1,
1309
- total: onlyMRZScan
1310
- ? (detectedDocumentType === 'PASSPORT' ? 1 : 2)
1311
- : (detectedDocumentType === 'PASSPORT' ? 2 : 3)
1312
- })}`
1313
- : nextStep === 'SCAN_HOLOGRAM'
1314
- ? `${t('identityDocumentCamera.hologramCheck')} ${t('identityDocumentCamera.stepProgress', {
1315
- current: 2,
1316
- total: detectedDocumentType === 'PASSPORT' ? 2 : 3
1317
- })}`
1318
- : nextStep === 'SCAN_ID_BACK'
1319
- ? `${t('identityDocumentCamera.backSide')} • ${t('identityDocumentCamera.stepProgress', { current: 3, total: 3 })}`
1320
- : ''}
1321
- </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
+ </>
1322
2768
  )}
1323
- {/* Status-based guidance text */}
1324
- <TextView style={[
1325
- styles.topZoneText,
1326
- status === 'SCANNING' && styles.topZoneTextScanning,
1327
- status === 'SCANNED' && styles.topZoneTextSuccess,
1328
- status === 'INCORRECT' && styles.topZoneTextError,
1329
- (isBrightnessLow || isFrameBlurry) && styles.topZoneTextWarning,
1330
- ]}>
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
+ >
1331
3114
  {status === 'SCANNED'
1332
3115
  ? completedStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1333
3116
  ? detectedDocumentType === 'PASSPORT'
@@ -1343,109 +3126,272 @@ const IdentityDocumentCamera = ({
1343
3126
  ? t('identityDocumentCamera.wrongSideFront')
1344
3127
  : nextStep === 'SCAN_ID_BACK'
1345
3128
  ? t('identityDocumentCamera.wrongSideBack')
1346
- : 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')
1347
3132
  : isBrightnessLow
1348
3133
  ? t('identityDocumentCamera.lowBrightness')
1349
3134
  : isFrameBlurry
1350
3135
  ? t('identityDocumentCamera.avoidBlur')
1351
- : nextStep === 'SCAN_ID_FRONT_OR_PASSPORT'
1352
- ? status === 'SCANNING'
1353
- ? currentFaceImage
1354
- ? detectedDocumentType === 'PASSPORT'
1355
- ? t('identityDocumentCamera.passportDetected')
1356
- : detectedDocumentType === 'ID_FRONT'
1357
- ? 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')
1358
3147
  : t('identityDocumentCamera.readingDocument')
1359
- : t('identityDocumentCamera.readingDocument')
1360
- : t('identityDocumentCamera.alignPhotoSide')
1361
- : nextStep === 'SCAN_HOLOGRAM'
1362
- ? t('identityDocumentCamera.alignHologram')
1363
- : nextStep === 'SCAN_ID_BACK'
1364
- ? status === 'SCANNING'
1365
- ? t('identityDocumentCamera.readingDocument')
1366
- : t('identityDocumentCamera.alignIDBackSide')
1367
- : nextStep === 'COMPLETED'
1368
- ? t('identityDocumentCamera.scanCompleted')
1369
- : ''}
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
+ : ''}
1370
3180
  </TextView>
1371
3181
  </View>
1372
3182
  <View style={styles.leftZone} />
1373
3183
  <View style={styles.rightZone} />
1374
3184
  <View style={styles.bottomZone}>
1375
- {showDebugImages && currentFaceImage && (
1376
- <View style={styles.imageContainer}>
1377
- <Image
1378
- source={{
1379
- uri: `data:image/jpeg;base64,${currentFaceImage}`,
1380
- }}
1381
- style={styles.faceImage}
1382
- />
1383
- <TextView style={styles.imageContainerText}>Face</TextView>
1384
- </View>
1385
- )}
1386
-
1387
- {showDebugImages && currentSecondaryFaceImage && (
1388
- <View style={styles.imageContainer}>
1389
- <Image
1390
- source={{
1391
- uri: `data:image/jpeg;base64,${currentSecondaryFaceImage}`,
1392
- }}
1393
- style={styles.faceImage}
1394
- />
1395
- <TextView style={styles.imageContainerText}>
1396
- Secondary Face
1397
- </TextView>
1398
- </View>
1399
- )}
1400
-
1401
- {showDebugImages && _currentHologramMaskImage && (
1402
- <View style={styles.imageContainer}>
1403
- <Image
1404
- source={{
1405
- uri: `data:image/jpeg;base64,${_currentHologramMaskImage}`,
1406
- }}
1407
- style={styles.faceImage}
1408
- />
1409
- <TextView style={styles.imageContainerText}>
1410
- Hologram Mask
1411
- </TextView>
1412
- </View>
1413
- )}
1414
-
1415
- {showDebugImages && currentHologramImage && (
1416
- <View style={styles.imageContainer}>
1417
- <Image
1418
- source={{
1419
- uri: `data:image/jpeg;base64,${currentHologramImage}`,
1420
- }}
1421
- style={styles.faceImage}
1422
- />
1423
- <TextView style={styles.imageContainerText}>Hologram</TextView>
1424
- </View>
1425
- )}
1426
-
1427
- {showDebugImages && (
1428
- <View style={styles.debugInfoContainer}>
1429
- <TextView style={styles.debugInfoText}>
1430
- Step: {nextStep}
1431
- </TextView>
1432
- <TextView style={styles.debugInfoText}>
1433
- Status: {status}
1434
- </TextView>
1435
- <TextView style={styles.debugInfoText}>
1436
- {`Face: ${currentFaceImage ? '✓' : '✗'} ${!faceDetectionEnabled ? '(DISABLED)' : ''
1437
- }`}
1438
- </TextView>
1439
- <TextView style={styles.debugInfoText}>
1440
- {`Hologram: ${currentHologramImage ? '✓' : '✗'} (${hologramDetectionCurrentRetryCount.value
1441
- }/${HOLOGRAM_DETECTION_RETRY_COUNT})`}
1442
- </TextView>
1443
- <TextView style={styles.debugInfoText}>
1444
- {`2nd Face: ${currentSecondaryFaceImage ? '✓' : '✗'} (${secondaryFaceDetectionCurrentRetryCount.value
1445
- }/${SECOND_FACE_DETECTION_RETRY_COUNT})`}
1446
- </TextView>
1447
- </View>
1448
- )}
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>
1449
3395
  </View>
1450
3396
  <View
1451
3397
  style={[
@@ -1453,19 +3399,18 @@ const IdentityDocumentCamera = ({
1453
3399
  {
1454
3400
  borderColor:
1455
3401
  status === 'SCANNED' || nextStep === 'COMPLETED'
1456
- ? '#4CAF50' // Green - success
3402
+ ? '#4CAF50'
1457
3403
  : status === 'INCORRECT'
1458
- ? '#f44336' // Red - error
3404
+ ? '#f44336'
1459
3405
  : status === 'SCANNING'
1460
- ? '#2196F3' // Blue - processing
3406
+ ? '#2196F3'
1461
3407
  : isBrightnessLow || isFrameBlurry
1462
- ? '#FFC107' // Yellow - warning
3408
+ ? '#FFC107'
1463
3409
  : 'white',
1464
3410
  borderWidth: status === 'SCANNING' ? 3 : 2,
1465
3411
  },
1466
3412
  ]}
1467
3413
  >
1468
- {/* Only show ONE animation at a time - priority order: completed/scanned > brightness > hologram > scanning */}
1469
3414
  {nextStep === 'COMPLETED' || status === 'SCANNED' ? (
1470
3415
  <LottieView
1471
3416
  source={require('../../Shared/Animations/success.json')}
@@ -1496,11 +3441,84 @@ const IdentityDocumentCamera = ({
1496
3441
  />
1497
3442
  ) : null}
1498
3443
  </View>
1499
- <TouchableOpacity
1500
- onPress={handleFocus}
1501
- style={styles.focusArea}
1502
- activeOpacity={1}
1503
- />
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
+ )}
1504
3522
  </>
1505
3523
  )}
1506
3524
  </View>
@@ -1537,14 +3555,6 @@ const styles = StyleSheet.create({
1537
3555
  alignItems: 'center',
1538
3556
  paddingHorizontal: 5,
1539
3557
  },
1540
- focusArea: {
1541
- position: 'absolute',
1542
- top: 0,
1543
- left: 0,
1544
- width: '100%',
1545
- height: '100%',
1546
- zIndex: 2,
1547
- },
1548
3558
  animation: {
1549
3559
  width: '100%',
1550
3560
  height: '100%',
@@ -1575,16 +3585,16 @@ const styles = StyleSheet.create({
1575
3585
  padding: 20,
1576
3586
  },
1577
3587
  topZoneTextScanning: {
1578
- color: '#2196F3', // Blue when scanning
3588
+ color: '#2196F3',
1579
3589
  },
1580
3590
  topZoneTextSuccess: {
1581
- color: '#4CAF50', // Green for success
3591
+ color: '#4CAF50',
1582
3592
  },
1583
3593
  topZoneTextWarning: {
1584
- color: '#FFC107', // Yellow for warnings
3594
+ color: '#FFC107',
1585
3595
  },
1586
3596
  topZoneTextError: {
1587
- color: '#f44336', // Red for errors
3597
+ color: '#f44336',
1588
3598
  },
1589
3599
  leftZone: {
1590
3600
  position: 'absolute',
@@ -1610,12 +3620,24 @@ const styles = StyleSheet.create({
1610
3620
  bottom: 0,
1611
3621
  backgroundColor: '#00000099',
1612
3622
  padding: 20,
3623
+ display: 'flex',
3624
+ flexDirection: 'column',
3625
+ gap: 10,
3626
+ justifyContent: 'flex-start',
3627
+ },
3628
+ debugImagesRow: {
1613
3629
  display: 'flex',
1614
3630
  flexDirection: 'row',
1615
3631
  gap: 10,
1616
3632
  justifyContent: 'flex-start',
1617
3633
  flexWrap: 'wrap',
1618
3634
  },
3635
+ cardDetectionRow: {
3636
+ display: 'flex',
3637
+ flexDirection: 'row',
3638
+ justifyContent: 'center',
3639
+ marginTop: 5,
3640
+ },
1619
3641
  imageContainer: {
1620
3642
  display: 'flex',
1621
3643
  flexDirection: 'column',
@@ -1635,6 +3657,18 @@ const styles = StyleSheet.create({
1635
3657
  borderWidth: 1,
1636
3658
  borderColor: 'white',
1637
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
+ },
1638
3672
  debugInfoContainer: {
1639
3673
  flex: 1,
1640
3674
  paddingLeft: 10,