@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
|
@@ -41,77 +41,67 @@ import {
|
|
|
41
41
|
eyeAverageOpenProbability,
|
|
42
42
|
pushHistory,
|
|
43
43
|
} from './gestureDetector';
|
|
44
|
-
import { ChallengeTracker, pickChallenges } from './challengeManager';
|
|
44
|
+
import { ChallengeTracker, modeRunsGestures, pickChallenges } from './challengeManager';
|
|
45
|
+
import { FlashReadyGate } from './flashReadyGate';
|
|
46
|
+
import { FaceContinuityGuard } from './faceContinuity';
|
|
47
|
+
import type { FlashResult } from './flashDetector';
|
|
48
|
+
import { useFlashPhase } from './useFlashPhase';
|
|
49
|
+
|
|
50
|
+
// State shape, options, guidance strings and phase predicates live in
|
|
51
|
+
// ./livenessState — re-exported here so existing imports keep working.
|
|
52
|
+
export {
|
|
53
|
+
positionGuidanceText,
|
|
54
|
+
lightingGuidanceText,
|
|
55
|
+
type PositionGuidance,
|
|
56
|
+
type LightingGuidance,
|
|
57
|
+
type LivenessUiState,
|
|
58
|
+
type UseLivenessOptions,
|
|
59
|
+
type UseLivenessReturn,
|
|
60
|
+
} from './livenessState';
|
|
45
61
|
import { LivenessSpeaker } from './speech';
|
|
46
|
-
import
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
completedCount: number;
|
|
62
|
-
totalCount: number;
|
|
63
|
-
positionGuidance: PositionGuidance | null;
|
|
64
|
-
lightingGuidance: LightingGuidance | null;
|
|
65
|
-
multipleFaces: boolean;
|
|
66
|
-
wrongGesture: boolean;
|
|
67
|
-
faceDetected: boolean;
|
|
68
|
-
failureReason: LivenessFailureReason | null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface UseLivenessOptions {
|
|
72
|
-
config?: Partial<LivenessConfig>;
|
|
73
|
-
voiceGuidance?: VoiceGuidanceOption;
|
|
74
|
-
/** Fires once when all challenges pass and the phase enters `capturing`. */
|
|
75
|
-
onReadyToCapture?: () => void;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface UseLivenessReturn extends LivenessUiState {
|
|
79
|
-
/** Push a face frame into the machine (call from the frame processor via runOnJS). */
|
|
80
|
-
onFace: (data: LivenessFaceData) => void;
|
|
81
|
-
/** Push a "no face this frame" signal. */
|
|
82
|
-
onNoFace: () => void;
|
|
83
|
-
/** Feed live lighting quality (null = ok). */
|
|
84
|
-
setLighting: (guidance: LightingGuidance | null) => void;
|
|
85
|
-
/** Mark the selfie captured → phase `complete`. */
|
|
86
|
-
markComplete: () => void;
|
|
87
|
-
/** Restart the session with a fresh random challenge set. */
|
|
88
|
-
reset: () => void;
|
|
89
|
-
/** True while the machine wants a still captured (phase === 'capturing'). */
|
|
90
|
-
shouldCapture: boolean;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Position guidance text (spoken + shown), matching the Flutter SDK exactly. */
|
|
94
|
-
export function positionGuidanceText(g: PositionGuidance): string {
|
|
95
|
-
return g === POSITION_TOO_FAR ? 'Kindly move closer' : 'Kindly move further away';
|
|
96
|
-
}
|
|
97
|
-
/** Lighting warning banner text — the full Flutter strings (shown, not spoken). */
|
|
98
|
-
export function lightingGuidanceText(g: LightingGuidance): string {
|
|
99
|
-
return g === 'dark'
|
|
100
|
-
? 'It looks dark here. Move to a brighter area or near a light source for better detection.'
|
|
101
|
-
: 'Too bright — reduce glare or move away from direct light for better detection.';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function isTerminal(phase: LivenessPhase): boolean {
|
|
105
|
-
return phase === 'complete' || phase === 'failed';
|
|
106
|
-
}
|
|
62
|
+
import {
|
|
63
|
+
INTEGRITY_FAILED_PATCH,
|
|
64
|
+
CHALLENGE_PASSED_MS,
|
|
65
|
+
POSITION_TOO_FAR,
|
|
66
|
+
POSITION_TOO_CLOSE,
|
|
67
|
+
MULTIPLE_FACES_GUIDANCE,
|
|
68
|
+
isSettled,
|
|
69
|
+
isTerminal,
|
|
70
|
+
positionGuidanceText,
|
|
71
|
+
type LightingGuidance,
|
|
72
|
+
type PositionGuidance,
|
|
73
|
+
type LivenessUiState,
|
|
74
|
+
type UseLivenessOptions,
|
|
75
|
+
type UseLivenessReturn,
|
|
76
|
+
} from './livenessState';
|
|
107
77
|
|
|
108
78
|
export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
109
|
-
const { config, voiceGuidance, onReadyToCapture } = opts;
|
|
79
|
+
const { config, voiceGuidance, onReadyToCapture, announce = true } = opts;
|
|
110
80
|
|
|
111
81
|
// Resolve the challenge set once per session (and on reset).
|
|
112
82
|
const trackerRef = useRef<ChallengeTracker | null>(null);
|
|
83
|
+
|
|
84
|
+
// Tracks that the face performing the challenges is the face still in frame.
|
|
85
|
+
// Liveness without it proves a live human was present, not WHICH human.
|
|
86
|
+
const continuityRef = useRef<FaceContinuityGuard>(new FaceContinuityGuard());
|
|
87
|
+
// Flash-only has no challenges to act as a buffer, so it needs its own
|
|
88
|
+
// "framed, lit and held still" gate before the screen lights up.
|
|
89
|
+
const flashGateRef = useRef<FlashReadyGate | null>(null);
|
|
90
|
+
// "No lighting warning" is ambiguous until the sampler has actually reported:
|
|
91
|
+
// during warm-up, unknown looks identical to good.
|
|
92
|
+
const lightingSampledRef = useRef(false);
|
|
93
|
+
// When a face was last actually seen. The capture gate needs this to refuse a
|
|
94
|
+
// frame with nobody in it.
|
|
95
|
+
const lastFaceSeenRef = useRef<number | null>(null);
|
|
96
|
+
|
|
97
|
+
// Set when the session can no longer vouch for who is in frame. Recorded
|
|
98
|
+
// rather than shown during `capturing`: that phase runs the capture (and, in
|
|
99
|
+
// future, the flash overlay), and re-laying out the screen mid-capture is how
|
|
100
|
+
// the Flutter SDK ended up displacing its preview from the flash cutout.
|
|
101
|
+
const integrityBrokenRef = useRef(false);
|
|
113
102
|
if (trackerRef.current === null) {
|
|
114
103
|
trackerRef.current = new ChallengeTracker(pickChallenges(config));
|
|
104
|
+
flashGateRef.current = modeRunsGestures(config?.mode) ? null : new FlashReadyGate();
|
|
115
105
|
}
|
|
116
106
|
const initialTotalCount = trackerRef.current.totalCount;
|
|
117
107
|
|
|
@@ -175,12 +165,45 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
175
165
|
[cancelTimer],
|
|
176
166
|
);
|
|
177
167
|
|
|
168
|
+
const flash = useFlashPhase(config?.mode, setState);
|
|
169
|
+
|
|
170
|
+
// Speak whatever the screen is currently instructing.
|
|
171
|
+
//
|
|
172
|
+
// Only CHALLENGE instructions were ever spoken, so a flash-only flow — which
|
|
173
|
+
// has no challenges — was silent from start to finish, and "Position your face
|
|
174
|
+
// in the circle" was never spoken in any mode. The guidance is most needed
|
|
175
|
+
// exactly where it was missing: a user holding a phone at arm's length during
|
|
176
|
+
// a flash sequence is not reading the screen.
|
|
177
|
+
//
|
|
178
|
+
// Keyed on the instruction rather than the phase so a challenge-to-challenge
|
|
179
|
+
// change is announced too. The speaker de-dupes consecutive identical phrases,
|
|
180
|
+
// so this cannot stutter over a re-render. Multi-face and lighting prompts are
|
|
181
|
+
// spoken by their own handlers and take priority, so they are skipped here.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!announce || !state.instruction || state.multipleFaces) return;
|
|
184
|
+
speak(state.instruction);
|
|
185
|
+
}, [announce, state.instruction, state.multipleFaces, speak]);
|
|
186
|
+
// Whether the flash occupies a slot in the progress indicator.
|
|
187
|
+
const flashIsStep = config?.mode === 'flash' || config?.mode === 'both';
|
|
188
|
+
|
|
178
189
|
const startNextChallenge = useCallback(() => {
|
|
179
190
|
const tracker = trackerRef.current!;
|
|
180
191
|
const current = tracker.current;
|
|
181
192
|
if (!current) {
|
|
182
|
-
// No challenges left → ask for a still.
|
|
183
193
|
cancelTimer();
|
|
194
|
+
// Flash runs LAST, after the gestures: by now the face is positioned and
|
|
195
|
+
// steady, which is what makes the reflection readable. Starting it during
|
|
196
|
+
// positioning would measure a moving target.
|
|
197
|
+
if (flash.pending()) {
|
|
198
|
+
setState((s) => ({
|
|
199
|
+
...s,
|
|
200
|
+
phase: 'flash',
|
|
201
|
+
instruction: 'Hold still and look at the screen',
|
|
202
|
+
activeChallenge: null,
|
|
203
|
+
positionGuidance: null,
|
|
204
|
+
}));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
184
207
|
setState((s) => ({
|
|
185
208
|
...s,
|
|
186
209
|
phase: 'capturing',
|
|
@@ -231,11 +254,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
231
254
|
// ── Position check (every frame; mirrors Flutter _checkFacePosition) ────────
|
|
232
255
|
const checkPosition = useCallback((ratio: number) => {
|
|
233
256
|
setState((s) => {
|
|
234
|
-
if (
|
|
235
|
-
s.phase === 'challenge_passed' ||
|
|
236
|
-
s.phase === 'capturing' ||
|
|
237
|
-
isTerminal(s.phase)
|
|
238
|
-
) {
|
|
257
|
+
if (s.phase === 'challenge_passed' || isSettled(s.phase) || isTerminal(s.phase)) {
|
|
239
258
|
return s;
|
|
240
259
|
}
|
|
241
260
|
let next: PositionGuidance | null;
|
|
@@ -247,26 +266,99 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
247
266
|
}, []);
|
|
248
267
|
|
|
249
268
|
// ── Per-frame entry point ──────────────────────────────────────────────────
|
|
269
|
+
/**
|
|
270
|
+
* A different face is in frame, or one returned after an absence.
|
|
271
|
+
*
|
|
272
|
+
* `substituted` ends the session: nothing performed so far can be attributed
|
|
273
|
+
* to whoever is there now. `reacquired` is not proof — people look away — but
|
|
274
|
+
* nothing vouches for the returning face either, so the challenges are
|
|
275
|
+
* restarted rather than inherited.
|
|
276
|
+
*
|
|
277
|
+
* Mid-capture the verdict is RECORDED, not shown: re-laying out the screen
|
|
278
|
+
* while a capture (or flash overlay) is running is a separate class of bug,
|
|
279
|
+
* so the flow reports it once capture is done.
|
|
280
|
+
*/
|
|
281
|
+
const onFaceIntegrityBroken = useCallback(
|
|
282
|
+
(kind: 'substituted' | 'reacquired') => {
|
|
283
|
+
const phase = stateRef.current.phase;
|
|
284
|
+
if (isTerminal(phase)) return;
|
|
285
|
+
integrityBrokenRef.current = true;
|
|
286
|
+
// Counted whether or not it is shown: a session with several
|
|
287
|
+
// discontinuities is worth flagging even when each one was recoverable.
|
|
288
|
+
flash.recordGlitch();
|
|
289
|
+
if (isSettled(phase)) return;
|
|
290
|
+
|
|
291
|
+
cancelTimer();
|
|
292
|
+
if (kind === 'substituted') {
|
|
293
|
+
setState((s) => ({ ...s, ...INTEGRITY_FAILED_PATCH }));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Re-acquired: start again from positioning with a fresh challenge set.
|
|
298
|
+
trackerRef.current = new ChallengeTracker(pickChallenges(config));
|
|
299
|
+
// The returning face has to earn the hold again — otherwise a face that
|
|
300
|
+
// left and came back would flash immediately on a gate that is still
|
|
301
|
+
// holding the departed face's dwell.
|
|
302
|
+
flashGateRef.current?.reset();
|
|
303
|
+
xHistoryRef.current = [];
|
|
304
|
+
earHistoryRef.current = [];
|
|
305
|
+
processingRef.current = false;
|
|
306
|
+
integrityBrokenRef.current = false;
|
|
307
|
+
setState((s) => ({
|
|
308
|
+
...s,
|
|
309
|
+
phase: 'positioning',
|
|
310
|
+
instruction: 'Position your face in the circle',
|
|
311
|
+
activeChallenge: null,
|
|
312
|
+
completedCount: 0,
|
|
313
|
+
positionGuidance: null,
|
|
314
|
+
}));
|
|
315
|
+
},
|
|
316
|
+
[cancelTimer, config, flash],
|
|
317
|
+
);
|
|
318
|
+
|
|
250
319
|
const onFace = useCallback(
|
|
251
320
|
(data: LivenessFaceData) => {
|
|
252
321
|
const phase = stateRef.current.phase;
|
|
253
322
|
if (isTerminal(phase)) return;
|
|
254
323
|
|
|
324
|
+
lastFaceSeenRef.current = Date.now();
|
|
325
|
+
|
|
255
326
|
if (!stateRef.current.faceDetected) {
|
|
256
327
|
setState((s) => ({ ...s, faceDetected: true }));
|
|
257
328
|
}
|
|
258
329
|
|
|
259
330
|
// Multiple-faces guard — pause everything until a single face returns.
|
|
331
|
+
//
|
|
332
|
+
// Runs in EVERY non-terminal phase, including the flash and the capture
|
|
333
|
+
// itself. It used to be suppressed once the phase had settled, to keep
|
|
334
|
+
// the layout still underneath the flash's cutout — but that put the blind
|
|
335
|
+
// spot exactly where it matters most: the flash IS the liveness
|
|
336
|
+
// measurement, and the capture is the frame that becomes the selfie, so a
|
|
337
|
+
// second face arriving during either went unrecorded. A misaligned hole
|
|
338
|
+
// is a cosmetic defect; an unnoticed second face is the thing the check
|
|
339
|
+
// exists to catch. Flutter has never gated this either.
|
|
340
|
+
//
|
|
341
|
+
// The layout concern is handled where it belongs — the banner is
|
|
342
|
+
// suppressed at RENDER time during a flash, so the state is still true
|
|
343
|
+
// while nothing moves.
|
|
260
344
|
if (data.faceCount > 1) {
|
|
261
|
-
if (
|
|
345
|
+
if (isSettled(phase)) {
|
|
346
|
+
// The flash and the shutter are the two moments that actually prove
|
|
347
|
+
// liveness. There is no useful guidance to show mid-sequence, so
|
|
348
|
+
// instead of prompting, the session is marked BROKEN — the flash
|
|
349
|
+
// aborts and the capture refuses. Flagging without breaking is what
|
|
350
|
+
// made a second face during the flash have no effect at all.
|
|
351
|
+
integrityBrokenRef.current = true;
|
|
352
|
+
}
|
|
353
|
+
if (!stateRef.current.multipleFaces) {
|
|
262
354
|
cancelTimer();
|
|
263
355
|
setState((s) => ({
|
|
264
356
|
...s,
|
|
265
357
|
multipleFaces: true,
|
|
266
|
-
instruction: MULTIPLE_FACES_GUIDANCE,
|
|
358
|
+
instruction: isSettled(s.phase) ? s.instruction : MULTIPLE_FACES_GUIDANCE,
|
|
267
359
|
wrongGesture: false,
|
|
268
360
|
}));
|
|
269
|
-
speak(MULTIPLE_FACES_GUIDANCE);
|
|
361
|
+
if (!isSettled(phase)) speak(MULTIPLE_FACES_GUIDANCE);
|
|
270
362
|
}
|
|
271
363
|
return;
|
|
272
364
|
}
|
|
@@ -287,10 +379,37 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
287
379
|
}
|
|
288
380
|
}
|
|
289
381
|
|
|
382
|
+
// Same face as a moment ago? Nothing here identifies anyone — it only
|
|
383
|
+
// rejects a substitution, which is what stops one person performing the
|
|
384
|
+
// challenges while another is photographed.
|
|
385
|
+
const continuity = continuityRef.current.update(data, Date.now());
|
|
386
|
+
if (continuity === 'substituted' || continuity === 'reacquired') {
|
|
387
|
+
onFaceIntegrityBroken(continuity);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
290
391
|
checkPosition(data.faceSizeRatio);
|
|
291
392
|
|
|
292
393
|
const s = stateRef.current;
|
|
293
394
|
if (s.phase === 'positioning') {
|
|
395
|
+
const gate = flashGateRef.current;
|
|
396
|
+
if (gate) {
|
|
397
|
+
// Flash-only. Advancing on a single good frame meant the screen
|
|
398
|
+
// flashed at a face that was merely passing through the right
|
|
399
|
+
// distance, before the "come closer / more light" guidance had any
|
|
400
|
+
// chance to show — and before lighting had actually been measured.
|
|
401
|
+
// Gestures don't need this because they take seconds and re-check
|
|
402
|
+
// framing throughout; nothing follows the flash, so it does.
|
|
403
|
+
const verdict = gate.update({
|
|
404
|
+
framed: s.positionGuidance == null,
|
|
405
|
+
lit: s.lightingGuidance == null,
|
|
406
|
+
lightingConfirmed: lightingSampledRef.current,
|
|
407
|
+
now: Date.now(),
|
|
408
|
+
});
|
|
409
|
+
if (s.positionGuidance) speak(positionGuidanceText(s.positionGuidance));
|
|
410
|
+
if (verdict.ready) startNextChallenge();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
294
413
|
// Advance only at the right distance AND with acceptable lighting.
|
|
295
414
|
if (s.positionGuidance == null && s.lightingGuidance == null) {
|
|
296
415
|
startNextChallenge();
|
|
@@ -328,7 +447,7 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
328
447
|
},
|
|
329
448
|
// checkGesture is defined below and stable via refs
|
|
330
449
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
331
|
-
[cancelTimer, checkPosition, config, speak, startNextChallenge, startTimer],
|
|
450
|
+
[cancelTimer, checkPosition, config, onFaceIntegrityBroken, speak, startNextChallenge, startTimer],
|
|
332
451
|
);
|
|
333
452
|
|
|
334
453
|
// checkGesture reads the current challenge + histories from refs.
|
|
@@ -374,7 +493,29 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
374
493
|
[onChallengePassed],
|
|
375
494
|
);
|
|
376
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Waits for a face detection that lands AFTER this call, up to `timeoutMs`.
|
|
498
|
+
*
|
|
499
|
+
* Deliberately not a recency check on the last-seen timestamp: a stamp from
|
|
500
|
+
* just before the face left still falls inside any short window, and that is
|
|
501
|
+
* exactly the blank-selfie case — the flash finishes, nobody is there, and the
|
|
502
|
+
* shutter fires on an empty frame anyway. Demanding a NEW detection, with the
|
|
503
|
+
* overlay gone and detection reliable again, means the face has to be there
|
|
504
|
+
* now rather than have been there a moment ago.
|
|
505
|
+
*/
|
|
506
|
+
const awaitFreshFace = useCallback(async (timeoutMs: number): Promise<boolean> => {
|
|
507
|
+
const since = Date.now();
|
|
508
|
+
const deadline = since + timeoutMs;
|
|
509
|
+
while (Date.now() < deadline) {
|
|
510
|
+
const seen = lastFaceSeenRef.current;
|
|
511
|
+
if (seen !== null && seen > since) return true;
|
|
512
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}, []);
|
|
516
|
+
|
|
377
517
|
const onNoFace = useCallback(() => {
|
|
518
|
+
continuityRef.current.reportNoFace();
|
|
378
519
|
if (!stateRef.current.faceDetected) return;
|
|
379
520
|
if (isTerminal(stateRef.current.phase)) return;
|
|
380
521
|
setState((s) => ({ ...s, faceDetected: false, positionGuidance: null, multipleFaces: false }));
|
|
@@ -382,12 +523,15 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
382
523
|
|
|
383
524
|
const setLighting = useCallback(
|
|
384
525
|
(guidance: LightingGuidance | null) => {
|
|
526
|
+
// The sampler has produced a real reading — "no warning" now means
|
|
527
|
+
// measured-OK rather than not-yet-known.
|
|
528
|
+
lightingSampledRef.current = true;
|
|
385
529
|
const prev = stateRef.current.lightingGuidance;
|
|
386
530
|
const phase = stateRef.current.phase;
|
|
387
531
|
setState((s) => {
|
|
388
532
|
if (guidance === s.lightingGuidance) return s;
|
|
389
533
|
// Lighting only matters before/while reaching capture.
|
|
390
|
-
if (s.phase
|
|
534
|
+
if (isSettled(s.phase) || isTerminal(s.phase)) {
|
|
391
535
|
return s.lightingGuidance != null ? { ...s, lightingGuidance: null } : s;
|
|
392
536
|
}
|
|
393
537
|
return { ...s, lightingGuidance: guidance };
|
|
@@ -433,6 +577,14 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
433
577
|
processingRef.current = false;
|
|
434
578
|
readyFiredRef.current = false;
|
|
435
579
|
speakerRef.current?.reset();
|
|
580
|
+
// A retry is a fresh session: whoever is in frame now becomes the reference.
|
|
581
|
+
continuityRef.current.reset();
|
|
582
|
+
flashGateRef.current?.reset();
|
|
583
|
+
lastFaceSeenRef.current = null;
|
|
584
|
+
integrityBrokenRef.current = false;
|
|
585
|
+
// A retry runs the whole check again, INCLUDING a fresh colour sequence —
|
|
586
|
+
// reusing the previous one would hand an attacker the answer.
|
|
587
|
+
flash.reset();
|
|
436
588
|
setState({
|
|
437
589
|
phase: 'positioning',
|
|
438
590
|
instruction: 'Position your face in the circle',
|
|
@@ -457,6 +609,21 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
457
609
|
}
|
|
458
610
|
}, [state.phase, onReadyToCapture]);
|
|
459
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Surfaces a failure detected during `capturing` and deliberately withheld.
|
|
614
|
+
*
|
|
615
|
+
* Called by the capture path once the still is done, so the re-layout happens
|
|
616
|
+
* against a settled screen. The Flutter SDK learned this the hard way: failing
|
|
617
|
+
* mid-capture moved the preview circle out from under the flash overlay's
|
|
618
|
+
* cutout, which is measured once when the flash starts.
|
|
619
|
+
*/
|
|
620
|
+
const reportIntegrityFailure = useCallback(() => {
|
|
621
|
+
if (!integrityBrokenRef.current) return;
|
|
622
|
+
if (stateRef.current.phase === 'complete') return;
|
|
623
|
+
cancelTimer();
|
|
624
|
+
setState((s) => ({ ...s, ...INTEGRITY_FAILED_PATCH }));
|
|
625
|
+
}, [cancelTimer]);
|
|
626
|
+
|
|
460
627
|
// Cleanup timers + speech on unmount.
|
|
461
628
|
useEffect(() => {
|
|
462
629
|
return () => {
|
|
@@ -471,12 +638,30 @@ export function useLiveness(opts: UseLivenessOptions = {}): UseLivenessReturn {
|
|
|
471
638
|
...state,
|
|
472
639
|
onFace,
|
|
473
640
|
onNoFace,
|
|
641
|
+
awaitFreshFace,
|
|
474
642
|
setLighting,
|
|
475
643
|
markComplete,
|
|
476
644
|
reset,
|
|
477
645
|
shouldCapture: state.phase === 'capturing',
|
|
646
|
+
shouldFlash: state.phase === 'flash',
|
|
647
|
+
completeFlash: flash.complete,
|
|
648
|
+
flashResult: flash.result(),
|
|
649
|
+
faceGlitches: flash.glitches(),
|
|
650
|
+
// The flash IS a step, so it gets a dot. Without this the indicator
|
|
651
|
+
// claimed the check was finished while the longest, most visible part of
|
|
652
|
+
// it was still to come — and in flash-only mode it showed no steps at all.
|
|
653
|
+
totalCount: state.totalCount + (flashIsStep ? 1 : 0),
|
|
654
|
+
completedCount: state.completedCount + (flashIsStep && flash.result() !== null ? 1 : 0),
|
|
655
|
+
integrityBroken: integrityBrokenRef.current,
|
|
656
|
+
// Read through a function, not a snapshot: integrity is recorded in a ref
|
|
657
|
+
// (deliberately — re-laying out mid-flash is its own class of bug), and a
|
|
658
|
+
// ref write does not re-render. Anything consulting it from a callback
|
|
659
|
+
// created BEFORE the flash would otherwise read the pre-flash value, which
|
|
660
|
+
// is precisely the window a substitution happens in.
|
|
661
|
+
isCompromised: () => integrityBrokenRef.current,
|
|
662
|
+
reportIntegrityFailure,
|
|
478
663
|
}),
|
|
479
|
-
[state, onFace, onNoFace, setLighting, markComplete, reset],
|
|
664
|
+
[state, onFace, onNoFace, awaitFreshFace, setLighting, markComplete, reset, reportIntegrityFailure, flash],
|
|
480
665
|
);
|
|
481
666
|
}
|
|
482
667
|
|
|
@@ -66,6 +66,20 @@ function toFaceData(raw: FaceResult | null): LivenessFaceData | null {
|
|
|
66
66
|
faceSizeRatio: raw.faceSizeRatio,
|
|
67
67
|
faceCount: raw.faceCount,
|
|
68
68
|
brightness: raw.brightness,
|
|
69
|
+
// −1 is the natives' "not available" sentinel (iOS Vision has no tracking
|
|
70
|
+
// id; neither reports a centre for the no-face frame). Map it to undefined
|
|
71
|
+
// so the continuity guard sees an absent signal rather than a real value of
|
|
72
|
+
// −1 sitting outside the 0–1 range.
|
|
73
|
+
faceCenterX: raw.faceCenterX >= 0 ? raw.faceCenterX : undefined,
|
|
74
|
+
faceCenterY: raw.faceCenterY >= 0 ? raw.faceCenterY : undefined,
|
|
75
|
+
trackingId: raw.trackingId >= 0 ? raw.trackingId : undefined,
|
|
76
|
+
// −1 is the native "could not sample" sentinel; mapping it to undefined
|
|
77
|
+
// keeps "unknown" out of the arithmetic, where it would read as pure black
|
|
78
|
+
// and manufacture a colour shift.
|
|
79
|
+
faceRgb:
|
|
80
|
+
raw.faceR >= 0 && raw.faceG >= 0 && raw.faceB >= 0
|
|
81
|
+
? ([raw.faceR, raw.faceG, raw.faceB] as const)
|
|
82
|
+
: undefined,
|
|
69
83
|
};
|
|
70
84
|
}
|
|
71
85
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseMrz, type MrzScan } from './parse';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Pulling an MRZ out of whatever the text recogniser returned.
|
|
5
|
+
//
|
|
6
|
+
// OCR output is messy: it may return the MRZ lines separately, merge them into
|
|
7
|
+
// one, split one across two, or interleave them with the rest of the photo
|
|
8
|
+
// page. Rather than trusting layout, this sanitises every line, keeps the
|
|
9
|
+
// MRZ-shaped ones, and tries each plausible grouping.
|
|
10
|
+
//
|
|
11
|
+
// Guessing is safe because CORRECTNESS COMES FROM THE CHECK DIGITS, not from
|
|
12
|
+
// this file. Anything a grouping produces still has to validate in `parseMrz`,
|
|
13
|
+
// so a wrong guess is discarded rather than believed.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Strips everything that cannot appear in an MRZ and upper-cases the rest. */
|
|
17
|
+
export function sanitizeMrzLine(raw: string): string {
|
|
18
|
+
return (
|
|
19
|
+
raw
|
|
20
|
+
.toUpperCase()
|
|
21
|
+
// ML Kit's latin model (Android) reads OCR-B's '<' filler as guillemets
|
|
22
|
+
// constantly — '«' for a '<<' pair, '‹' for a single. Stripping them (the
|
|
23
|
+
// old behaviour) SHORTENED the line past what `fit` tolerates, which is a
|
|
24
|
+
// big part of why Android never had the MRZ after the first capture.
|
|
25
|
+
// Mapping is safe here for the same reason every guess in this file is:
|
|
26
|
+
// the check digits decide, not us.
|
|
27
|
+
.replace(/«/g, '<<')
|
|
28
|
+
.replace(/»/g, '<<')
|
|
29
|
+
.replace(/[‹›]/g, '<')
|
|
30
|
+
.replace(/[^A-Z0-9<]/g, '')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether a line looks like part of an MRZ rather than ordinary page text.
|
|
36
|
+
*
|
|
37
|
+
* MRZ lines are filler-padded, so `<` density is the strongest signal — far
|
|
38
|
+
* more reliable than length alone, since a passport's printed fields produce
|
|
39
|
+
* plenty of long lines.
|
|
40
|
+
*/
|
|
41
|
+
export function looksLikeMrzLine(sanitized: string): boolean {
|
|
42
|
+
if (sanitized.length < 28) return false;
|
|
43
|
+
const fillers = (sanitized.match(/</g) ?? []).length;
|
|
44
|
+
return fillers >= 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Coerce a candidate to exactly `width`.
|
|
49
|
+
*
|
|
50
|
+
* Recognisers commonly clip a trailing filler or bolt on a stray glyph from the
|
|
51
|
+
* page edge, so a near-miss is worth one attempt — the check digits reject it
|
|
52
|
+
* if the guess was wrong, which costs nothing but a frame.
|
|
53
|
+
*/
|
|
54
|
+
function fit(line: string, width: number): string | null {
|
|
55
|
+
if (line.length === width) return line;
|
|
56
|
+
// Short by a filler or two: MRZ lines are '<'-padded on the right.
|
|
57
|
+
if (line.length >= width - 2 && line.length < width) return line.padEnd(width, '<');
|
|
58
|
+
// Long by a stray glyph or three: drop the trailing noise.
|
|
59
|
+
if (line.length > width && line.length <= width + 3) return line.slice(0, width);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read an MRZ out of one frame's recognised lines, or null when this frame does
|
|
65
|
+
* not carry a complete, valid one.
|
|
66
|
+
*/
|
|
67
|
+
export function extractMrz(recognizedLines: string[], now: Date = new Date()): MrzScan | null {
|
|
68
|
+
const candidates = recognizedLines
|
|
69
|
+
.map(sanitizeMrzLine)
|
|
70
|
+
.filter(looksLikeMrzLine);
|
|
71
|
+
if (candidates.length === 0) return null;
|
|
72
|
+
|
|
73
|
+
// 1) A single line already holding the whole MRZ (some recognisers merge).
|
|
74
|
+
for (const line of candidates) {
|
|
75
|
+
if (line.length === 88 || line.length === 90) {
|
|
76
|
+
const scan = parseMrz(line, now);
|
|
77
|
+
if (scan) return scan;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2) TD3 — two adjacent 44-char lines.
|
|
82
|
+
for (let i = 0; i + 1 < candidates.length; i++) {
|
|
83
|
+
const a = fit(candidates[i]!, 44);
|
|
84
|
+
const b = fit(candidates[i + 1]!, 44);
|
|
85
|
+
if (!a || !b) continue;
|
|
86
|
+
const scan = parseMrz(a + b, now);
|
|
87
|
+
if (scan) return scan;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3) TD1 — three adjacent 30-char lines.
|
|
91
|
+
for (let i = 0; i + 2 < candidates.length; i++) {
|
|
92
|
+
const a = fit(candidates[i]!, 30);
|
|
93
|
+
const b = fit(candidates[i + 1]!, 30);
|
|
94
|
+
const c = fit(candidates[i + 2]!, 30);
|
|
95
|
+
if (!a || !b || !c) continue;
|
|
96
|
+
const scan = parseMrz(a + b + c, now);
|
|
97
|
+
if (scan) return scan;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|