@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.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 (283) hide show
  1. package/README.md +152 -8
  2. package/android/build.gradle +6 -0
  3. package/android/src/main/AndroidManifest.xml +14 -4
  4. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
  8. package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
  9. package/app.plugin.js +102 -13
  10. package/ios/HybridMyazaEmrtd.swift +447 -0
  11. package/ios/HybridMyazaFaceDetector.swift +104 -2
  12. package/ios/HybridMyazaRectDetector.swift +103 -0
  13. package/ios/HybridMyazaTextRecognizer.swift +111 -0
  14. package/nitrogen/generated/.gitattributes +1 -0
  15. package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
  16. package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
  17. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
  18. package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
  19. package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
  20. package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
  21. package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
  22. package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
  23. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
  24. package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
  25. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
  26. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
  27. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
  28. package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
  29. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
  30. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
  31. package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
  42. package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
  44. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
  45. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
  46. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
  47. package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
  48. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
  49. package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
  50. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
  52. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
  54. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
  55. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
  56. package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
  57. package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
  58. package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
  59. package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
  60. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  61. package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
  62. package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
  63. package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
  67. package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
  68. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
  69. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
  70. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
  71. package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
  72. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
  73. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
  74. package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
  75. package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
  76. package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
  77. package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
  78. package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
  79. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
  80. package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
  81. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
  82. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
  83. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
  84. package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
  85. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
  86. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
  87. package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
  88. package/package.json +16 -4
  89. package/src/MyazaKYC.tsx +115 -22
  90. package/src/capture/documentIdentity.ts +153 -0
  91. package/src/capture/documentSignals.ts +177 -0
  92. package/src/capture/documentTextGate.ts +171 -0
  93. package/src/capture/hints.ts +57 -0
  94. package/src/capture/index.ts +24 -0
  95. package/src/capture/rectDetector.ts +84 -0
  96. package/src/capture/rectGate.ts +91 -0
  97. package/src/capture/useAutoCapture.ts +198 -0
  98. package/src/components/CameraViewfinder.tsx +226 -70
  99. package/src/components/ContactCodeEntry.tsx +139 -0
  100. package/src/components/CountryField.tsx +87 -0
  101. package/src/components/CountryFlag.tsx +15 -15
  102. package/src/components/DialCodePicker.tsx +186 -0
  103. package/src/components/DocumentCropper.tsx +1 -1
  104. package/src/components/DocumentReview.tsx +142 -0
  105. package/src/components/DocumentReviewSide.tsx +135 -0
  106. package/src/components/DocumentReviewZoom.tsx +119 -0
  107. package/src/components/ExpiryCountdown.tsx +52 -0
  108. package/src/components/FlashOverlay.tsx +156 -0
  109. package/src/components/Icon.tsx +70 -1
  110. package/src/components/KycFlow.tsx +66 -94
  111. package/src/components/KycSheet.tsx +148 -18
  112. package/src/components/MediaSourceSheet.tsx +163 -0
  113. package/src/components/MyazaButton.tsx +14 -2
  114. package/src/components/MyazaDateField.tsx +240 -0
  115. package/src/components/MyazaInput.tsx +73 -26
  116. package/src/components/MyazaSelect.tsx +283 -0
  117. package/src/components/MyazaWordmark.tsx +33 -0
  118. package/src/components/OptionRow.tsx +132 -0
  119. package/src/components/PhoneNumberInput.tsx +149 -0
  120. package/src/components/PoweredBy.tsx +89 -0
  121. package/src/components/ReadyPrimer.tsx +149 -0
  122. package/src/components/StepView.tsx +91 -0
  123. package/src/components/Typography.tsx +18 -3
  124. package/src/components/VerifiedNotice.tsx +38 -0
  125. package/src/components/WhatsAppIcon.tsx +29 -0
  126. package/src/components/WorkflowGate.tsx +128 -0
  127. package/src/components/brand-font.ts +113 -0
  128. package/src/components/flashHoleGeometry.ts +35 -0
  129. package/src/components/flow/FatalConfigError.tsx +56 -0
  130. package/src/components/fonts.ts +2 -19
  131. package/src/components/readyPrimerContent.ts +52 -0
  132. package/src/components/runtime.tsx +38 -43
  133. package/src/components/stepHeaderMeta.tsx +87 -0
  134. package/src/components/theme-provider.tsx +89 -0
  135. package/src/components/useWorkflowMount.ts +116 -0
  136. package/src/components/viewfinder/CardGuide.tsx +60 -0
  137. package/src/components/viewfinder/DocumentGhost.tsx +149 -0
  138. package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
  139. package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
  140. package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
  141. package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
  142. package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
  143. package/src/config/brand.ts +97 -0
  144. package/src/config/business.ts +179 -0
  145. package/src/config/businessSteps.ts +105 -0
  146. package/src/config/contact.ts +159 -0
  147. package/src/config/countryNames.g.ts +254 -0
  148. package/src/config/currencyFlags.ts +144 -0
  149. package/src/config/font-resolve.ts +78 -0
  150. package/src/config/idTypes.ts +96 -1
  151. package/src/config/keyPeople.ts +162 -0
  152. package/src/config/phone.ts +39 -0
  153. package/src/config/proofOfAddress.ts +75 -0
  154. package/src/config/questionnaire.ts +153 -0
  155. package/src/config/regions.ts +118 -0
  156. package/src/config/registrationHint.ts +87 -0
  157. package/src/config/stepOrder.ts +118 -0
  158. package/src/config/theme.ts +38 -2
  159. package/src/config/workflowMerge.ts +124 -0
  160. package/src/emrtd/bac.ts +128 -0
  161. package/src/emrtd/bytes.ts +127 -0
  162. package/src/emrtd/crypto.ts +163 -0
  163. package/src/emrtd/der.ts +192 -0
  164. package/src/emrtd/dg2.ts +111 -0
  165. package/src/emrtd/files.ts +215 -0
  166. package/src/emrtd/index.ts +30 -0
  167. package/src/emrtd/mrzKey.ts +54 -0
  168. package/src/emrtd/native.ts +79 -0
  169. package/src/emrtd/optionalRead.ts +42 -0
  170. package/src/emrtd/read.ts +224 -0
  171. package/src/emrtd/retry.ts +98 -0
  172. package/src/emrtd/secureMessaging.ts +178 -0
  173. package/src/emrtd/session.ts +200 -0
  174. package/src/emrtd/stages.ts +122 -0
  175. package/src/index.ts +110 -0
  176. package/src/lib/calendar.ts +71 -0
  177. package/src/lib/use-keyboard-inset.ts +34 -0
  178. package/src/liveness/challengeManager.ts +27 -1
  179. package/src/liveness/faceContinuity.ts +153 -0
  180. package/src/liveness/flashDetector.ts +145 -0
  181. package/src/liveness/flashReadyGate.ts +106 -0
  182. package/src/liveness/flashRunner.ts +118 -0
  183. package/src/liveness/integritySignals.ts +60 -0
  184. package/src/liveness/livenessState.ts +139 -0
  185. package/src/liveness/speech.ts +19 -1
  186. package/src/liveness/types.ts +48 -1
  187. package/src/liveness/useFlashPhase.ts +78 -0
  188. package/src/liveness/useLiveness.ts +260 -75
  189. package/src/liveness/visionCameraFaceDetector.ts +14 -0
  190. package/src/mrz/extract.ts +101 -0
  191. package/src/mrz/parse.ts +178 -0
  192. package/src/mrz/textRecognizer.ts +149 -0
  193. package/src/screens/ApplicantRoleStep.tsx +322 -0
  194. package/src/screens/BusinessDetailsStep.tsx +212 -0
  195. package/src/screens/BusinessDocumentSlot.tsx +167 -0
  196. package/src/screens/BusinessDocumentsStep.tsx +213 -0
  197. package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
  198. package/src/screens/CompanyInfoFields.tsx +83 -0
  199. package/src/screens/ConsentStep.tsx +68 -68
  200. package/src/screens/ContactChannelChoice.tsx +133 -0
  201. package/src/screens/ContactCodeStep.tsx +130 -0
  202. package/src/screens/ContactDestinationField.tsx +70 -0
  203. package/src/screens/ContactFooterNote.tsx +34 -0
  204. package/src/screens/ContactVerificationStep.tsx +199 -0
  205. package/src/screens/ContactVerifiedPanel.tsx +36 -0
  206. package/src/screens/CountrySelectStep.tsx +210 -0
  207. package/src/screens/DocumentCaptureStep.tsx +156 -132
  208. package/src/screens/IdInputStep.tsx +13 -5
  209. package/src/screens/IdTypeStep.tsx +74 -33
  210. package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
  211. package/src/screens/KeyPersonCard.tsx +119 -0
  212. package/src/screens/KeyPersonForm.tsx +148 -0
  213. package/src/screens/KeyPersonSheet.tsx +184 -0
  214. package/src/screens/LivenessStep.tsx +155 -247
  215. package/src/screens/MrzScanView.tsx +173 -0
  216. package/src/screens/NfcStep.tsx +290 -0
  217. package/src/screens/ProofOfAddressStep.tsx +238 -0
  218. package/src/screens/QuestionnaireField.tsx +219 -0
  219. package/src/screens/QuestionnaireMoneyField.tsx +134 -0
  220. package/src/screens/QuestionnaireStep.tsx +76 -0
  221. package/src/screens/SubmittedStep.tsx +5 -0
  222. package/src/screens/consent/model.ts +109 -0
  223. package/src/screens/document/CameraPhase.tsx +150 -0
  224. package/src/screens/document/RequiredPill.tsx +60 -0
  225. package/src/screens/liveness/LightingBanner.tsx +50 -0
  226. package/src/screens/liveness/LivenessOutcome.tsx +109 -0
  227. package/src/screens/liveness/ProgressDots.tsx +67 -0
  228. package/src/screens/liveness/SelfiePreview.tsx +39 -0
  229. package/src/screens/liveness/constants.ts +31 -0
  230. package/src/screens/liveness/index.ts +8 -0
  231. package/src/screens/liveness/resolveGuidance.ts +27 -0
  232. package/src/screens/liveness/useSelfieUpload.ts +107 -0
  233. package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
  234. package/src/screens/nfc/NfcReadBody.tsx +63 -0
  235. package/src/screens/nfc/NfcReadProgress.tsx +96 -0
  236. package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
  237. package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
  238. package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
  239. package/src/screens/nfc/availability.ts +73 -0
  240. package/src/screens/nfc/readErrorMessage.ts +93 -0
  241. package/src/screens/nfc/useChipPortrait.ts +58 -0
  242. package/src/screens/nfc/useNfcAvailability.ts +82 -0
  243. package/src/screens/useFlashHole.ts +105 -0
  244. package/src/screens/useFlashSequence.ts +96 -0
  245. package/src/screens/usePoaAttach.ts +121 -0
  246. package/src/services/api-types.ts +164 -0
  247. package/src/services/api-verify-types.ts +99 -0
  248. package/src/services/api.ts +72 -102
  249. package/src/services/cardCrop.ts +25 -10
  250. package/src/services/contactErrors.ts +43 -0
  251. package/src/services/deviceMetadata.ts +1 -1
  252. package/src/services/documentPicker.ts +20 -0
  253. package/src/services/fingerprint-sources.ts +129 -0
  254. package/src/services/fingerprint.ts +101 -0
  255. package/src/services/haptics.ts +38 -0
  256. package/src/services/mediaCompress.ts +7 -2
  257. package/src/services/resolveUrl.ts +2 -2
  258. package/src/services/workflowGate.ts +138 -0
  259. package/src/specs/MyazaEmrtd.nitro.ts +114 -0
  260. package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
  261. package/src/specs/MyazaRectDetector.nitro.ts +50 -0
  262. package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
  263. package/src/store/derive.ts +225 -0
  264. package/src/store/kycStore.ts +250 -149
  265. package/src/store/state.ts +253 -0
  266. package/src/store/submit.ts +101 -0
  267. package/src/store/submitApplicant.ts +81 -0
  268. package/src/types/appearance.ts +113 -0
  269. package/src/types/business.ts +131 -0
  270. package/src/types/config.ts +150 -124
  271. package/src/types/id-types.ts +57 -0
  272. package/src/types/verification.ts +5 -0
  273. package/src/types/workflow.ts +141 -0
  274. package/src/utils/amountFormat.ts +73 -0
  275. package/src/utils/tokens.ts +4 -2
  276. package/src/__tests__/cardCrop.test.ts +0 -39
  277. package/src/__tests__/deviceMetadata.test.ts +0 -34
  278. package/src/__tests__/errors.test.ts +0 -34
  279. package/src/__tests__/flow.test.ts +0 -61
  280. package/src/__tests__/gestureDetector.test.ts +0 -37
  281. package/src/__tests__/liveness.test.ts +0 -112
  282. package/src/__tests__/resolveUrl.test.ts +0 -64
  283. package/src/__tests__/validators.test.ts +0 -38
