@myazahq/kyc-sdk-react-native 2.0.2 → 2.1.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 +111 -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 +67 -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 +152 -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 +331 -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 +151 -0
- package/src/screens/BusinessKeyPersonRow.tsx +170 -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/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,162 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Image, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
8
|
+
import { Icon } from '../../components/Icon';
|
|
9
|
+
import { useChipPortrait } from './useChipPortrait';
|
|
10
|
+
import { NfcScannedSummary } from './NfcScannedSummary';
|
|
11
|
+
import type { EmrtdReadResult } from '../../emrtd';
|
|
12
|
+
import type { MrzScan } from '../../mrz/parse';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// "Chip read successfully".
|
|
16
|
+
//
|
|
17
|
+
// The read used to advance silently, which gave the user nothing to show for
|
|
18
|
+
// the one step that asks them to hold a phone against a passport.
|
|
19
|
+
//
|
|
20
|
+
// The chip's OWN portrait is the confirmation when we can show it: it is the
|
|
21
|
+
// issuing government's photo of the holder, which is a far stronger signal
|
|
22
|
+
// than a tick. Most passports store it as JPEG 2000, which React Native cannot
|
|
23
|
+
// decode, so the generic mark is the ordinary case rather than a failure — the
|
|
24
|
+
// read stands either way and the bytes still go to the server.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export function NfcSuccessPanel({
|
|
28
|
+
result,
|
|
29
|
+
mrz,
|
|
30
|
+
onContinue,
|
|
31
|
+
onRescan,
|
|
32
|
+
}: {
|
|
33
|
+
result: EmrtdReadResult;
|
|
34
|
+
mrz: MrzScan | null;
|
|
35
|
+
onContinue: () => void;
|
|
36
|
+
onRescan: () => void;
|
|
37
|
+
}): React.ReactElement {
|
|
38
|
+
const { colors } = useTheme();
|
|
39
|
+
const portraitUri = useChipPortrait(result.dg2);
|
|
40
|
+
// A URI that turns out unrenderable (a corrupt slice, a format the Image
|
|
41
|
+
// component rejects) must degrade to the generic mark, not an empty circle.
|
|
42
|
+
const [portraitBroken, setPortraitBroken] = useState(false);
|
|
43
|
+
const portrait = portraitBroken ? null : portraitUri;
|
|
44
|
+
const name = [mrz?.firstName, mrz?.lastName].filter(Boolean).join(' ');
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ alignItems: 'center' }}>
|
|
48
|
+
<View style={{ height: spacing.lg }} />
|
|
49
|
+
{/* The chip's own portrait, circular, with the success mark tucked into
|
|
50
|
+
its lower-right — 1:1 with Flutter.
|
|
51
|
+
|
|
52
|
+
Circular and large on purpose: this is the issuing government's photo
|
|
53
|
+
of the holder, and seeing it is a far stronger confirmation than a
|
|
54
|
+
tick. The tick rides ON the portrait rather than replacing it, so the
|
|
55
|
+
screen says "verified" and "this is who it says" at once. */}
|
|
56
|
+
<View style={{ width: PORTRAIT, height: PORTRAIT }}>
|
|
57
|
+
{portrait ? (
|
|
58
|
+
<Image
|
|
59
|
+
source={{ uri: portrait }}
|
|
60
|
+
style={{
|
|
61
|
+
width: PORTRAIT,
|
|
62
|
+
height: PORTRAIT,
|
|
63
|
+
borderRadius: PORTRAIT / 2,
|
|
64
|
+
backgroundColor: colors.backgroundSecondary,
|
|
65
|
+
}}
|
|
66
|
+
resizeMode="cover"
|
|
67
|
+
onError={() => {
|
|
68
|
+
if (__DEV__) console.log('[myaza] chip portrait: Image failed to render the URI');
|
|
69
|
+
setPortraitBroken(true);
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
// No renderable portrait — the generic mark, at the same size, so the
|
|
74
|
+
// layout does not jump between documents that carry one and those
|
|
75
|
+
// that do not.
|
|
76
|
+
<View
|
|
77
|
+
style={{
|
|
78
|
+
width: PORTRAIT,
|
|
79
|
+
height: PORTRAIT,
|
|
80
|
+
borderRadius: PORTRAIT / 2,
|
|
81
|
+
backgroundColor: `${colors.primary}1A`,
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<Icon name="badge-check" size={56} color={colors.primary} />
|
|
87
|
+
</View>
|
|
88
|
+
)}
|
|
89
|
+
<View
|
|
90
|
+
style={{
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
right: 0,
|
|
93
|
+
bottom: 2,
|
|
94
|
+
width: BADGE,
|
|
95
|
+
height: BADGE,
|
|
96
|
+
borderRadius: BADGE / 2,
|
|
97
|
+
backgroundColor: colors.success,
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
// Punched out of the portrait rather than floating over it.
|
|
101
|
+
borderWidth: 3,
|
|
102
|
+
borderColor: colors.background,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Icon name="check" size={20} color="#FFFFFF" />
|
|
106
|
+
</View>
|
|
107
|
+
</View>
|
|
108
|
+
|
|
109
|
+
<View style={{ height: spacing.lg }} />
|
|
110
|
+
<MyazaText variant="heading2" style={{ textAlign: 'center' }}>
|
|
111
|
+
Chip read successfully
|
|
112
|
+
</MyazaText>
|
|
113
|
+
<View style={{ height: spacing.xs }} />
|
|
114
|
+
<MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
115
|
+
{name
|
|
116
|
+
? `We read the secure chip in ${name}’s document.`
|
|
117
|
+
: 'The secure chip in your document was read and verified.'}
|
|
118
|
+
</MyazaText>
|
|
119
|
+
|
|
120
|
+
{/* Said plainly rather than hidden: without the signed security object
|
|
121
|
+
the server cannot verify the chip data against the issuer, so the read
|
|
122
|
+
is worth materially less — and the user is the only one who can
|
|
123
|
+
retry it. */}
|
|
124
|
+
{!result.sod ? (
|
|
125
|
+
<>
|
|
126
|
+
<View style={{ height: spacing.md }} />
|
|
127
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
|
|
128
|
+
The document’s security file could not be read, so the chip data
|
|
129
|
+
cannot be fully verified. You can try again or continue.
|
|
130
|
+
</MyazaText>
|
|
131
|
+
</>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
{/* What was read, shown back — the same card the pre-read screen uses, so
|
|
135
|
+
the user checks the same two fields against the printed page. */}
|
|
136
|
+
{mrz ? (
|
|
137
|
+
<>
|
|
138
|
+
<View style={{ height: spacing.lg }} />
|
|
139
|
+
<View style={{ alignSelf: 'stretch' }}>
|
|
140
|
+
<NfcScannedSummary scan={mrz} />
|
|
141
|
+
</View>
|
|
142
|
+
</>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
<View style={{ height: spacing.lg }} />
|
|
146
|
+
<View style={{ alignSelf: 'stretch' }}>
|
|
147
|
+
<MyazaButton label="Continue" onPress={onContinue} />
|
|
148
|
+
{!result.sod ? (
|
|
149
|
+
<>
|
|
150
|
+
<View style={{ height: spacing.sm }} />
|
|
151
|
+
<MyazaButton label="Scan again" variant="ghost" onPress={onRescan} />
|
|
152
|
+
</>
|
|
153
|
+
) : null}
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Portrait diameter. Large enough to read a face at arm's length. */
|
|
160
|
+
const PORTRAIT = 132;
|
|
161
|
+
/** The success mark overlapping its lower-right corner. */
|
|
162
|
+
const BADGE = 40;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Is NFC actually unavailable, or has the native module simply not appeared yet?
|
|
3
|
+
//
|
|
4
|
+
// These two look identical at the first render and mean opposite things. Nitro
|
|
5
|
+
// populates its registry asynchronously, so `isNfcAvailable()` can report false
|
|
6
|
+
// for a moment on a phone that reads chips perfectly well — observed on a real
|
|
7
|
+
// device where three consecutive reloads reported available and the fourth did
|
|
8
|
+
// not, with no code change between them.
|
|
9
|
+
//
|
|
10
|
+
// The consequence is out of proportion to the cause: the chip step SKIPS ITSELF
|
|
11
|
+
// when NFC is unavailable, so one unlucky sample deletes the whole NFC screen
|
|
12
|
+
// with nothing logged. That is how it first surfaced — reported as the screen
|
|
13
|
+
// simply not being there.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately free of native imports so the rule is testable on its own.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long to keep asking before believing the answer is no.
|
|
20
|
+
*
|
|
21
|
+
* Two failures wear the same face and deserve different patience:
|
|
22
|
+
*
|
|
23
|
+
* • `device_unsupported` — the native module answered, and the answer is that
|
|
24
|
+
* this phone cannot read chips. Final; no amount of waiting changes it.
|
|
25
|
+
* • `module_not_registered` — Nitro's registry has not produced the object
|
|
26
|
+
* yet. That is OUR race, not the device's, and it resolves in milliseconds
|
|
27
|
+
* once the registry lands.
|
|
28
|
+
*
|
|
29
|
+
* The window was 1200ms and the capability probe was still reporting false at
|
|
30
|
+
* 1500ms on real launches — with the rectangle detector failing alongside it,
|
|
31
|
+
* which has nothing to do with NFC hardware and so proves the registry, not the
|
|
32
|
+
* phone, was the problem. Three seconds costs a one-off wait only on devices
|
|
33
|
+
* that genuinely have no NFC, and buys back a step that was vanishing on
|
|
34
|
+
* perfectly capable ones.
|
|
35
|
+
*/
|
|
36
|
+
export const SETTLE_MS = 3000;
|
|
37
|
+
/** Gap between samples. Short — the registry usually lands on the first retry. */
|
|
38
|
+
export const POLL_MS = 120;
|
|
39
|
+
|
|
40
|
+
export type NfcAvailability =
|
|
41
|
+
/** Still resolving — render nothing yet. */
|
|
42
|
+
| 'checking'
|
|
43
|
+
/** The chip step can run. */
|
|
44
|
+
| 'available'
|
|
45
|
+
/** The native module answered: this device cannot read chips. Skip. */
|
|
46
|
+
| 'unsupported'
|
|
47
|
+
/**
|
|
48
|
+
* The native module never registered. NOT the same as the device saying no —
|
|
49
|
+
* this is our own wiring failing, and it must not delete a verification step.
|
|
50
|
+
*/
|
|
51
|
+
| 'module_missing';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Asymmetric on purpose: a positive answer is trusted immediately, a negative
|
|
55
|
+
* one only after the window has elapsed.
|
|
56
|
+
*
|
|
57
|
+
* The two errors are not equally costly. Believing a false negative removes a
|
|
58
|
+
* verification step entirely; believing a premature positive costs nothing,
|
|
59
|
+
* because the read itself still has to succeed on its own merits.
|
|
60
|
+
*/
|
|
61
|
+
export function settleVerdict(
|
|
62
|
+
present: boolean,
|
|
63
|
+
elapsedMs: number,
|
|
64
|
+
reason?: 'module_not_registered' | 'device_unsupported' | null,
|
|
65
|
+
): NfcAvailability {
|
|
66
|
+
if (present) return 'available';
|
|
67
|
+
if (elapsedMs < SETTLE_MS) return 'checking';
|
|
68
|
+
// Whose failure is it? A device that says it cannot read chips is a fact to
|
|
69
|
+
// act on. A module that never registered is OUR bug, and silently dropping
|
|
70
|
+
// the chip read to hide it is the worst of the three options — the user is
|
|
71
|
+
// told nothing and the verification quietly loses a factor.
|
|
72
|
+
return reason === 'device_unsupported' ? 'unsupported' : 'module_missing';
|
|
73
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { EmrtdSessionError } from '../../emrtd/session';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// What to tell the user when a chip read fails.
|
|
5
|
+
//
|
|
6
|
+
// NEVER the platform's own string. iOS produces things like
|
|
7
|
+
// Error Domain=NFCError Code=202 "Session invalidated unexpectedly"
|
|
8
|
+
// which was being rendered verbatim on the screen: it tells the user nothing
|
|
9
|
+
// they can act on, and reads as a crash rather than a retryable hiccup.
|
|
10
|
+
//
|
|
11
|
+
// Each message names the thing the user can actually change — where the phone
|
|
12
|
+
// is, which document was scanned, whether to try again. Mirrors the Flutter
|
|
13
|
+
// SDK's `_messageFor`.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Raw platform error shapes worth distinguishing, matched in the string. */
|
|
17
|
+
const RAW_PATTERNS: Array<[RegExp, string]> = [
|
|
18
|
+
// 200 — the user dismissed the system sheet. Not a failure worth apologising
|
|
19
|
+
// for; just offer the read again.
|
|
20
|
+
[/Code=200\b/, 'Chip reading was cancelled. You can try again.'],
|
|
21
|
+
// 201 — the sheet timed out waiting for a document.
|
|
22
|
+
[
|
|
23
|
+
/Code=201\b/,
|
|
24
|
+
'No chip was found in time. Hold the top edge of your document flat against the back of your phone and try again.',
|
|
25
|
+
],
|
|
26
|
+
// 202 — the session ended on its own. Almost always the document shifting out
|
|
27
|
+
// of range mid-read, which is exactly what the user should adjust.
|
|
28
|
+
[
|
|
29
|
+
/Code=202\b/,
|
|
30
|
+
'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
|
|
31
|
+
],
|
|
32
|
+
// 203 — the NFC radio is busy elsewhere (Apple Pay, another reader).
|
|
33
|
+
[/Code=203\b/, 'The NFC reader is busy. Wait a moment and try again.'],
|
|
34
|
+
// 100–104 — the transceive-level family: how a physical drop surfaces while a
|
|
35
|
+
// command is in flight (tag lost / retry exceeded / response error / session
|
|
36
|
+
// invalidated / not connected). Same user remedy as 202: keep it still.
|
|
37
|
+
[
|
|
38
|
+
/Code=10[0-4]\b/,
|
|
39
|
+
'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
|
|
40
|
+
],
|
|
41
|
+
// Android's IsoDep raises TagLostException("Tag was lost.") for the same
|
|
42
|
+
// physical case.
|
|
43
|
+
[
|
|
44
|
+
/tag was lost/i,
|
|
45
|
+
'The connection to the chip dropped. Hold the document still against the back of your phone and try again.',
|
|
46
|
+
],
|
|
47
|
+
// Android's own detection timeout (see HybridMyazaEmrtd.kt) — the reader
|
|
48
|
+
// waited and no document ever arrived.
|
|
49
|
+
[
|
|
50
|
+
/no chip was detected/i,
|
|
51
|
+
'No chip was found in time. Hold the top edge of your document flat against the back of your phone and try again.',
|
|
52
|
+
],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A human sentence for a failed chip read.
|
|
57
|
+
*
|
|
58
|
+
* `stageAtFailure` (when the caller tracked it) disambiguates the one code
|
|
59
|
+
* that means two different things: a 202 AFTER chip contact is the document
|
|
60
|
+
* shifting out of range, but a 202 while still `'waiting'` — before any chip
|
|
61
|
+
* was ever detected — means the READER itself failed to start (a wedged NFC
|
|
62
|
+
* daemon, or a build signed without the NFC entitlement). Telling that user
|
|
63
|
+
* to hold the document still sends them chasing a positioning problem that
|
|
64
|
+
* does not exist; a phone restart is what actually clears a wedged reader.
|
|
65
|
+
*/
|
|
66
|
+
export function readErrorMessage(err: unknown, stageAtFailure?: string): string {
|
|
67
|
+
if (stageAtFailure === 'waiting') {
|
|
68
|
+
const raw = err instanceof Error ? err.message : String(err ?? '');
|
|
69
|
+
if (/Code=202\b/.test(raw)) {
|
|
70
|
+
return "The NFC reader couldn't start. Restart your phone and try again.";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (err instanceof EmrtdSessionError) {
|
|
74
|
+
switch (err.code) {
|
|
75
|
+
case 'bac_failed':
|
|
76
|
+
// The MRZ is check-digit validated, so this is not a typo — the chip
|
|
77
|
+
// belongs to a different document than the page that was scanned.
|
|
78
|
+
return "Couldn't unlock the chip with this document's details. Make sure you scanned the photo page of the same document.";
|
|
79
|
+
case 'select_failed':
|
|
80
|
+
return "This document's chip could not be opened. It may not be an electronic document.";
|
|
81
|
+
case 'read_failed':
|
|
82
|
+
return 'The chip moved out of range. Hold the document still against the back of your phone.';
|
|
83
|
+
default:
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const raw = err instanceof Error ? err.message : String(err ?? '');
|
|
89
|
+
for (const [pattern, message] of RAW_PATTERNS) {
|
|
90
|
+
if (pattern.test(raw)) return message;
|
|
91
|
+
}
|
|
92
|
+
return "Couldn't read the chip. Please try again.";
|
|
93
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { decodeChipImage } from '../../emrtd';
|
|
4
|
+
import { extractChipPortrait } from '../../emrtd/dg2';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The chip's own portrait, made renderable.
|
|
8
|
+
//
|
|
9
|
+
// Seeing the photo the issuing government stored on the chip is a far stronger
|
|
10
|
+
// confirmation than a tick — it is the one moment the user can check the
|
|
11
|
+
// document against itself.
|
|
12
|
+
//
|
|
13
|
+
// Most passports store DG2 as JPEG 2000, which React Native cannot display. The
|
|
14
|
+
// portrait was therefore being read, hash-verified against the signed security
|
|
15
|
+
// object, and then discarded unshown on the majority of documents. The platform
|
|
16
|
+
// has a decoder (ImageIO on iOS, BitmapFactory on Android), so the JP2 case is
|
|
17
|
+
// handed to it and comes back as JPEG.
|
|
18
|
+
//
|
|
19
|
+
// Best-effort throughout: a portrait that cannot be decoded yields null and the
|
|
20
|
+
// panel falls back to its generic mark. The chip read's verdict never depends
|
|
21
|
+
// on whether the picture could be drawn.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export function useChipPortrait(dg2: string | undefined): string | null {
|
|
25
|
+
// JPEG needs no help — use it immediately rather than waiting a frame on a
|
|
26
|
+
// native round trip that has nothing to do.
|
|
27
|
+
const direct = extractChipPortrait(dg2);
|
|
28
|
+
const [decoded, setDecoded] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Four ways to end up with no picture, and they need different fixes: the
|
|
32
|
+
// chip never gave up DG2 (SOD failed, so it was never requested — or the
|
|
33
|
+
// document lifted during the photo read, the longest transfer), DG2
|
|
34
|
+
// arrived in a format we did not recognise, the platform declined to
|
|
35
|
+
// decode it, or a direct-JPEG slice that does not actually render. Logged
|
|
36
|
+
// on EVERY path — the direct-JPEG case used to be silent, which made "no
|
|
37
|
+
// photo" undiagnosable from the field.
|
|
38
|
+
if (__DEV__) {
|
|
39
|
+
console.log('[myaza] chip portrait:', {
|
|
40
|
+
dg2Bytes: dg2 ? dg2.length : 0,
|
|
41
|
+
format: direct.format,
|
|
42
|
+
direct: direct.dataUri != null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (direct.dataUri || !direct.imageBase64) return;
|
|
46
|
+
let live = true;
|
|
47
|
+
// The extracted IMAGE, not the DG2 container — see ChipPortrait.imageBase64.
|
|
48
|
+
void decodeChipImage(direct.imageBase64).then((jpegBase64) => {
|
|
49
|
+
if (__DEV__) console.log('[myaza] chip portrait decode:', jpegBase64 ? 'ok' : 'declined');
|
|
50
|
+
if (live && jpegBase64) setDecoded(`data:image/jpeg;base64,${jpegBase64}`);
|
|
51
|
+
});
|
|
52
|
+
return () => {
|
|
53
|
+
live = false;
|
|
54
|
+
};
|
|
55
|
+
}, [dg2, direct.dataUri, direct.imageBase64]);
|
|
56
|
+
|
|
57
|
+
return direct.dataUri ?? decoded;
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { isNfcAvailable, nfcUnavailableReason } from '../../emrtd';
|
|
5
|
+
import { POLL_MS, settleVerdict, type NfcAvailability } from './availability';
|
|
6
|
+
|
|
7
|
+
export { settleVerdict, type NfcAvailability } from './availability';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Is NFC actually unavailable, or has the native module simply not appeared yet?
|
|
11
|
+
//
|
|
12
|
+
// These two look identical at first render and mean opposite things. Nitro's
|
|
13
|
+
// registry is populated asynchronously, so `isNfcAvailable()` can report false
|
|
14
|
+
// for a moment on a phone that reads chips perfectly well — observed on a
|
|
15
|
+
// device where three consecutive reloads reported true and the fourth false,
|
|
16
|
+
// with no code change between them.
|
|
17
|
+
//
|
|
18
|
+
// That mattered because the chip step SKIPS ITSELF when NFC is unavailable. A
|
|
19
|
+
// single unlucky read therefore deleted the whole NFC screen with nothing
|
|
20
|
+
// logged and no way to get back to it — indistinguishable from a phone with no
|
|
21
|
+
// radio, which is why it was first reported as "there was no NFC screen".
|
|
22
|
+
//
|
|
23
|
+
// So: don't decide on one sample. Re-check across a short window and only
|
|
24
|
+
// conclude "unavailable" once it has stayed false the whole time. A device
|
|
25
|
+
// that genuinely has no radio pays a fraction of a second before skipping; a
|
|
26
|
+
// device that has one no longer loses the step to a race.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Availability as a settled verdict rather than a snapshot.
|
|
31
|
+
*
|
|
32
|
+
* `available` is returned as soon as it is true — the wait only ever applies to
|
|
33
|
+
* the negative answer, because that is the one that is destructive if wrong.
|
|
34
|
+
*/
|
|
35
|
+
export function useNfcAvailability(): NfcAvailability {
|
|
36
|
+
const [state, setState] = useState<NfcAvailability>(() =>
|
|
37
|
+
isNfcAvailable() ? 'available' : 'checking',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// A verdict reached while the app was not fully foregrounded is not a verdict.
|
|
41
|
+
//
|
|
42
|
+
// `NFCTagReaderSession.readingAvailable` is documented as a device capability,
|
|
43
|
+
// but iOS reports it FALSE while the app is backgrounded or mid-transition —
|
|
44
|
+
// and this flow sends users away and back (a passport arrives from a chat, the
|
|
45
|
+
// phone is unlocked mid-step). A device that reads chips perfectly well then
|
|
46
|
+
// answers "unsupported" once, permanently, for that mount.
|
|
47
|
+
//
|
|
48
|
+
// So a settled negative is re-opened when the app becomes active again. The
|
|
49
|
+
// positive is never re-examined: it cannot become wrong.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
|
52
|
+
if (next === 'active') setState((prev) => (prev === 'available' ? prev : 'checking'));
|
|
53
|
+
});
|
|
54
|
+
return () => sub.remove();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (state !== 'checking') return;
|
|
59
|
+
|
|
60
|
+
let live = true;
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
|
|
63
|
+
const tick = (): void => {
|
|
64
|
+
if (!live) return;
|
|
65
|
+
const verdict = settleVerdict(isNfcAvailable(), Date.now() - startedAt, nfcUnavailableReason());
|
|
66
|
+
if (verdict !== 'checking') {
|
|
67
|
+
setState(verdict);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
timer = setTimeout(tick, POLL_MS);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let timer = setTimeout(tick, POLL_MS);
|
|
74
|
+
return () => {
|
|
75
|
+
live = false;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
};
|
|
78
|
+
// Runs once: after the first resolution the verdict is final for this mount.
|
|
79
|
+
}, [state]);
|
|
80
|
+
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { FlashHole } from '../components/flashHoleGeometry';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Where the preview circle actually sits on the physical screen.
|
|
8
|
+
//
|
|
9
|
+
// The flash paints in a full-screen modal over the sheet, so the hole has to be
|
|
10
|
+
// given in window coordinates — it cannot be derived from the step's own
|
|
11
|
+
// spacing constants.
|
|
12
|
+
//
|
|
13
|
+
// Measuring ONCE is not enough, and this is the subtle part: iOS stacks a
|
|
14
|
+
// presenting sheet BACK when a modal is presented over it. The sheet scales
|
|
15
|
+
// down and shifts, so the preview MOVES as a direct consequence of showing the
|
|
16
|
+
// overlay that is measuring it. A single reading taken before or during that
|
|
17
|
+
// animation describes a position the preview has already left — the observed
|
|
18
|
+
// result was a hole both larger than the preview and higher up, off by exactly
|
|
19
|
+
// the transform.
|
|
20
|
+
//
|
|
21
|
+
// So it re-measures until the answer stops changing, then stops. Nothing else
|
|
22
|
+
// on screen moves during a flash (guidance banners are suppressed), so a
|
|
23
|
+
// settled value stays valid for the rest of the sequence.
|
|
24
|
+
//
|
|
25
|
+
// Failure is not fatal: a null hole paints a solid flash, which still lights
|
|
26
|
+
// the face. See FlashOverlay.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** How often to re-read while the position is still settling. */
|
|
30
|
+
const POLL_MS = 120;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long to keep watching. Comfortably longer than the sheet-stacking
|
|
34
|
+
* animation, and it stops early as soon as two readings agree.
|
|
35
|
+
*/
|
|
36
|
+
const SETTLE_WINDOW_MS = 1000;
|
|
37
|
+
|
|
38
|
+
/** Sub-pixel differences are not movement. */
|
|
39
|
+
const EPSILON = 0.5;
|
|
40
|
+
|
|
41
|
+
function same(a: FlashHole | null, b: FlashHole): boolean {
|
|
42
|
+
return (
|
|
43
|
+
a !== null &&
|
|
44
|
+
Math.abs(a.x - b.x) < EPSILON &&
|
|
45
|
+
Math.abs(a.y - b.y) < EPSILON &&
|
|
46
|
+
Math.abs(a.size - b.size) < EPSILON
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useFlashHole(active: boolean): {
|
|
51
|
+
ref: React.RefObject<View | null>;
|
|
52
|
+
hole: FlashHole | null;
|
|
53
|
+
} {
|
|
54
|
+
const ref = useRef<View | null>(null);
|
|
55
|
+
const [hole, setHole] = useState<FlashHole | null>(null);
|
|
56
|
+
const latest = useRef<FlashHole | null>(null);
|
|
57
|
+
|
|
58
|
+
const measure = useCallback((onSettled: () => void) => {
|
|
59
|
+
const node = ref.current;
|
|
60
|
+
if (!node) return;
|
|
61
|
+
node.measureInWindow((x, y, width, height) => {
|
|
62
|
+
// A zero-sized measurement means the view is not laid out yet; punching a
|
|
63
|
+
// hole there would leave a gap in the wrong place, which is worse than
|
|
64
|
+
// painting solid.
|
|
65
|
+
if (!width || !height) return;
|
|
66
|
+
// The preview is a circle in a square box, so the smaller side is the
|
|
67
|
+
// diameter — guards against a reading taken mid-layout.
|
|
68
|
+
const next = { x, y, size: Math.min(width, height) };
|
|
69
|
+
if (same(latest.current, next)) {
|
|
70
|
+
onSettled();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
latest.current = next;
|
|
74
|
+
setHole(next);
|
|
75
|
+
});
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!active) {
|
|
80
|
+
latest.current = null;
|
|
81
|
+
setHole(null);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
let stopped = false;
|
|
87
|
+
const stop = (): void => {
|
|
88
|
+
stopped = true;
|
|
89
|
+
if (timer) clearTimeout(timer);
|
|
90
|
+
};
|
|
91
|
+
const deadline = Date.now() + SETTLE_WINDOW_MS;
|
|
92
|
+
|
|
93
|
+
const tick = (): void => {
|
|
94
|
+
if (stopped) return;
|
|
95
|
+
measure(stop);
|
|
96
|
+
if (!stopped && Date.now() < deadline) timer = setTimeout(tick, POLL_MS);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// One frame's grace so the flash phase's own render has committed.
|
|
100
|
+
timer = setTimeout(tick, 0);
|
|
101
|
+
return stop;
|
|
102
|
+
}, [active, measure]);
|
|
103
|
+
|
|
104
|
+
return { ref, hole };
|
|
105
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { runFlashSequence } from '../liveness/flashRunner';
|
|
4
|
+
import { DEFAULT_FLASH_SEQUENCE, generateFlashSequence } from '../liveness/flashDetector';
|
|
5
|
+
import type { FlashResult, Rgb } from '../liveness/flashDetector';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Running the flash sequence from the liveness screen.
|
|
9
|
+
//
|
|
10
|
+
// Split out of LivenessStep (200-line rule). It starts once when the machine
|
|
11
|
+
// asks, and tears down on unmount so a half-finished sequence can never leave
|
|
12
|
+
// the screen painted a solid colour with no way back.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** A sequence that could not run at all — unmeasured, which fails soft. */
|
|
16
|
+
const UNMEASURED: FlashResult = {
|
|
17
|
+
passed: true,
|
|
18
|
+
score: 0,
|
|
19
|
+
matched: 0,
|
|
20
|
+
total: 0,
|
|
21
|
+
inconclusive: 0,
|
|
22
|
+
sequence: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function useFlashSequence({
|
|
26
|
+
active,
|
|
27
|
+
shouldContinue,
|
|
28
|
+
sequenceLength,
|
|
29
|
+
readFaceRgb,
|
|
30
|
+
onComplete,
|
|
31
|
+
}: {
|
|
32
|
+
/** True while the machine is in the flash phase. */
|
|
33
|
+
active: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Aborts mid-sequence when it returns false — a second face, or a swapped
|
|
36
|
+
* one. Finishing would measure a reflection off somebody who is not the
|
|
37
|
+
* subject, which is worse than no measurement at all.
|
|
38
|
+
*/
|
|
39
|
+
shouldContinue?: () => boolean;
|
|
40
|
+
sequenceLength: number | undefined;
|
|
41
|
+
readFaceRgb: () => Rgb | null;
|
|
42
|
+
onComplete: (result: FlashResult) => void;
|
|
43
|
+
}): { flashColor: string | null } {
|
|
44
|
+
const [flashColor, setFlashColor] = useState<string | null>(null);
|
|
45
|
+
const startedRef = useRef(false);
|
|
46
|
+
const liveRef = useRef(true);
|
|
47
|
+
// The callbacks are read through refs so the effect below never re-runs on
|
|
48
|
+
// their identity — restarting a sequence mid-flight would leave the screen
|
|
49
|
+
// painted and the counter of measured flashes wrong.
|
|
50
|
+
const completeRef = useRef(onComplete);
|
|
51
|
+
completeRef.current = onComplete;
|
|
52
|
+
const readRef = useRef(readFaceRgb);
|
|
53
|
+
readRef.current = readFaceRgb;
|
|
54
|
+
const continueRef = useRef(shouldContinue);
|
|
55
|
+
continueRef.current = shouldContinue;
|
|
56
|
+
|
|
57
|
+
useEffect(
|
|
58
|
+
() => () => {
|
|
59
|
+
liveRef.current = false;
|
|
60
|
+
},
|
|
61
|
+
[],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
// Re-arm when the phase leaves flash. The guard exists to stop the sequence
|
|
66
|
+
// restarting while it is ALREADY running (the effect re-runs whenever the
|
|
67
|
+
// screen re-renders), but it was never cleared — so a retake found it still
|
|
68
|
+
// set from the first attempt, returned immediately, and left the machine
|
|
69
|
+
// parked in the flash phase with nothing driving it. The step hung with no
|
|
70
|
+
// error, because from its point of view a sequence was still in flight.
|
|
71
|
+
if (!active) {
|
|
72
|
+
startedRef.current = false;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (startedRef.current) return;
|
|
76
|
+
startedRef.current = true;
|
|
77
|
+
void runFlashSequence(
|
|
78
|
+
{
|
|
79
|
+
setColor: setFlashColor,
|
|
80
|
+
readFaceRgb: () => readRef.current(),
|
|
81
|
+
isActive: () => liveRef.current && (continueRef.current?.() ?? true),
|
|
82
|
+
},
|
|
83
|
+
generateFlashSequence(sequenceLength ?? DEFAULT_FLASH_SEQUENCE),
|
|
84
|
+
)
|
|
85
|
+
.then((result) => completeRef.current(result))
|
|
86
|
+
.catch(() => {
|
|
87
|
+
// A failed sequence is a MISSING measurement, not a failed check — the
|
|
88
|
+
// user is not at fault for a camera hiccup, and the server re-analyses
|
|
89
|
+
// the recorded video regardless.
|
|
90
|
+
setFlashColor(null);
|
|
91
|
+
completeRef.current(UNMEASURED);
|
|
92
|
+
});
|
|
93
|
+
}, [active, sequenceLength]);
|
|
94
|
+
|
|
95
|
+
return { flashColor };
|
|
96
|
+
}
|