@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.
- package/README.md +152 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +70 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +96 -1
- package/src/config/keyPeople.ts +162 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +322 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +156 -132
- package/src/screens/IdInputStep.tsx +13 -5
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/KeyPersonForm.tsx +148 -0
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/LivenessStep.tsx +155 -247
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +2 -2
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -124
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- 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
|
+
}
|