@@ -0,0 +1,447 @@
1
+ import Foundation
2
+ import CoreNFC
3
+ import ImageIO
4
+ import UIKit
5
+ import CommonCrypto
6
+ import NitroModules
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // HybridMyazaEmrtd — the iOS half of eMRTD chip reading, implementing the
10
+ // nitrogen-generated `HybridMyazaEmrtdSpec` from src/specs/MyazaEmrtd.nitro.ts.
11
+ // The Android half is HybridMyazaEmrtd.kt.
12
+ //
13
+ // Two responsibilities, deliberately no more:
14
+ //
15
+ // • TRANSPORT. CoreNFC's ISO-7816 tag, wrapped so TypeScript can send an
16
+ // APDU and get bytes back. Everything above — BAC, secure messaging, file
17
+ // reads — is TypeScript, where it is tested against ICAO 9303's worked
18
+ // examples in CI.
19
+ // • PRIMITIVES. CommonCrypto's DES, AES and SHA. Apple's implementations are
20
+ // audited and hardware-backed; a hand-written DES in JavaScript would be
21
+ // both slower and a novel implementation on a path that authenticates a
22
+ // passport.
23
+ //
24
+ // This file decides NOTHING about authenticity. It moves bytes.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ final class HybridMyazaEmrtd: HybridMyazaEmrtdSpec {
28
+
29
+ private var session: NFCTagReaderSession?
30
+ private var tag: NFCISO7816Tag?
31
+ private var delegate: SessionDelegate?
32
+
33
+ // MARK: - Transport
34
+
35
+ /// Whether this device can read chips.
36
+ ///
37
+ /// `NFCTagReaderSession.readingAvailable` has been observed returning FALSE on
38
+ /// a device that reads chips perfectly well — across app restarts, a
39
+ /// foreground transition and a reinstall — and on the very same run that then
40
+ /// opened a session and read a passport's MRZ. A device that truly lacked the
41
+ /// capability could not have got that far, so the false is the property
42
+ /// lying, not the hardware answering.
43
+ ///
44
+ /// The likeliest reason is the thread: Nitro invokes this synchronously from
45
+ /// the JS thread, and CoreNFC is UIKit-adjacent. Reading it on the main thread
46
+ /// would be the direct fix, but a `DispatchQueue.main.sync` from the JS thread
47
+ /// can deadlock whenever main is itself waiting on JS — a real possibility
48
+ /// during startup, and far worse than a wrong boolean.
49
+ ///
50
+ /// So it exploits the one thing that IS certain: NFC hardware cannot vanish
51
+ /// while the app is running. A single observed `true` is therefore
52
+ /// authoritative for the rest of the process, and later false readings — which
53
+ /// have only ever been wrong — cannot retract it. A device genuinely without
54
+ /// NFC never observes a true and keeps reporting false, which is correct.
55
+ private static var everAvailable = false
56
+
57
+ func isAvailable() throws -> Bool {
58
+ let now = NFCTagReaderSession.readingAvailable
59
+ if now { Self.everAvailable = true }
60
+ return now || Self.everAvailable
61
+ }
62
+
63
+ /// Decode an image format JS cannot render, returning base64 JPEG.
64
+ ///
65
+ /// Passport chips overwhelmingly store DG2 as JPEG 2000, which React Native
66
+ /// cannot display — so the portrait the issuing government signed was being
67
+ /// read, hash-verified against the SOD, and then thrown away unshown. iOS has
68
+ /// a decoder: ImageIO registers `public.jpeg-2000`.
69
+ ///
70
+ /// Empty string on failure rather than throwing. The preview is a courtesy;
71
+ /// the chip read's verdict does not depend on it.
72
+ func decodeImage(dataBase64: String) throws -> Promise<String> {
73
+ Promise.async {
74
+ guard let data = Data(base64Encoded: dataBase64) else { return "" }
75
+ // Off the main thread: a full-resolution portrait is a few hundred
76
+ // kilobytes of wavelet data and decoding it should not stall a frame.
77
+ return await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
78
+ DispatchQueue.global(qos: .userInitiated).async {
79
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
80
+ let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil),
81
+ let jpeg = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9)
82
+ else {
83
+ continuation.resume(returning: "")
84
+ return
85
+ }
86
+ continuation.resume(returning: jpeg.base64EncodedString())
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ func startSession(alertMessage: String) throws -> Promise<EmrtdTagInfo> {
93
+ Promise.async {
94
+ try await withCheckedThrowingContinuation { continuation in
95
+ // The WHOLE session lifecycle runs on the MAIN thread. Nitro promises
96
+ // execute on a background executor, and a session created and begun
97
+ // there has been observed invalidating INSTANTLY with `Code=202
98
+ // "Session invalidated unexpectedly"` — before the system sheet ever
99
+ // appears — on the same phone that reads chips happily through a
100
+ // main-thread session (the Flutter SDK's path; method channels deliver
101
+ // on main). Consistent with `isAvailable` above: CoreNFC is
102
+ // UIKit-adjacent and answers wrongly off the main thread. Unlike the
103
+ // availability read, dispatching async here cannot deadlock — nothing
104
+ // waits synchronously on the result.
105
+ DispatchQueue.main.async {
106
+ // A stale session from an earlier attempt must never overlap the new
107
+ // one — two live sessions is itself an instant invalidation.
108
+ self.session?.invalidate()
109
+ self.session = nil
110
+ self.tag = nil
111
+
112
+ // The delegate is retained explicitly: NFCTagReaderSession holds its
113
+ // delegate weakly, and a deallocated one silently ends the session
114
+ // with no callback at all.
115
+ let delegate = SessionDelegate { [weak self] result in
116
+ switch result {
117
+ case .success(let tag):
118
+ self?.tag = tag
119
+ continuation.resume(
120
+ returning: EmrtdTagInfo(connected: true, uid: Self.identifier(of: tag))
121
+ )
122
+ case .failure(let error):
123
+ continuation.resume(throwing: error)
124
+ }
125
+ }
126
+ self.delegate = delegate
127
+
128
+ guard
129
+ let session = NFCTagReaderSession(
130
+ pollingOption: .iso14443, delegate: delegate, queue: nil
131
+ )
132
+ else {
133
+ continuation.resume(throwing: EmrtdError.unavailable)
134
+ return
135
+ }
136
+ session.alertMessage = alertMessage
137
+ self.session = session
138
+ session.begin()
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ func stopSession(message: String) throws -> Promise<Void> {
145
+ Promise.async {
146
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
147
+ // Same main-thread rule as startSession — teardown must not race a
148
+ // begin() already queued on main.
149
+ DispatchQueue.main.async {
150
+ if !message.isEmpty {
151
+ self.session?.alertMessage = message
152
+ }
153
+ self.session?.invalidate()
154
+ self.session = nil
155
+ self.tag = nil
156
+ self.delegate = nil
157
+ continuation.resume()
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ func updateSessionMessage(message: String) throws -> Promise<Void> {
164
+ Promise.async {
165
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
166
+ // Same main-thread rule as the rest of the session lifecycle. The
167
+ // system sheet covers the app during a read, so this line is the only
168
+ // narration an iOS user gets — updated per stage from TypeScript.
169
+ DispatchQueue.main.async {
170
+ self.session?.alertMessage = message
171
+ continuation.resume()
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ func transceive(apduBase64: String) throws -> Promise<EmrtdApduResponse> {
178
+ Promise.async {
179
+ guard let tag = self.tag else { throw EmrtdError.notConnected }
180
+ guard
181
+ let raw = Data(base64Encoded: apduBase64),
182
+ let apdu = NFCISO7816APDU(data: raw)
183
+ else {
184
+ throw EmrtdError.malformedApdu
185
+ }
186
+
187
+ let (data, sw1, sw2) = try await tag.sendCommand(apdu: apdu)
188
+ // A non-success status word RESOLVES rather than throwing: 0x6CXX and
189
+ // 0x63CF are answers the protocol layer has to interpret, not failures.
190
+ return EmrtdApduResponse(
191
+ data: data.base64EncodedString(),
192
+ statusWord: Double(Int(sw1) << 8 | Int(sw2))
193
+ )
194
+ }
195
+ }
196
+
197
+ private static func identifier(of tag: NFCISO7816Tag) -> String {
198
+ tag.identifier.base64EncodedString()
199
+ }
200
+
201
+ // MARK: - Primitives
202
+
203
+ func sha1(dataBase64: String) throws -> String {
204
+ let data = Self.decode(dataBase64)
205
+ // The length is captured BEFORE the buffer is borrowed: reading
206
+ // `data.count` inside `withUnsafeBytes` is an overlapping access.
207
+ let length = CC_LONG(data.count)
208
+ var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
209
+ data.withUnsafeBytes { buffer in
210
+ _ = CC_SHA1(buffer.baseAddress, length, &digest)
211
+ }
212
+ return Data(digest).base64EncodedString()
213
+ }
214
+
215
+ func sha256(dataBase64: String) throws -> String {
216
+ let data = Self.decode(dataBase64)
217
+ let length = CC_LONG(data.count)
218
+ var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
219
+ data.withUnsafeBytes { buffer in
220
+ _ = CC_SHA256(buffer.baseAddress, length, &digest)
221
+ }
222
+ return Data(digest).base64EncodedString()
223
+ }
224
+
225
+ func desEde2Cbc(
226
+ keyBase64: String, dataBase64: String, ivBase64: String, encrypt: Bool
227
+ ) throws -> String {
228
+ // Two-key 3DES is K1|K2|K1. CommonCrypto wants the full 24 bytes, so the
229
+ // expansion happens here rather than being assumed.
230
+ let key16 = Self.decode(keyBase64)
231
+ guard key16.count == 16 else { throw EmrtdError.badKeyLength }
232
+ var key24 = Data(key16)
233
+ key24.append(key16.prefix(8))
234
+
235
+ return try Self.crypt(
236
+ algorithm: CCAlgorithm(kCCAlgorithm3DES),
237
+ key: key24,
238
+ iv: Self.decode(ivBase64),
239
+ data: Self.decode(dataBase64),
240
+ blockSize: kCCBlockSize3DES,
241
+ encrypt: encrypt
242
+ )
243
+ }
244
+
245
+ func desBlock(keyBase64: String, blockBase64: String, encrypt: Bool) throws -> String {
246
+ // ECB on a single block — the retail MAC's inner primitive. The chaining is
247
+ // done in TypeScript, where the standard's construction is testable.
248
+ try Self.crypt(
249
+ algorithm: CCAlgorithm(kCCAlgorithmDES),
250
+ key: Self.decode(keyBase64),
251
+ iv: Data(),
252
+ data: Self.decode(blockBase64),
253
+ blockSize: kCCBlockSizeDES,
254
+ encrypt: encrypt,
255
+ options: CCOptions(kCCOptionECBMode)
256
+ )
257
+ }
258
+
259
+ func aesCbc(
260
+ keyBase64: String, dataBase64: String, ivBase64: String, encrypt: Bool
261
+ ) throws -> String {
262
+ try Self.crypt(
263
+ algorithm: CCAlgorithm(kCCAlgorithmAES),
264
+ key: Self.decode(keyBase64),
265
+ iv: Self.decode(ivBase64),
266
+ data: Self.decode(dataBase64),
267
+ blockSize: kCCBlockSizeAES128,
268
+ encrypt: encrypt
269
+ )
270
+ }
271
+
272
+ func aesCmac(keyBase64: String, dataBase64: String) throws -> String {
273
+ // CommonCrypto has no CMAC, so RFC 4493 is built on AES-ECB here. It is a
274
+ // short, exactly-specified construction; the alternative is a third-party
275
+ // dependency inside the passport path.
276
+ let key = Self.decode(keyBase64)
277
+ let message = Self.decode(dataBase64)
278
+ let block = 16
279
+
280
+ // Subkeys: encrypt a zero block, then shift-and-conditionally-XOR.
281
+ let zeros = Data(repeating: 0, count: block)
282
+ let l = Self.decode(
283
+ try Self.crypt(
284
+ algorithm: CCAlgorithm(kCCAlgorithmAES), key: key, iv: Data(), data: zeros,
285
+ blockSize: kCCBlockSizeAES128, encrypt: true, options: CCOptions(kCCOptionECBMode)
286
+ )
287
+ )
288
+ let k1 = Self.shiftLeftXor(l)
289
+ let k2 = Self.shiftLeftXor(k1)
290
+
291
+ // The final block is XORed with K1 when the message divides evenly, and
292
+ // padded then XORed with K2 when it does not.
293
+ var blocks = [Data]()
294
+ var i = 0
295
+ while i < message.count {
296
+ blocks.append(message.subdata(in: i..<min(i + block, message.count)))
297
+ i += block
298
+ }
299
+ if blocks.isEmpty { blocks = [Data()] }
300
+
301
+ var last = blocks.removeLast()
302
+ if last.count == block {
303
+ last = Self.xor(last, k1)
304
+ } else {
305
+ var padded = last
306
+ padded.append(0x80)
307
+ padded.append(Data(repeating: 0, count: block - padded.count))
308
+ last = Self.xor(padded, k2)
309
+ }
310
+
311
+ var chain = Data(repeating: 0, count: block)
312
+ for b in blocks + [last] {
313
+ let input = Self.xor(chain, b)
314
+ chain = Self.decode(
315
+ try Self.crypt(
316
+ algorithm: CCAlgorithm(kCCAlgorithmAES), key: key, iv: Data(), data: input,
317
+ blockSize: kCCBlockSizeAES128, encrypt: true, options: CCOptions(kCCOptionECBMode)
318
+ )
319
+ )
320
+ }
321
+ return chain.base64EncodedString()
322
+ }
323
+
324
+ func randomBytes(length: Double) throws -> String {
325
+ var bytes = [UInt8](repeating: 0, count: Int(length))
326
+ // SecRandomCopyBytes, not arc4random: the BAC nonce's unpredictability is
327
+ // what stops the exchange being replayable.
328
+ guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
329
+ throw EmrtdError.randomFailed
330
+ }
331
+ return Data(bytes).base64EncodedString()
332
+ }
333
+
334
+ // MARK: - Helpers
335
+
336
+ private static func decode(_ b64: String) -> Data {
337
+ Data(base64Encoded: b64) ?? Data()
338
+ }
339
+
340
+ private static func xor(_ a: Data, _ b: Data) -> Data {
341
+ Data(zip(a, b).map { $0 ^ $1 })
342
+ }
343
+
344
+ /// One-bit left shift, XORed with the RFC 4493 constant on overflow.
345
+ private static func shiftLeftXor(_ input: Data) -> Data {
346
+ var out = [UInt8](repeating: 0, count: input.count)
347
+ var carry: UInt8 = 0
348
+ for i in stride(from: input.count - 1, through: 0, by: -1) {
349
+ let byte = input[i]
350
+ out[i] = (byte << 1) | carry
351
+ carry = (byte & 0x80) != 0 ? 1 : 0
352
+ }
353
+ if (input.first! & 0x80) != 0 { out[out.count - 1] ^= 0x87 }
354
+ return Data(out)
355
+ }
356
+
357
+ private static func crypt(
358
+ algorithm: CCAlgorithm,
359
+ key: Data,
360
+ iv: Data,
361
+ data: Data,
362
+ blockSize: Int,
363
+ encrypt: Bool,
364
+ options: CCOptions = 0
365
+ ) throws -> String {
366
+ // NO padding option: the protocol applies ISO 9797-1 method 2 itself, and
367
+ // letting CommonCrypto add PKCS#7 on top would corrupt every message.
368
+ var out = Data(count: data.count + blockSize)
369
+ // Captured BEFORE the buffer is exclusively borrowed below — reading
370
+ // `out.count` inside `withUnsafeMutableBytes` is an overlapping access.
371
+ let capacity = out.count
372
+ let keyLength = key.count
373
+ let dataLength = data.count
374
+ let hasIv = !iv.isEmpty
375
+ var moved = 0
376
+ let status = out.withUnsafeMutableBytes { outPtr in
377
+ data.withUnsafeBytes { dataPtr in
378
+ key.withUnsafeBytes { keyPtr in
379
+ iv.withUnsafeBytes { ivPtr in
380
+ CCCrypt(
381
+ CCOperation(encrypt ? kCCEncrypt : kCCDecrypt),
382
+ algorithm,
383
+ options,
384
+ keyPtr.baseAddress, keyLength,
385
+ hasIv ? ivPtr.baseAddress : nil,
386
+ dataPtr.baseAddress, dataLength,
387
+ outPtr.baseAddress, capacity,
388
+ &moved
389
+ )
390
+ }
391
+ }
392
+ }
393
+ }
394
+ guard status == kCCSuccess else { throw EmrtdError.cryptoFailed(Int(status)) }
395
+ return out.prefix(moved).base64EncodedString()
396
+ }
397
+ }
398
+
399
+ // MARK: - Session delegate
400
+
401
+ private final class SessionDelegate: NSObject, NFCTagReaderSessionDelegate {
402
+ private let onResult: (Result<NFCISO7816Tag, Error>) -> Void
403
+ private var settled = false
404
+
405
+ init(onResult: @escaping (Result<NFCISO7816Tag, Error>) -> Void) {
406
+ self.onResult = onResult
407
+ }
408
+
409
+ /// The continuation may be resumed exactly once; CoreNFC can deliver both an
410
+ /// invalidation and a detection in edge cases, and resuming twice traps.
411
+ private func settle(_ result: Result<NFCISO7816Tag, Error>) {
412
+ guard !settled else { return }
413
+ settled = true
414
+ onResult(result)
415
+ }
416
+
417
+ func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
418
+
419
+ func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
420
+ settle(.failure(error))
421
+ }
422
+
423
+ func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
424
+ guard let detected = tags.first, case let .iso7816(tag) = detected else {
425
+ session.invalidate(errorMessage: "That is not a passport chip.")
426
+ settle(.failure(EmrtdError.wrongTagType))
427
+ return
428
+ }
429
+ session.connect(to: detected) { [weak self] error in
430
+ if let error {
431
+ self?.settle(.failure(error))
432
+ } else {
433
+ self?.settle(.success(tag))
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ enum EmrtdError: Error {
440
+ case unavailable
441
+ case notConnected
442
+ case malformedApdu
443
+ case wrongTagType
444
+ case badKeyLength
445
+ case randomFailed
446
+ case cryptoFailed(Int)
447
+ }
@@ -90,6 +90,20 @@ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
90
90
  let rightOpen = Self.openProbability(fromEAR: Self.eyeAspectRatio(face.landmarks?.rightEye))
91
91
  let smile = Self.smileProbability(face.landmarks)
92
92
  let faceSizeRatio = Double(face.boundingBox.width)
93
+ // Face centre, so the TS continuity guard can tell "the same face moved"
94
+ // from "a different face appeared" — size alone cannot, since two people at
95
+ // the same distance measure alike.
96
+ let centerX = Double(face.boundingBox.midX)
97
+ let centerY = Double(face.boundingBox.midY)
98
+
99
+ // Mean RGB of the face region, for flash (screen-reflection) liveness. Taken
100
+ // here because the buffer is already mapped — a second pass would cost a
101
+ // whole frame walk for numbers available now. Sampled over the CENTRE of
102
+ // the frame rather than Vision's bounding box: the box is in a rotated,
103
+ // mirrored coordinate space, and the positioning gate has already put the
104
+ // face in the middle, so a fixed centre crop is both simpler and steadier
105
+ // than a box that jitters frame to frame.
106
+ let faceRgb = Self.averageFaceRgb(pixelBuffer)
93
107
 
94
108
  return FaceResult(
95
109
  headEulerAngleX: pitchDeg,
@@ -101,7 +115,15 @@ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
101
115
  faceSizeRatio: faceSizeRatio,
102
116
  // Number of faces in frame — the TS liveness flow pauses on > 1.
103
117
  faceCount: Double(faces.count),
104
- brightness: brightness
118
+ brightness: brightness,
119
+ faceCenterX: centerX,
120
+ faceCenterY: centerY,
121
+ // Vision has no cross-frame face identifier, so iOS never supplies one;
122
+ // the geometric guard carries the check on this platform.
123
+ trackingId: -1,
124
+ faceR: faceRgb.0,
125
+ faceG: faceRgb.1,
126
+ faceB: faceRgb.2
105
127
  )
106
128
  }
107
129
 
@@ -111,10 +133,90 @@ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
111
133
  FaceResult(
112
134
  headEulerAngleX: 0, headEulerAngleY: 0, headEulerAngleZ: 0,
113
135
  smilingProbability: 0, leftEyeOpenProbability: 1, rightEyeOpenProbability: 1,
114
- faceSizeRatio: 0, faceCount: 0, brightness: brightness
136
+ faceSizeRatio: 0, faceCount: 0, brightness: brightness,
137
+ faceCenterX: -1, faceCenterY: -1, trackingId: -1,
138
+ faceR: -1, faceG: -1, faceB: -1
115
139
  )
116
140
  }
117
141
 
142
+ /// Mean RGB (0–255 each) of the centre crop, where the positioning gate has
143
+ /// already placed the face. Used by flash liveness to measure how the face
144
+ /// reflects each emitted colour.
145
+ ///
146
+ /// The crop matches the web SDK's (centre 40% × 50%) so all three platforms —
147
+ /// and the server's re-analysis of the recorded video — measure the same
148
+ /// patch of skin.
149
+ private static func averageFaceRgb(_ pixelBuffer: CVPixelBuffer) -> (Double, Double, Double) {
150
+ CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
151
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
152
+
153
+ let format = CVPixelBufferGetPixelFormatType(pixelBuffer)
154
+ let w = CVPixelBufferGetWidth(pixelBuffer)
155
+ let h = CVPixelBufferGetHeight(pixelBuffer)
156
+ guard w > 0, h > 0 else { return (-1, -1, -1) }
157
+
158
+ let x0 = Int(Double(w) * 0.3), x1 = Int(Double(w) * 0.7)
159
+ let y0 = Int(Double(h) * 0.25), y1 = Int(Double(h) * 0.75)
160
+ let stepX = max(1, (x1 - x0) / 32)
161
+ let stepY = max(1, (y1 - y0) / 32)
162
+
163
+ var rs = 0.0, gs = 0.0, bs = 0.0, count = 0.0
164
+
165
+ if CVPixelBufferGetPlaneCount(pixelBuffer) >= 2,
166
+ let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
167
+ let cBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) {
168
+ // 4:2:0 bi-planar: luma full-res, chroma quarter-res and interleaved.
169
+ let yRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
170
+ let cRow = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
171
+ let yPtr = yBase.assumingMemoryBound(to: UInt8.self)
172
+ let cPtr = cBase.assumingMemoryBound(to: UInt8.self)
173
+ // The chroma order differs between the two 4:2:0 formats; reading it the
174
+ // wrong way round swaps red and blue, which would invert every flash
175
+ // verdict rather than merely degrade it.
176
+ let crFirst = format == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
177
+ || format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
178
+ var y = y0
179
+ while y < y1 {
180
+ var x = x0
181
+ while x < x1 {
182
+ let luma = Double(yPtr[y * yRow + x])
183
+ let cIdx = (y / 2) * cRow + (x / 2) * 2
184
+ let c0 = Double(cPtr[cIdx]) - 128.0
185
+ let c1 = Double(cPtr[cIdx + 1]) - 128.0
186
+ let cb = crFirst ? c0 : c1
187
+ let cr = crFirst ? c1 : c0
188
+ rs += luma + 1.402 * cr
189
+ gs += luma - 0.344136 * cb - 0.714136 * cr
190
+ bs += luma + 1.772 * cb
191
+ count += 1
192
+ x += stepX
193
+ }
194
+ y += stepY
195
+ }
196
+ } else if let base = CVPixelBufferGetBaseAddress(pixelBuffer) {
197
+ // Non-planar BGRA.
198
+ let rowBytes = CVPixelBufferGetBytesPerRow(pixelBuffer)
199
+ let ptr = base.assumingMemoryBound(to: UInt8.self)
200
+ var y = y0
201
+ while y < y1 {
202
+ let row = ptr + y * rowBytes
203
+ var x = x0
204
+ while x < x1 {
205
+ bs += Double(row[x * 4])
206
+ gs += Double(row[x * 4 + 1])
207
+ rs += Double(row[x * 4 + 2])
208
+ count += 1
209
+ x += stepX
210
+ }
211
+ y += stepY
212
+ }
213
+ }
214
+
215
+ guard count > 0 else { return (-1, -1, -1) }
216
+ let clamp = { (v: Double) in min(255.0, max(0.0, v / count)) }
217
+ return (clamp(rs), clamp(gs), clamp(bs))
218
+ }
219
+
118
220
  /// Mean luma (0–255) of the frame, sampled on a coarse ~64×48 grid for speed.
119
221
  /// For a YUV (4:2:0) buffer the first plane IS luma; for non-planar buffers we
120
222
  /// approximate from the green channel. Mirrors the Flutter `_BrightnessSampler`.
@@ -0,0 +1,103 @@
1
+ import Foundation
2
+ import Vision
3
+ import CoreMedia
4
+ import CoreVideo
5
+ import VisionCamera
6
+ import NitroModules
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // HybridMyazaRectDetector — document EDGE detection on iOS, implementing the
10
+ // nitrogen-generated `HybridMyazaRectDetectorSpec` from
11
+ // src/specs/MyazaRectDetector.nitro.ts.
12
+ //
13
+ // Apple Vision's `VNDetectRectanglesRequest` answers ONE question: is there
14
+ // something document-shaped in frame, and where. It cannot answer which
15
+ // document it is — an aspect ratio cannot tell a passport from a driver's
16
+ // licence — so auto-capture pairs this with text recognition and requires both
17
+ // to agree. On its own it would happily shoot a book, a receipt or a laptop.
18
+ //
19
+ // There is no Android counterpart: ML Kit has no frame-by-frame rectangle
20
+ // detector, so Android's auto-capture rides the text gate alone. That is why
21
+ // the text gate answers framing by itself on both platforms, and this is an
22
+ // accelerator on top rather than a dependency.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ final class HybridMyazaRectDetector: HybridMyazaRectDetectorSpec {
26
+
27
+ /// Reused across frames: building the request per frame is measurable at 30fps.
28
+ private let request: VNDetectRectanglesRequest = {
29
+ let r = VNDetectRectanglesRequest()
30
+ // An ID-1 card is ~1.586:1 and a passport page ~1.42:1; the window is opened
31
+ // wide enough to admit both at an angle, since perspective foreshortens the
32
+ // observed ratio. The TEXT gate is what rejects the wrong document — this
33
+ // only has to find a candidate.
34
+ r.minimumAspectRatio = 0.4
35
+ r.maximumAspectRatio = 1.0
36
+ // A document held up to the camera fills a good share of the frame. Below
37
+ // this it is too far away for OCR to read it anyway.
38
+ r.minimumSize = 0.2
39
+ // Near-square corners. A generous tolerance because the document is rarely
40
+ // exactly parallel to the sensor.
41
+ r.quadratureTolerance = 30
42
+ r.minimumConfidence = 0.6
43
+ // One candidate: the caller wants the document, not every rectangle in the
44
+ // room, and ranking happens below by area.
45
+ r.maximumObservations = 1
46
+ return r
47
+ }()
48
+
49
+ private static let notFound = DetectedRect(
50
+ found: false, x: 0, y: 0, width: 0, height: 0, aspect: 0, confidence: 0
51
+ )
52
+
53
+ func detectRect(frame: any HybridFrameSpec) throws -> DetectedRect {
54
+ guard
55
+ let nativeFrame = frame as? NativeFrame,
56
+ let sampleBuffer = nativeFrame.sampleBuffer,
57
+ let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
58
+ else {
59
+ return Self.notFound
60
+ }
61
+
62
+ // `.right` matches the text recogniser: the camera delivers the buffer
63
+ // rotated 90° relative to how the document is held, and measuring a
64
+ // sideways frame yields a plausible box with the aspect inverted.
65
+ let handler = VNImageRequestHandler(
66
+ cvPixelBuffer: pixelBuffer, orientation: .right, options: [:]
67
+ )
68
+
69
+ do {
70
+ try handler.perform([request])
71
+ } catch {
72
+ // A detector that fails on one frame must not take the scan down; the
73
+ // next frame gets its own attempt.
74
+ return Self.notFound
75
+ }
76
+
77
+ guard
78
+ let observation = (request.results as? [VNRectangleObservation])?
79
+ .max(by: { $0.boundingBox.width * $0.boundingBox.height
80
+ < $1.boundingBox.width * $1.boundingBox.height })
81
+ else {
82
+ return Self.notFound
83
+ }
84
+
85
+ let box = observation.boundingBox
86
+ guard box.height > 0 else { return Self.notFound }
87
+
88
+ // Vision reports a BOTTOM-LEFT origin; the gate's thresholds are written
89
+ // for a top-left origin like every other coordinate in the SDK, so flip y
90
+ // here rather than making every caller remember which convention it got.
91
+ let y = 1.0 - box.origin.y - box.height
92
+
93
+ return DetectedRect(
94
+ found: true,
95
+ x: Double(box.origin.x),
96
+ y: Double(y),
97
+ width: Double(box.width),
98
+ height: Double(box.height),
99
+ aspect: Double(box.width / box.height),
100
+ confidence: Double(observation.confidence)
101
+ )
102
+ }
103
+ }