@myazahq/kyc-sdk-react-native 2.0.1 → 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/CameraPermissionView.tsx +39 -3
- 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 +98 -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 +190 -145
- package/src/screens/IdInputStep.tsx +19 -9
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/LivenessStep.tsx +181 -267
- 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 +3 -2
- package/src/services/validators.ts +3 -0
- 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 -119
- 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
|
@@ -16,7 +16,8 @@ import { withRetry } from '../services/retry';
|
|
|
16
16
|
import { mapToKycError, safeReportError } from '../services/errors';
|
|
17
17
|
import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
|
|
18
18
|
import { KYCError } from '../types/verification';
|
|
19
|
-
import {
|
|
19
|
+
import { useStore } from 'zustand';
|
|
20
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
20
21
|
import { MyazaText } from '../components/Typography';
|
|
21
22
|
import { MyazaButton } from '../components/MyazaButton';
|
|
22
23
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
@@ -29,9 +30,27 @@ import {
|
|
|
29
30
|
} from '../config/captureSettings';
|
|
30
31
|
import { Icon } from '../components/Icon';
|
|
31
32
|
import { useToast } from '../components/toast';
|
|
32
|
-
import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
|
|
33
|
+
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
34
|
+
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
35
|
+
import { READY_LIVENESS } from '../components/readyPrimerContent';
|
|
33
36
|
import { LivenessAvatar } from './LivenessAvatar';
|
|
34
37
|
import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
|
|
38
|
+
import { buildLivenessIntegrity } from '../liveness/integritySignals';
|
|
39
|
+
import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
|
|
40
|
+
import { useFlashSequence } from './useFlashSequence';
|
|
41
|
+
import { useFlashHole } from './useFlashHole';
|
|
42
|
+
import {
|
|
43
|
+
FRESH_FACE_TIMEOUT_MS,
|
|
44
|
+
INSTRUCTION_HEIGHT,
|
|
45
|
+
LightingBanner,
|
|
46
|
+
ProgressDots,
|
|
47
|
+
SelfiePreview,
|
|
48
|
+
StyleAbsFill,
|
|
49
|
+
resolveGuidance,
|
|
50
|
+
useSelfieUpload,
|
|
51
|
+
LivenessComplete,
|
|
52
|
+
LivenessFailed,
|
|
53
|
+
} from './liveness';
|
|
35
54
|
import {
|
|
36
55
|
lightingGuidanceText,
|
|
37
56
|
positionGuidanceText,
|
|
@@ -63,6 +82,7 @@ export function LivenessStep(): React.ReactElement {
|
|
|
63
82
|
const config = useKycConfig();
|
|
64
83
|
const api = useKyc((s) => s.api);
|
|
65
84
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
85
|
+
const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
|
|
66
86
|
const nextStep = useKyc((s) => s.nextStep);
|
|
67
87
|
|
|
68
88
|
const device = useCameraDevice('front');
|
|
@@ -85,10 +105,14 @@ export function LivenessStep(): React.ReactElement {
|
|
|
85
105
|
});
|
|
86
106
|
const videoRecorder = useVideoRecorder(videoOutput, !!device);
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
109
|
+
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
110
|
+
// "Grant access". `perm` is driven by the async requestPermission result, not
|
|
111
|
+
// synchronously from `hasPermission` — otherwise the window while the OS prompt
|
|
112
|
+
// is open (hasPermission still false) would read as "denied" and fire onError.
|
|
113
|
+
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
114
|
+
hasPermission ? 'granted' : 'priming',
|
|
90
115
|
);
|
|
91
|
-
const askedRef = useRef(false);
|
|
92
116
|
const permReportedRef = useRef(false);
|
|
93
117
|
|
|
94
118
|
// Camera-availability grace (a simulator has no front camera).
|
|
@@ -98,20 +122,28 @@ export function LivenessStep(): React.ReactElement {
|
|
|
98
122
|
return () => clearTimeout(t);
|
|
99
123
|
}, []);
|
|
100
124
|
const cameraUnavailable = cameraGrace && !device;
|
|
125
|
+
// Gate the whole camera path on the user acknowledging the primer.
|
|
126
|
+
const [ready, setReady] = useState(false);
|
|
127
|
+
const showPrimer = perm === 'priming' && !!device;
|
|
101
128
|
const permissionDenied = perm === 'denied' && !!device;
|
|
102
129
|
|
|
130
|
+
// Reflect an externally-granted permission.
|
|
103
131
|
useEffect(() => {
|
|
104
|
-
if (hasPermission)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
if (hasPermission) setPerm('granted');
|
|
133
|
+
}, [hasPermission]);
|
|
134
|
+
|
|
135
|
+
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (perm !== 'requesting') return;
|
|
138
|
+
let cancelled = false;
|
|
110
139
|
void (async () => {
|
|
111
140
|
const granted = await requestPermission();
|
|
112
|
-
setPerm(granted ? 'granted' : 'denied');
|
|
141
|
+
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
113
142
|
})();
|
|
114
|
-
|
|
143
|
+
return () => {
|
|
144
|
+
cancelled = true;
|
|
145
|
+
};
|
|
146
|
+
}, [perm, requestPermission]);
|
|
115
147
|
|
|
116
148
|
useEffect(() => {
|
|
117
149
|
if (permissionDenied && !permReportedRef.current) {
|
|
@@ -124,56 +156,19 @@ export function LivenessStep(): React.ReactElement {
|
|
|
124
156
|
if (!permissionDenied) permReportedRef.current = false;
|
|
125
157
|
}, [permissionDenied, config.onError]);
|
|
126
158
|
|
|
127
|
-
//
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
setUploadError(null);
|
|
141
|
-
setRetryInfo(null);
|
|
142
|
-
const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
|
|
143
|
-
try {
|
|
144
|
-
const selfieId = await withRetry(
|
|
145
|
-
() => api.upload({ uri: selfie, type: 'image/jpeg' }, 'selfie'),
|
|
146
|
-
{ onRetry },
|
|
147
|
-
);
|
|
148
|
-
selfieIdRef.current = selfieId;
|
|
149
|
-
setMediaId('selfie', selfieId);
|
|
150
|
-
// Liveness video is best-effort — a failure here must not block the user.
|
|
151
|
-
if (videoPath) {
|
|
152
|
-
try {
|
|
153
|
-
// Transcode the raw recording down to a small evidence clip first.
|
|
154
|
-
const small = await compressVideo(videoPath);
|
|
155
|
-
const videoId = await withRetry(
|
|
156
|
-
() => api.upload({ uri: small, type: 'video/mp4' }, 'liveness_video', MAX_VIDEO_BYTES),
|
|
157
|
-
{ onRetry },
|
|
158
|
-
);
|
|
159
|
-
setMediaId('livenessVideo', videoId);
|
|
160
|
-
} catch {
|
|
161
|
-
/* keep the selfie, drop the video (incl. if it exceeded the 5MB cap) */
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
setRetryInfo(null);
|
|
165
|
-
setUploading(false);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
setRetryInfo(null);
|
|
168
|
-
setUploading(false);
|
|
169
|
-
const kycError = mapToKycError(err, 'upload');
|
|
170
|
-
setUploadError(kycError.message);
|
|
171
|
-
toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
|
|
172
|
-
safeReportError(config.onError, kycError);
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
[api, setMediaId, config.onError, toast],
|
|
176
|
-
);
|
|
159
|
+
// Selfie + liveness-video upload (see ./liveness/useSelfieUpload).
|
|
160
|
+
const upload = useSelfieUpload();
|
|
161
|
+
const {
|
|
162
|
+
selfieUri,
|
|
163
|
+
setSelfieUri,
|
|
164
|
+
uploading,
|
|
165
|
+
retryInfo,
|
|
166
|
+
uploadError,
|
|
167
|
+
setUploadError,
|
|
168
|
+
selfieIdRef,
|
|
169
|
+
videoPathRef,
|
|
170
|
+
uploadSelfieAndVideo,
|
|
171
|
+
} = upload;
|
|
177
172
|
|
|
178
173
|
// ── Liveness state machine ─────────────────────────────────────────────────
|
|
179
174
|
// `handleCapture` is created after `liveness` (it calls liveness.markComplete),
|
|
@@ -181,7 +176,16 @@ export function LivenessStep(): React.ReactElement {
|
|
|
181
176
|
const captureRef = useRef<() => void>(() => {});
|
|
182
177
|
const liveness = useLiveness({
|
|
183
178
|
voiceGuidance: config.voiceGuidance,
|
|
179
|
+
// Silent until the primer is dismissed and the camera is actually up. The
|
|
180
|
+
// machine is constructed behind the primer, so without this it talks to a
|
|
181
|
+
// user who has not started yet.
|
|
182
|
+
announce: ready && !showPrimer,
|
|
184
183
|
onReadyToCapture: () => captureRef.current(),
|
|
184
|
+
config: {
|
|
185
|
+
...DEFAULT_LIVENESS_CONFIG,
|
|
186
|
+
mode: config.livenessMode ?? 'gestures',
|
|
187
|
+
flashSequenceLength: config.flashSequenceLength,
|
|
188
|
+
},
|
|
185
189
|
});
|
|
186
190
|
|
|
187
191
|
const handleCapture = useCallback(async () => {
|
|
@@ -196,10 +200,46 @@ export function LivenessStep(): React.ReactElement {
|
|
|
196
200
|
await new Promise((r) => setTimeout(r, SELFIE_SETTLE_MS));
|
|
197
201
|
// v5: capture the selfie via the photo output (no camera ref). filePath is
|
|
198
202
|
// a plain fs path — prepend file:// for the uploader/compressor.
|
|
203
|
+
// A different face appeared while the machine was in `capturing`. The
|
|
204
|
+
// verdict was recorded rather than shown (nothing may re-lay out mid
|
|
205
|
+
// capture), so it is acted on here — BEFORE the shutter, so no still of a
|
|
206
|
+
// stranger is ever taken, let alone uploaded.
|
|
207
|
+
// Live read. `liveness.integrityBroken` is a snapshot from the render
|
|
208
|
+
// this callback was created in — i.e. from BEFORE the flash — so a face
|
|
209
|
+
// swapped or added during the sequence was recorded and then never seen
|
|
210
|
+
// here.
|
|
211
|
+
if (liveness.isCompromised()) {
|
|
212
|
+
liveness.reportIntegrityFailure();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Nobody in frame? Do not photograph an empty room.
|
|
216
|
+
//
|
|
217
|
+
// The flash can leave the face briefly undetectable, so this waits for a
|
|
218
|
+
// FRESH detection now that the overlay is gone rather than trusting a
|
|
219
|
+
// timestamp from before it started — a stale stamp from just before the
|
|
220
|
+
// user walked away passes any recency check, which is precisely how a
|
|
221
|
+
// blank selfie got captured and submitted as a passing liveness result.
|
|
222
|
+
//
|
|
223
|
+
// Losing the face is not a failure, just an abandoned attempt: drop back
|
|
224
|
+
// to positioning with the camera still running, so the user re-frames and
|
|
225
|
+
// the gate fires again with no teardown and no permission re-prompt.
|
|
226
|
+
if (!(await liveness.awaitFreshFace(FRESH_FACE_TIMEOUT_MS))) {
|
|
227
|
+
liveness.reset();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
199
230
|
const file = await photoOutput.capturePhotoToFile({ flashMode: 'off' }, {});
|
|
200
231
|
const raw = `file://${file.filePath}`;
|
|
201
232
|
const compressed = await compressSelfieImage(raw).catch(() => raw);
|
|
202
233
|
setSelfieUri(compressed);
|
|
234
|
+
// Recorded at capture, not at submit: by then the liveness hook is gone
|
|
235
|
+
// and its flash result with it.
|
|
236
|
+
setCaptureIntegrity({
|
|
237
|
+
liveness: buildLivenessIntegrity(
|
|
238
|
+
config.livenessMode ?? 'gestures',
|
|
239
|
+
liveness.faceGlitches,
|
|
240
|
+
liveness.flashResult,
|
|
241
|
+
),
|
|
242
|
+
});
|
|
203
243
|
liveness.markComplete();
|
|
204
244
|
// Stop the liveness video (best-effort) and start the eager upload. The
|
|
205
245
|
// recorder resolves the finished file path (or null if nothing recorded);
|
|
@@ -210,7 +250,7 @@ export function LivenessStep(): React.ReactElement {
|
|
|
210
250
|
} catch {
|
|
211
251
|
liveness.reset();
|
|
212
252
|
}
|
|
213
|
-
}, [device, liveness, uploadSelfieAndVideo, photoOutput, videoRecorder]);
|
|
253
|
+
}, [device, liveness, uploadSelfieAndVideo, photoOutput, videoRecorder, setCaptureIntegrity, config.livenessMode]);
|
|
214
254
|
|
|
215
255
|
useEffect(() => {
|
|
216
256
|
captureRef.current = () => void handleCapture();
|
|
@@ -232,20 +272,61 @@ export function LivenessStep(): React.ReactElement {
|
|
|
232
272
|
// with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
|
|
233
273
|
// pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
|
|
234
274
|
// function" error when called from the frame-processor worklet.
|
|
275
|
+
// The most recent face-region RGB, for the flash sequence. A ref rather than
|
|
276
|
+
// state: this updates every frame, and re-rendering the camera preview at
|
|
277
|
+
// frame rate would be both wasteful and — during a flash — actively harmful,
|
|
278
|
+
// since the overlay's cutout is measured once.
|
|
279
|
+
const faceRgbRef = useRef<readonly [number, number, number] | null>(null);
|
|
280
|
+
|
|
235
281
|
const handleFace = useCallback((data: LivenessFaceData | null) => {
|
|
236
282
|
const l = livenessRef.current;
|
|
237
283
|
if (!data) {
|
|
284
|
+
faceRgbRef.current = null;
|
|
238
285
|
l.onNoFace();
|
|
239
286
|
return;
|
|
240
287
|
}
|
|
288
|
+
faceRgbRef.current = data.faceRgb ?? null;
|
|
241
289
|
// Low-light gate (thresholds mirror Flutter's _BrightnessSampler: <62 dark,
|
|
242
290
|
// >200 bright). Feeds the lighting warning + speech and blocks challenges so
|
|
243
291
|
// gestures don't misbehave in poor light.
|
|
244
292
|
l.setLighting(data.brightness < 62 ? 'dark' : data.brightness > 200 ? 'bright' : null);
|
|
245
293
|
if (data.faceCount > 0) l.onFace(data);
|
|
246
|
-
else
|
|
294
|
+
else {
|
|
295
|
+
faceRgbRef.current = null;
|
|
296
|
+
l.onNoFace();
|
|
297
|
+
}
|
|
247
298
|
}, []);
|
|
248
299
|
|
|
300
|
+
|
|
301
|
+
// Hoisted above this component's several early returns — it is a hook, and
|
|
302
|
+
// calling it further down (next to the circle it measures, where it reads
|
|
303
|
+
// better) would make it conditional on which branch rendered.
|
|
304
|
+
const { ref: flashHoleRef, hole: flashHole } = useFlashHole(liveness.phase === 'flash');
|
|
305
|
+
|
|
306
|
+
const { flashColor } = useFlashSequence({
|
|
307
|
+
active: liveness.shouldFlash,
|
|
308
|
+
// Stop painting the moment a second face appears or the face is swapped:
|
|
309
|
+
// finishing the sequence would produce a reflection measurement for a face
|
|
310
|
+
// that is no longer the subject.
|
|
311
|
+
shouldContinue: () => !livenessRef.current.isCompromised(),
|
|
312
|
+
sequenceLength: config.flashSequenceLength,
|
|
313
|
+
readFaceRgb: () => faceRgbRef.current,
|
|
314
|
+
onComplete: (result) => livenessRef.current.completeFlash(result),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Published to the sheet root rather than drawn in this step: an overlay here
|
|
318
|
+
// lights only the padded body, and the screen IS the light source. The sheet
|
|
319
|
+
// root is still the SAME tree as the preview, so the cutout and the preview
|
|
320
|
+
// scale together under any UIKit transform.
|
|
321
|
+
const flashStore = useKycStore();
|
|
322
|
+
const setFlashPaint = useStore(flashStore, (st) => st.setFlashPaint);
|
|
323
|
+
const isFlashing = liveness.phase === 'flash';
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
setFlashPaint(isFlashing ? { color: flashColor, hole: flashHole } : null);
|
|
326
|
+
}, [isFlashing, flashColor, flashHole, setFlashPaint]);
|
|
327
|
+
// Clear on unmount so a colour can never outlive the step.
|
|
328
|
+
useEffect(() => () => setFlashPaint(null), [setFlashPaint]);
|
|
329
|
+
|
|
249
330
|
const frameOutput = useFrameOutput({
|
|
250
331
|
// ML Kit (Android) and Apple Vision both consume YUV efficiently.
|
|
251
332
|
pixelFormat: 'yuv',
|
|
@@ -270,81 +351,40 @@ export function LivenessStep(): React.ReactElement {
|
|
|
270
351
|
if (cameraUnavailable) {
|
|
271
352
|
return <CameraUnavailableView />;
|
|
272
353
|
}
|
|
354
|
+
// "Here's what happens next", BEFORE the OS prompt — and shown even when
|
|
355
|
+
// permission is already granted, because it is about what the step will ask
|
|
356
|
+
// of the user, not about access. Opening the camera unannounced is what makes
|
|
357
|
+
// people fumble the first attempt.
|
|
358
|
+
if (!ready) {
|
|
359
|
+
return <ReadyPrimer content={READY_LIVENESS} onReady={() => setReady(true)} />;
|
|
360
|
+
}
|
|
361
|
+
if (showPrimer) {
|
|
362
|
+
return <CameraPermissionPrimingView onGrant={() => setPerm('requesting')} />;
|
|
363
|
+
}
|
|
273
364
|
if (permissionDenied) {
|
|
274
|
-
return (
|
|
275
|
-
<CameraPermissionView
|
|
276
|
-
onRetry={() => {
|
|
277
|
-
askedRef.current = false;
|
|
278
|
-
setPerm('checking');
|
|
279
|
-
}}
|
|
280
|
-
/>
|
|
281
|
-
);
|
|
365
|
+
return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
|
|
282
366
|
}
|
|
283
367
|
|
|
284
368
|
// ── Review (selfie captured) ─────────────────────────────────────────────────
|
|
285
369
|
if (liveness.phase === 'complete' && selfieUri) {
|
|
286
370
|
return (
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
loading={uploading}
|
|
300
|
-
onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
|
|
301
|
-
/>
|
|
302
|
-
) : (
|
|
303
|
-
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
|
304
|
-
<View style={{ flex: 1 }}>
|
|
305
|
-
<MyazaButton
|
|
306
|
-
label="Retake"
|
|
307
|
-
variant="outline"
|
|
308
|
-
leadingIcon="refresh"
|
|
309
|
-
disabled={uploading}
|
|
310
|
-
onPress={() => {
|
|
311
|
-
setSelfieUri(null);
|
|
312
|
-
setUploadError(null);
|
|
313
|
-
selfieIdRef.current = null;
|
|
314
|
-
videoPathRef.current = null;
|
|
315
|
-
liveness.reset();
|
|
316
|
-
}}
|
|
317
|
-
/>
|
|
318
|
-
</View>
|
|
319
|
-
<View style={{ flex: 1 }}>
|
|
320
|
-
<MyazaButton
|
|
321
|
-
label="Continue"
|
|
322
|
-
loading={uploading}
|
|
323
|
-
disabled={uploading || !selfieIdRef.current}
|
|
324
|
-
onPress={nextStep}
|
|
325
|
-
/>
|
|
326
|
-
</View>
|
|
327
|
-
</View>
|
|
328
|
-
)}
|
|
329
|
-
</View>
|
|
371
|
+
<LivenessComplete
|
|
372
|
+
selfieUri={selfieUri}
|
|
373
|
+
upload={upload}
|
|
374
|
+
onRetake={() => {
|
|
375
|
+
setSelfieUri(null);
|
|
376
|
+
setUploadError(null);
|
|
377
|
+
selfieIdRef.current = null;
|
|
378
|
+
videoPathRef.current = null;
|
|
379
|
+
liveness.reset();
|
|
380
|
+
}}
|
|
381
|
+
onContinue={nextStep}
|
|
382
|
+
/>
|
|
330
383
|
);
|
|
331
384
|
}
|
|
332
385
|
|
|
333
|
-
// ── Failed (timeout / face lost) — mirrors the Flutter failed view: a centred
|
|
334
|
-
// red message and a full-width "Try Again" button (no icon). ───────────────
|
|
335
386
|
if (liveness.phase === 'failed') {
|
|
336
|
-
return
|
|
337
|
-
<View style={{ alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl, gap: spacing.lg }}>
|
|
338
|
-
<MyazaText variant="bodyMedium" color={colors.error} style={{ textAlign: 'center', fontWeight: '500' }}>
|
|
339
|
-
{liveness.failureReason === 'timeout'
|
|
340
|
-
? "Time's up. Let's try again."
|
|
341
|
-
: 'Face lost. Please try again.'}
|
|
342
|
-
</MyazaText>
|
|
343
|
-
<View style={{ alignSelf: 'stretch' }}>
|
|
344
|
-
<MyazaButton label="Try Again" onPress={liveness.reset} />
|
|
345
|
-
</View>
|
|
346
|
-
</View>
|
|
347
|
-
);
|
|
387
|
+
return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
|
|
348
388
|
}
|
|
349
389
|
|
|
350
390
|
// ── Live camera (circular) + ring state machine + guidance ──────────────────
|
|
@@ -385,15 +425,23 @@ export function LivenessStep(): React.ReactElement {
|
|
|
385
425
|
const CIRCLE = Math.min(Dimensions.get('window').width - spacing.md * 4, 300);
|
|
386
426
|
const lighting = liveness.lightingGuidance;
|
|
387
427
|
|
|
428
|
+
// Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
|
|
429
|
+
// stay visible throughout — only the flash itself goes full-screen.
|
|
388
430
|
return (
|
|
389
431
|
<View style={{ alignItems: 'center', gap: spacing.md }}>
|
|
390
432
|
{/* Instruction text — above the circle */}
|
|
391
|
-
<MyazaText
|
|
392
|
-
|
|
433
|
+
<MyazaText
|
|
434
|
+
variant="heading3"
|
|
435
|
+
style={{ textAlign: 'center', minHeight: INSTRUCTION_HEIGHT }}
|
|
436
|
+
color={instrColor}
|
|
437
|
+
>
|
|
438
|
+
{phase === 'flash' ? '' : guidance.text}
|
|
393
439
|
</MyazaText>
|
|
394
440
|
|
|
395
441
|
{/* Circular camera with a thick colour-state ring */}
|
|
396
442
|
<View
|
|
443
|
+
ref={flashHoleRef}
|
|
444
|
+
collapsable={false}
|
|
397
445
|
style={{
|
|
398
446
|
width: CIRCLE,
|
|
399
447
|
height: CIRCLE,
|
|
@@ -472,144 +520,10 @@ export function LivenessStep(): React.ReactElement {
|
|
|
472
520
|
{lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
|
|
473
521
|
|
|
474
522
|
{/* Numbered progress dots with connectors */}
|
|
475
|
-
<ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning'} />
|
|
523
|
+
<ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
|
|
476
524
|
|
|
477
525
|
{/* Gesture demo avatar */}
|
|
478
526
|
{liveness.activeChallenge ? <LivenessAvatar challenge={liveness.activeChallenge} /> : null}
|
|
479
527
|
</View>
|
|
480
528
|
);
|
|
481
529
|
}
|
|
482
|
-
|
|
483
|
-
const StyleAbsFill = { position: 'absolute' as const, top: 0, left: 0, right: 0, bottom: 0 };
|
|
484
|
-
|
|
485
|
-
// Lighting warning — 1:1 with the Flutter `_LightingWarningBanner`: amber-50 bg,
|
|
486
|
-
// amber-200 border, amber-800 lightbulb + text, fading/sliding in (~300ms).
|
|
487
|
-
const AMBER_50 = '#FFFBEB';
|
|
488
|
-
const AMBER_200 = '#FDE68A';
|
|
489
|
-
const AMBER_800 = '#92400E';
|
|
490
|
-
|
|
491
|
-
function LightingBanner({ text }: { text: string }): React.ReactElement {
|
|
492
|
-
const anim = useRef(new Animated.Value(0)).current;
|
|
493
|
-
useEffect(() => {
|
|
494
|
-
Animated.timing(anim, { toValue: 1, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
|
|
495
|
-
}, [anim]);
|
|
496
|
-
return (
|
|
497
|
-
<Animated.View
|
|
498
|
-
style={{
|
|
499
|
-
alignSelf: 'stretch',
|
|
500
|
-
opacity: anim,
|
|
501
|
-
transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] }) }],
|
|
502
|
-
}}
|
|
503
|
-
>
|
|
504
|
-
<View
|
|
505
|
-
style={{
|
|
506
|
-
flexDirection: 'row',
|
|
507
|
-
alignItems: 'flex-start',
|
|
508
|
-
gap: spacing.sm,
|
|
509
|
-
borderRadius: radius.sm,
|
|
510
|
-
borderWidth: 1,
|
|
511
|
-
borderColor: AMBER_200,
|
|
512
|
-
backgroundColor: AMBER_50,
|
|
513
|
-
paddingHorizontal: spacing.sm + 4,
|
|
514
|
-
paddingVertical: 10,
|
|
515
|
-
}}
|
|
516
|
-
>
|
|
517
|
-
<Icon name="lightbulb" size={16} color={AMBER_800} />
|
|
518
|
-
<MyazaText variant="bodySmall" color={AMBER_800} style={{ flexShrink: 1, lineHeight: 17 }}>
|
|
519
|
-
{text}
|
|
520
|
-
</MyazaText>
|
|
521
|
-
</View>
|
|
522
|
-
</Animated.View>
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** The single guidance string + tone to show under the camera. */
|
|
527
|
-
function resolveGuidance(l: ReturnType<typeof useLiveness>): { text: string; tone: 'normal' | 'error' } {
|
|
528
|
-
if (l.multipleFaces) return { text: l.instruction, tone: 'error' };
|
|
529
|
-
// Lighting is shown in its own banner (below), not as the main instruction.
|
|
530
|
-
if (l.phase === 'positioning' && !l.faceDetected) {
|
|
531
|
-
return { text: 'Position your face in the circle', tone: 'normal' };
|
|
532
|
-
}
|
|
533
|
-
if (l.positionGuidance) return { text: positionGuidanceText(l.positionGuidance), tone: 'error' };
|
|
534
|
-
if (l.wrongGesture) return { text: 'Wrong gesture — follow the prompt', tone: 'error' };
|
|
535
|
-
return { text: l.instruction, tone: 'normal' };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Numbered progress steps with connectors — mirrors the web/Flutter SDKs:
|
|
539
|
-
// passed = solid green + checkmark, active = green-outlined number, pending =
|
|
540
|
-
// grey number. `active` marks the current (completed-th) step as in-progress.
|
|
541
|
-
function ProgressDots({
|
|
542
|
-
total,
|
|
543
|
-
completed,
|
|
544
|
-
active,
|
|
545
|
-
}: {
|
|
546
|
-
total: number;
|
|
547
|
-
completed: number;
|
|
548
|
-
active: boolean;
|
|
549
|
-
}): React.ReactElement {
|
|
550
|
-
const { colors } = useTheme();
|
|
551
|
-
return (
|
|
552
|
-
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
|
553
|
-
{Array.from({ length: total }).map((_, i) => {
|
|
554
|
-
const state: 'passed' | 'active' | 'pending' =
|
|
555
|
-
i < completed ? 'passed' : i === completed && active ? 'active' : 'pending';
|
|
556
|
-
return (
|
|
557
|
-
<React.Fragment key={i}>
|
|
558
|
-
<View
|
|
559
|
-
style={{
|
|
560
|
-
width: 28,
|
|
561
|
-
height: 28,
|
|
562
|
-
borderRadius: 14,
|
|
563
|
-
alignItems: 'center',
|
|
564
|
-
justifyContent: 'center',
|
|
565
|
-
backgroundColor: state === 'passed' ? colors.success : state === 'active' ? colors.background : colors.gray300,
|
|
566
|
-
borderWidth: state === 'active' ? 2 : 0,
|
|
567
|
-
borderColor: colors.success,
|
|
568
|
-
}}
|
|
569
|
-
>
|
|
570
|
-
{state === 'passed' ? (
|
|
571
|
-
<Svg width={14} height={14} viewBox="0 0 24 24">
|
|
572
|
-
<Path d="M5 13l4 4L19 7" fill="none" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
|
573
|
-
</Svg>
|
|
574
|
-
) : (
|
|
575
|
-
<MyazaText variant="bodySmall" color={state === 'active' ? colors.success : colors.textMuted} style={{ fontWeight: '700' }}>
|
|
576
|
-
{String(i + 1)}
|
|
577
|
-
</MyazaText>
|
|
578
|
-
)}
|
|
579
|
-
</View>
|
|
580
|
-
{i < total - 1 ? (
|
|
581
|
-
<View style={{ width: 28, height: 2, backgroundColor: i < completed ? colors.success : colors.gray300 }} />
|
|
582
|
-
) : null}
|
|
583
|
-
</React.Fragment>
|
|
584
|
-
);
|
|
585
|
-
})}
|
|
586
|
-
</View>
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
|
|
591
|
-
const { colors } = useTheme();
|
|
592
|
-
const S = 200;
|
|
593
|
-
return (
|
|
594
|
-
<View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
|
|
595
|
-
<View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111', borderColor: `${colors.primary}33`, overflow: 'hidden' }}>
|
|
596
|
-
{/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
|
|
597
|
-
child to a rounded parent (same fix as the header brand bar). */}
|
|
598
|
-
<Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
|
|
599
|
-
{/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
|
|
600
|
-
{uploading ? (
|
|
601
|
-
<View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
|
|
602
|
-
<MyazaPulseLoader size={64} />
|
|
603
|
-
</View>
|
|
604
|
-
) : null}
|
|
605
|
-
</View>
|
|
606
|
-
<View style={{ height: spacing.md }} />
|
|
607
|
-
<MyazaText variant="heading3" style={{ textAlign: 'center' }}>
|
|
608
|
-
Looking good!
|
|
609
|
-
</MyazaText>
|
|
610
|
-
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
611
|
-
Tap Continue to submit, or Retake to try again.
|
|
612
|
-
</MyazaText>
|
|
613
|
-
</View>
|
|
614
|
-
);
|
|
615
|
-
}
|