@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,173 @@
|
|
|
1
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
Camera,
|
|
5
|
+
useCameraDevice,
|
|
6
|
+
useCameraPermission,
|
|
7
|
+
useFrameOutput,
|
|
8
|
+
} from 'react-native-vision-camera';
|
|
9
|
+
import { runOnJS } from 'react-native-worklets';
|
|
10
|
+
|
|
11
|
+
import { radius, spacing } from '../config/theme';
|
|
12
|
+
import { useTheme } from '../components/runtime';
|
|
13
|
+
import { MyazaText } from '../components/Typography';
|
|
14
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
15
|
+
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
16
|
+
import { extractMrz } from '../mrz/extract';
|
|
17
|
+
import { recognizeMrzLines, MRZ_BAND_FRACTION } from '../mrz/textRecognizer';
|
|
18
|
+
import type { MrzScan } from '../mrz/parse';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Live MRZ scan — the camera fallback that unlocks the chip.
|
|
22
|
+
//
|
|
23
|
+
// The MRZ printed on the photo page IS the chip's access key (document number,
|
|
24
|
+
// date of birth, expiry). The document step normally lifts it off the photo the
|
|
25
|
+
// user already took; when that read fails there was previously nowhere to go,
|
|
26
|
+
// and the chip simply could not be opened. This is that second chance, and it
|
|
27
|
+
// is the same capability the Flutter SDK offers.
|
|
28
|
+
//
|
|
29
|
+
// Frames are recognised one at a time — `dropFramesWhileBusy` skips whatever
|
|
30
|
+
// arrives mid-recognition rather than queueing it. OCR is far slower than the
|
|
31
|
+
// camera, so a queue would only serve progressively staler frames.
|
|
32
|
+
//
|
|
33
|
+
// A frame that does not yield a CHECK-DIGIT-VALID MRZ is discarded silently and
|
|
34
|
+
// the next one tried. That is what makes continuous scanning feel instant while
|
|
35
|
+
// still refusing a misread: the check digits are the whole reason this can be
|
|
36
|
+
// trusted to run unattended, since a wrong key just makes the chip refuse.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function MrzScanView({
|
|
40
|
+
onScanned,
|
|
41
|
+
}: {
|
|
42
|
+
onScanned: (scan: MrzScan) => void;
|
|
43
|
+
}): React.ReactElement {
|
|
44
|
+
const { colors } = useTheme();
|
|
45
|
+
const device = useCameraDevice('back');
|
|
46
|
+
const { hasPermission, requestPermission } = useCameraPermission();
|
|
47
|
+
const [denied, setDenied] = useState(false);
|
|
48
|
+
|
|
49
|
+
// A ref, not state: the worklet callback lands on the JS thread after the
|
|
50
|
+
// frame that found the MRZ, and a stale render would let a second frame
|
|
51
|
+
// report the same document again.
|
|
52
|
+
const doneRef = useRef(false);
|
|
53
|
+
|
|
54
|
+
const handleLines = useCallback(
|
|
55
|
+
(lines: string[]) => {
|
|
56
|
+
if (doneRef.current || lines.length === 0) return;
|
|
57
|
+
const scan = extractMrz(lines);
|
|
58
|
+
if (!scan) return; // partial or misread — keep looking
|
|
59
|
+
doneRef.current = true;
|
|
60
|
+
onScanned(scan);
|
|
61
|
+
},
|
|
62
|
+
[onScanned],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const frameOutput = useFrameOutput({
|
|
66
|
+
// Both recognisers (Apple Vision, ML Kit) consume YUV directly, so this
|
|
67
|
+
// avoids a conversion per frame.
|
|
68
|
+
pixelFormat: 'yuv',
|
|
69
|
+
dropFramesWhileBusy: true,
|
|
70
|
+
onFrame: (frame) => {
|
|
71
|
+
'worklet';
|
|
72
|
+
const lines = recognizeMrzLines(frame);
|
|
73
|
+
frame.dispose();
|
|
74
|
+
runOnJS(handleLines)(lines);
|
|
75
|
+
},
|
|
76
|
+
// Drops are the normal state of affairs here, not a fault worth logging.
|
|
77
|
+
onFrameDropped: () => {
|
|
78
|
+
'worklet';
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const askForCamera = useCallback(() => {
|
|
83
|
+
void requestPermission().then((granted) => {
|
|
84
|
+
if (!granted) setDenied(true);
|
|
85
|
+
});
|
|
86
|
+
}, [requestPermission]);
|
|
87
|
+
|
|
88
|
+
if (!hasPermission) {
|
|
89
|
+
return (
|
|
90
|
+
<Centered>
|
|
91
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
92
|
+
{denied
|
|
93
|
+
? 'Camera access is off, so the printed code cannot be read. You can still continue without the chip.'
|
|
94
|
+
: 'We need the camera to read the printed code on your document.'}
|
|
95
|
+
</MyazaText>
|
|
96
|
+
{denied ? null : (
|
|
97
|
+
<>
|
|
98
|
+
<View style={{ height: spacing.md }} />
|
|
99
|
+
<MyazaButton label="Allow camera" onPress={askForCamera} />
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
102
|
+
</Centered>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (device == null) {
|
|
107
|
+
return (
|
|
108
|
+
<Centered>
|
|
109
|
+
<MyazaText variant="bodyMedium" color={colors.error} style={{ textAlign: 'center' }}>
|
|
110
|
+
No camera is available on this device.
|
|
111
|
+
</MyazaText>
|
|
112
|
+
</Centered>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<View
|
|
118
|
+
style={{
|
|
119
|
+
// 1.42:1 — a passport photo page is wider than it is tall, and framing
|
|
120
|
+
// the preview to the document is what stops users holding it too far
|
|
121
|
+
// away for the MRZ glyphs to resolve.
|
|
122
|
+
aspectRatio: 1.42,
|
|
123
|
+
borderRadius: radius.md,
|
|
124
|
+
overflow: 'hidden',
|
|
125
|
+
backgroundColor: colors.textDark,
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<Camera
|
|
129
|
+
style={{ flex: 1 }}
|
|
130
|
+
device={device}
|
|
131
|
+
isActive
|
|
132
|
+
outputs={[frameOutput]}
|
|
133
|
+
/>
|
|
134
|
+
|
|
135
|
+
{/* The band the recogniser actually reads, drawn so the user aims the
|
|
136
|
+
strip into it rather than guessing. Kept in lockstep with
|
|
137
|
+
MRZ_BAND_FRACTION — a guide that disagrees with the crop would send
|
|
138
|
+
people to aim at the wrong part of the page. */}
|
|
139
|
+
<View
|
|
140
|
+
pointerEvents="none"
|
|
141
|
+
style={{
|
|
142
|
+
position: 'absolute',
|
|
143
|
+
left: 0,
|
|
144
|
+
right: 0,
|
|
145
|
+
bottom: 0,
|
|
146
|
+
height: `${MRZ_BAND_FRACTION * 100}%`,
|
|
147
|
+
borderTopWidth: 2,
|
|
148
|
+
borderColor: `${colors.primary}CC`,
|
|
149
|
+
backgroundColor: `${colors.primary}14`,
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
<View
|
|
154
|
+
pointerEvents="none"
|
|
155
|
+
style={{
|
|
156
|
+
position: 'absolute',
|
|
157
|
+
left: 0,
|
|
158
|
+
right: 0,
|
|
159
|
+
bottom: spacing.sm,
|
|
160
|
+
alignItems: 'center',
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<MyazaPulseLoader size={20} />
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function Centered({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
170
|
+
return (
|
|
171
|
+
<View style={{ paddingVertical: spacing.xl, alignItems: 'center' }}>{children}</View>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../components/MyazaButton';
|
|
8
|
+
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
9
|
+
import { Icon } from '../components/Icon';
|
|
10
|
+
import { cancelChipRead, nfcUnavailableReason, readPassportChip, type EmrtdReadResult } from '../emrtd';
|
|
11
|
+
import { useNfcAvailability } from './nfc/useNfcAvailability';
|
|
12
|
+
import { readErrorMessage } from './nfc/readErrorMessage';
|
|
13
|
+
import { successHaptic } from '../services/haptics';
|
|
14
|
+
import { NfcSuccessPanel } from './nfc/NfcSuccessPanel';
|
|
15
|
+
import { NfcMrzPrompt } from './nfc/NfcMrzPrompt';
|
|
16
|
+
import { NfcReadBody } from './nfc/NfcReadBody';
|
|
17
|
+
import type { NfcReadStage } from '../emrtd';
|
|
18
|
+
import type { MrzScan } from '../mrz/parse';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Reading the passport's chip.
|
|
22
|
+
//
|
|
23
|
+
// The chip holds the same biodata as the printed page, but SIGNED by the
|
|
24
|
+
// issuing government — which is what makes it worth reading. The MRZ scanned
|
|
25
|
+
// from the photo page is the key that unlocks it: possession of the document is
|
|
26
|
+
// the access control.
|
|
27
|
+
//
|
|
28
|
+
// Everything here is BEST-EFFORT. A phone without NFC, a chip that will not
|
|
29
|
+
// read, a user who gives up — all of them continue the flow. Absence of a chip
|
|
30
|
+
// read is not evidence of anything; the document checks that already ran still
|
|
31
|
+
// stand, and the server treats a missing chip as a missing signal rather than a
|
|
32
|
+
// failure. The ONE exception lives on the server: a chip that positively fails
|
|
33
|
+
// its cryptographic checks fails the verification, because that is proof of
|
|
34
|
+
// tampering rather than absence of proof.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export const nfcMeta = {
|
|
38
|
+
title: 'Scan Document Chip',
|
|
39
|
+
description: 'Hold your document to the back of your phone.',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Phase = 'idle' | 'reading' | 'done' | 'failed';
|
|
43
|
+
|
|
44
|
+
// Attempt-first skip: the escape hatch is EARNED, never ambient. It appears
|
|
45
|
+
// after the first failed read — or after this long on the step, which covers
|
|
46
|
+
// the reads that never fail but never end either (Android reader mode sits
|
|
47
|
+
// listening for a tag the user can't position) and an MRZ hunt that won't lock
|
|
48
|
+
// on. Showing it on arrival invites "don't feel like it" exits from users
|
|
49
|
+
// whose chip would have read fine; hiding it forever traps the ones whose chip
|
|
50
|
+
// never will. Once revealed it stays.
|
|
51
|
+
const SKIP_REVEAL_MS = 15_000;
|
|
52
|
+
|
|
53
|
+
export function NfcStep(): React.ReactElement {
|
|
54
|
+
const config = useKycConfig();
|
|
55
|
+
const store = useKycStore();
|
|
56
|
+
const { colors } = useTheme();
|
|
57
|
+
const mrz = useKyc((s) => s.mrzScan);
|
|
58
|
+
|
|
59
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
60
|
+
const [error, setError] = useState<string | null>(null);
|
|
61
|
+
// Dev builds also show WHERE the read died and the raw platform error — the
|
|
62
|
+
// friendly message deliberately hides both, which made field reports
|
|
63
|
+
// ("the connection dropped") impossible to tell apart: a session that never
|
|
64
|
+
// opened, a BAC refusal at first contact, and a mid-transfer drop all
|
|
65
|
+
// rendered identically.
|
|
66
|
+
const [result, setResult] = useState<EmrtdReadResult | null>(null);
|
|
67
|
+
const [stage, setStage] = useState<NfcReadStage>('waiting');
|
|
68
|
+
const stageRef = useRef<NfcReadStage>('waiting');
|
|
69
|
+
const liveRef = useRef(true);
|
|
70
|
+
// Tracked separately from `phase` because the cleanup below runs after the
|
|
71
|
+
// last render and would otherwise close over a stale value.
|
|
72
|
+
const readingRef = useRef(false);
|
|
73
|
+
useEffect(() => () => {
|
|
74
|
+
liveRef.current = false;
|
|
75
|
+
// Leaving this step must close the session UNCONDITIONALLY. Opening one
|
|
76
|
+
// BLOCKS until a document is presented — up to a minute on Android — so a
|
|
77
|
+
// user who backs out during that wait would otherwise strand reader mode
|
|
78
|
+
// on an activity the flow has left, and hold iOS's system sheet over the
|
|
79
|
+
// next screen. It used to be gated on `readingRef`, which is cleared in
|
|
80
|
+
// the read's `finally` — so after a FAILED read, leaving performed no
|
|
81
|
+
// cleanup at all and reader mode outlived the screen for the rest of the
|
|
82
|
+
// process. cancelChipRead is idempotent; calling it with nothing open is
|
|
83
|
+
// free.
|
|
84
|
+
void cancelChipRead();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Settled, not sampled: a false on the first render can just mean the native
|
|
88
|
+
// module has not landed yet, and acting on it deletes this step entirely.
|
|
89
|
+
const availability = useNfcAvailability();
|
|
90
|
+
const advance = useCallback(() => store.getState().nextStep(), [store]);
|
|
91
|
+
|
|
92
|
+
// Attempt-first skip reveal (see SKIP_REVEAL_MS above). A failed read also
|
|
93
|
+
// reveals it immediately, in the read's catch.
|
|
94
|
+
const allowSkip = config.nfc?.allowSkip === true;
|
|
95
|
+
const [skipRevealed, setSkipRevealed] = useState(false);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!allowSkip || skipRevealed) return;
|
|
98
|
+
const timer = setTimeout(() => setSkipRevealed(true), SKIP_REVEAL_MS);
|
|
99
|
+
return () => clearTimeout(timer);
|
|
100
|
+
}, [allowSkip, skipRevealed]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
// NOTHING auto-skips this step any more.
|
|
104
|
+
//
|
|
105
|
+
// `readingAvailable` was the gate, and it has been wrong every time we could
|
|
106
|
+
// check it against reality — reporting unsupported on a phone that had read
|
|
107
|
+
// chips minutes earlier, across app restarts, a foreground transition and a
|
|
108
|
+
// reinstall. Two plausible causes (the Nitro registry not yet populated,
|
|
109
|
+
// then iOS answering mid-foreground-transition) each explained one
|
|
110
|
+
// occurrence and not the next, so the real one is still unidentified.
|
|
111
|
+
//
|
|
112
|
+
// Gating on a signal we cannot trust is worse than not gating at all, and
|
|
113
|
+
// the costs are wildly asymmetric: a wrong skip silently removes a
|
|
114
|
+
// verification factor and tells nobody, while a wrong render costs one tap
|
|
115
|
+
// on a device with no NFC — where the user still has the skip affordance and
|
|
116
|
+
// the back button.
|
|
117
|
+
//
|
|
118
|
+
// So the step renders regardless and the READ decides. If NFC genuinely is
|
|
119
|
+
// unavailable, iOS says so when the session opens: an honest answer, and a
|
|
120
|
+
// far better diagnostic than a screen that never appeared.
|
|
121
|
+
if (__DEV__ && availability === 'unsupported') {
|
|
122
|
+
console.log('[myaza] NFC reported unavailable, showing the step anyway:', nfcUnavailableReason() ?? 'unknown');
|
|
123
|
+
}
|
|
124
|
+
}, [availability]);
|
|
125
|
+
|
|
126
|
+
// A read that has been superseded (a newer read started, or the step was
|
|
127
|
+
// left) still settles eventually — the retry loop sleeps between attempts —
|
|
128
|
+
// and its late rejection must not clobber the state of whatever replaced it.
|
|
129
|
+
const readSeqRef = useRef(0);
|
|
130
|
+
const read = useCallback(async () => {
|
|
131
|
+
if (!mrz) return;
|
|
132
|
+
const seq = ++readSeqRef.current;
|
|
133
|
+
setPhase('reading');
|
|
134
|
+
setError(null);
|
|
135
|
+
setStage('waiting');
|
|
136
|
+
stageRef.current = 'waiting';
|
|
137
|
+
readingRef.current = true;
|
|
138
|
+
try {
|
|
139
|
+
const result = await readPassportChip(
|
|
140
|
+
{
|
|
141
|
+
documentNumber: mrz.documentNumber,
|
|
142
|
+
dateOfBirth: toMrzDate(mrz.dateOfBirth),
|
|
143
|
+
dateOfExpiry: toMrzDate(mrz.dateOfExpiry),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
onStage: (s) => {
|
|
147
|
+
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
148
|
+
stageRef.current = s;
|
|
149
|
+
setStage(s);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
154
|
+
store.getState().setChipData(result);
|
|
155
|
+
setResult(result);
|
|
156
|
+
setPhase('done');
|
|
157
|
+
// A chip read has no shutter, no sound, nothing physical — the haptic is
|
|
158
|
+
// what makes it land as a completed action. Fire-and-forget: hardware
|
|
159
|
+
// without one simply does nothing.
|
|
160
|
+
successHaptic();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!liveRef.current || seq !== readSeqRef.current) return;
|
|
163
|
+
setPhase('failed');
|
|
164
|
+
// A real attempt just failed — the skip is earned now, no need to make
|
|
165
|
+
// the user wait out the reveal timer on top of a failure.
|
|
166
|
+
setSkipRevealed(true);
|
|
167
|
+
// Never the platform's own string — see readErrorMessage. The stage
|
|
168
|
+
// disambiguates a reader that never started from a chip that dropped.
|
|
169
|
+
setError(readErrorMessage(err, stageRef.current));
|
|
170
|
+
// Console ONLY — a stack trace on a KYC screen reads as a crash to the
|
|
171
|
+
// person holding the passport, and dev builds are exactly where real
|
|
172
|
+
// documents get tested. The friendly message above is the UI.
|
|
173
|
+
if (__DEV__) {
|
|
174
|
+
const raw = err instanceof Error ? err.message : String(err ?? '');
|
|
175
|
+
console.warn(`[kyc.nfc] read died at '${stageRef.current}' — ${raw}`);
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
if (seq === readSeqRef.current) readingRef.current = false;
|
|
179
|
+
}
|
|
180
|
+
}, [mrz, store, advance]);
|
|
181
|
+
|
|
182
|
+
// Start the read as soon as the step opens.
|
|
183
|
+
//
|
|
184
|
+
// The MRZ was already lifted off the photo page during document capture, so
|
|
185
|
+
// everything needed to unlock the chip is in hand — asking the user to press
|
|
186
|
+
// a button first adds a step that answers no question. Flutter goes straight
|
|
187
|
+
// to the read for the same reason, and a user who has just been told to hold
|
|
188
|
+
// the document to the phone expects it to be listening.
|
|
189
|
+
//
|
|
190
|
+
// Once per arrival: `startedRef` is cleared only when the step is left, so a
|
|
191
|
+
// failure shows its own Try again rather than being restarted underneath the
|
|
192
|
+
// user.
|
|
193
|
+
const startedRef = useRef(false);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
// Attempt the read on any settled verdict, not only a positive one — the
|
|
196
|
+
// availability check is no longer trusted to veto (see above). `checking`
|
|
197
|
+
// still waits, so a session is never opened before the module has resolved.
|
|
198
|
+
if (availability === 'checking' || !mrz) return;
|
|
199
|
+
if (startedRef.current) return;
|
|
200
|
+
startedRef.current = true;
|
|
201
|
+
void read();
|
|
202
|
+
}, [availability, mrz, read]);
|
|
203
|
+
|
|
204
|
+
// The success panel lives in its own file (200-line rule) — it is the only
|
|
205
|
+
// part of this step with real layout.
|
|
206
|
+
if (phase === 'done' && result) {
|
|
207
|
+
return (
|
|
208
|
+
<NfcSuccessPanel
|
|
209
|
+
result={result}
|
|
210
|
+
mrz={mrz}
|
|
211
|
+
onContinue={advance}
|
|
212
|
+
onRescan={() => {
|
|
213
|
+
// Straight back into a read — there is no idle "Scan chip" button any
|
|
214
|
+
// more (the step reads on arrival, like Flutter), so parking on idle
|
|
215
|
+
// would dead-end.
|
|
216
|
+
setResult(null);
|
|
217
|
+
void read();
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
// The live MRZ scan lives in its own file (200-line rule).
|
|
225
|
+
if (!mrz) {
|
|
226
|
+
return (
|
|
227
|
+
<NfcMrzPrompt
|
|
228
|
+
onScanned={(scan) => store.getState().setMrzScan(scan)}
|
|
229
|
+
onSkip={allowSkip && skipRevealed ? advance : null}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<View style={{ alignItems: 'center' }}>
|
|
236
|
+
<NfcReadBody reading={phase === 'reading'} stage={stage} scan={mrz} />
|
|
237
|
+
|
|
238
|
+
{error ? (
|
|
239
|
+
<>
|
|
240
|
+
<View style={{ height: spacing.md }} />
|
|
241
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ textAlign: 'center' }}>
|
|
242
|
+
{error}
|
|
243
|
+
</MyazaText>
|
|
244
|
+
</>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
<View style={{ height: spacing.lg }} />
|
|
248
|
+
<View style={{ alignSelf: 'stretch' }}>
|
|
249
|
+
{/* No "Scan chip" button: the read starts on arrival (like Flutter),
|
|
250
|
+
so a button naming an action already underway only invites a tap
|
|
251
|
+
that restarts it. The retry appears only once there is something to
|
|
252
|
+
retry. */}
|
|
253
|
+
{phase === 'failed' ? (
|
|
254
|
+
<MyazaButton label="Try scanning the chip again" onPress={() => void read()} />
|
|
255
|
+
) : null}
|
|
256
|
+
{/* Attempt-first: hidden until a failed read (or the reveal timer)
|
|
257
|
+
earns it — see SKIP_REVEAL_MS. Opt-IN, matching Flutter
|
|
258
|
+
(`allowSkip: json['allowSkip'] ?? false`): an absent flag shows no
|
|
259
|
+
skip, because that is what the server sends unless an org turns it
|
|
260
|
+
on. Once revealed it stays tappable even mid-read while the reader
|
|
261
|
+
is still WAITING for a tag — that wait can be endless on Android,
|
|
262
|
+
and leaving cancels the session (the unmount cleanup) — but not
|
|
263
|
+
once a chip is actually transferring, where a tap would rip up a
|
|
264
|
+
read that is about to succeed. */}
|
|
265
|
+
{allowSkip && skipRevealed ? (
|
|
266
|
+
<>
|
|
267
|
+
{phase === 'failed' ? <View style={{ height: spacing.sm }} /> : null}
|
|
268
|
+
<MyazaButton
|
|
269
|
+
label="Continue without the chip"
|
|
270
|
+
variant="ghost"
|
|
271
|
+
disabled={phase === 'reading' && stage !== 'waiting'}
|
|
272
|
+
onPress={advance}
|
|
273
|
+
/>
|
|
274
|
+
</>
|
|
275
|
+
) : null}
|
|
276
|
+
</View>
|
|
277
|
+
</View>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* ISO date (YYYY-MM-DD) → the MRZ's YYMMDD.
|
|
283
|
+
*
|
|
284
|
+
* The BAC key is computed over the MRZ form, so this has to be the two-digit
|
|
285
|
+
* year the document actually prints — not the four-digit one the rest of the
|
|
286
|
+
* SDK passes around.
|
|
287
|
+
*/
|
|
288
|
+
function toMrzDate(iso: string): string {
|
|
289
|
+
return iso.slice(2, 4) + iso.slice(5, 7) + iso.slice(8, 10);
|
|
290
|
+
}
|