@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.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 +277 -7
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +146 -10
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +30 -9
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +10 -192
- package/src/components/KycFlow.tsx +69 -34
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +48 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +13 -5
- package/src/index.ts +49 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +33 -16
- package/src/screens/LivenessStep.tsx +188 -49
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +8 -4
- package/src/screens/liveness/SelfiePreview.tsx +31 -7
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-types.ts +119 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +149 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/mediaCompress.ts +23 -5
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +120 -5
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +63 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Animated, Easing, Image } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import { useTheme } from '../components/runtime';
|
|
4
|
+
import { useTheme, useKycConfig } from '../components/runtime';
|
|
5
5
|
import { Icon } from '../components/Icon';
|
|
6
|
+
import { livenessAvatarUrl } from '../liveness/avatarSource';
|
|
6
7
|
import type { LivenessChallenge } from '../liveness/types';
|
|
7
8
|
|
|
8
9
|
// Animated GIF avatar demonstrating the requested gesture — the RN mirror of the
|
|
9
|
-
// web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile
|
|
10
|
-
// circular badge (primary-tinted, like Flutter), sliding up + fading in when
|
|
11
|
-
// challenge changes. RN's <Image> animates GIFs natively.
|
|
10
|
+
// web/Flutter `LivenessAvatar`. Shows the same Nod/Turn/Blink/Smile animations in
|
|
11
|
+
// a circular badge (primary-tinted, like Flutter), sliding up + fading in when
|
|
12
|
+
// the challenge changes. RN's <Image> animates GIFs natively.
|
|
13
|
+
//
|
|
14
|
+
// The animations are FETCHED (see liveness/avatarSource) rather than bundled.
|
|
15
|
+
// They were 5.5 MB of this package, in every integrator's app, for a badge the
|
|
16
|
+
// liveness step shows for a few seconds. The flow prefetches all four at open,
|
|
17
|
+
// so by the time this renders the file is normally already on the device; when
|
|
18
|
+
// it is not, `broken` renders the gesture icon exactly as it always did for a
|
|
19
|
+
// failed decode.
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
smile: require('../assets/liveness/Smile.gif'),
|
|
18
|
-
};
|
|
21
|
+
/** The badge on a tall phone; the step hands a smaller size down on a short one
|
|
22
|
+
* (lib/livenessLayout), so the gesture stays on screen beside the circle. */
|
|
23
|
+
const DEFAULT_SIZE = 96;
|
|
24
|
+
const DEFAULT_ICON = 40;
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
export function LivenessAvatar({
|
|
27
|
+
challenge,
|
|
28
|
+
size = DEFAULT_SIZE,
|
|
29
|
+
iconSize = DEFAULT_ICON,
|
|
30
|
+
}: {
|
|
31
|
+
challenge: LivenessChallenge;
|
|
32
|
+
size?: number;
|
|
33
|
+
iconSize?: number;
|
|
34
|
+
}): React.ReactElement {
|
|
35
|
+
const SIZE = size;
|
|
23
36
|
const { colors } = useTheme();
|
|
37
|
+
const { apiKey, devUrl } = useKycConfig();
|
|
24
38
|
const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
|
|
25
39
|
const [broken, setBroken] = useState(false);
|
|
26
40
|
const anim = useRef(new Animated.Value(1)).current;
|
|
41
|
+
// `displayed`, not `challenge`: the badge cross-fades, so the image must keep
|
|
42
|
+
// showing the outgoing gesture until the transition swaps it.
|
|
43
|
+
const uri = livenessAvatarUrl(displayed, apiKey, devUrl);
|
|
27
44
|
|
|
28
45
|
// Slide-up + fade transition when the challenge changes (mirrors Flutter's
|
|
29
46
|
// AnimatedSwitcher: Offset(0, 0.4) → 0, easeOutCubic, ~350ms).
|
|
@@ -54,11 +71,11 @@ export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }):
|
|
|
54
71
|
transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [SIZE * 0.4, 0] }) }],
|
|
55
72
|
}}
|
|
56
73
|
>
|
|
57
|
-
{broken ? (
|
|
58
|
-
<Icon name="scan-face" size={
|
|
74
|
+
{broken || uri == null ? (
|
|
75
|
+
<Icon name="scan-face" size={iconSize} color={colors.primary} />
|
|
59
76
|
) : (
|
|
60
77
|
<Image
|
|
61
|
-
source={
|
|
78
|
+
source={{ uri }}
|
|
62
79
|
style={{ width: '100%', height: '100%' }}
|
|
63
80
|
resizeMode="cover"
|
|
64
81
|
onError={() => setBroken(true)}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Animated, Easing, Image, useWindowDimensions, View } from 'react-native';
|
|
3
3
|
import Svg, { Ellipse, Path } from 'react-native-svg';
|
|
4
4
|
import {
|
|
5
|
-
Camera,
|
|
6
5
|
useCameraDevice,
|
|
7
6
|
useCameraPermission,
|
|
8
7
|
useFrameOutput,
|
|
@@ -15,6 +14,7 @@ import { radius, spacing } from '../config/theme';
|
|
|
15
14
|
import { withRetry } from '../services/retry';
|
|
16
15
|
import { mapToKycError, safeReportError } from '../services/errors';
|
|
17
16
|
import { compressSelfieImage, compressVideo } from '../services/mediaCompress';
|
|
17
|
+
import { isSelfieBlurry, measureSelfieSharpness } from '../lib/selfie-sharpness';
|
|
18
18
|
import { KYCError } from '../types/verification';
|
|
19
19
|
import { useStore } from 'zustand';
|
|
20
20
|
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
@@ -35,9 +35,11 @@ import { useFaceModelReady } from '../liveness/useModelReady';
|
|
|
35
35
|
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
36
36
|
import { READY_LIVENESS } from '../components/readyPrimerContent';
|
|
37
37
|
import { LivenessAvatar } from './LivenessAvatar';
|
|
38
|
+
import { livenessLayout } from '../lib/livenessLayout';
|
|
38
39
|
import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
|
|
39
40
|
import { buildLivenessIntegrity } from '../liveness/integritySignals';
|
|
40
41
|
import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
|
|
42
|
+
import { livenessProgress } from '../lib/captureRing';
|
|
41
43
|
import { useFlashSequence } from './useFlashSequence';
|
|
42
44
|
import { useFlashHole } from './useFlashHole';
|
|
43
45
|
import {
|
|
@@ -45,13 +47,19 @@ import {
|
|
|
45
47
|
INSTRUCTION_HEIGHT,
|
|
46
48
|
LightingBanner,
|
|
47
49
|
ProgressDots,
|
|
50
|
+
CaptureRing,
|
|
51
|
+
ShutterFlash,
|
|
48
52
|
SelfiePreview,
|
|
49
53
|
StyleAbsFill,
|
|
50
54
|
resolveGuidance,
|
|
51
55
|
useSelfieUpload,
|
|
56
|
+
LivenessCamera,
|
|
52
57
|
LivenessComplete,
|
|
53
58
|
LivenessFailed,
|
|
59
|
+
LivenessHandover,
|
|
60
|
+
useSelfieAutoAdvance,
|
|
54
61
|
} from './liveness';
|
|
62
|
+
import { showsSelfieReview } from '../config/biometricOptions';
|
|
55
63
|
import {
|
|
56
64
|
lightingGuidanceText,
|
|
57
65
|
positionGuidanceText,
|
|
@@ -85,6 +93,7 @@ export function LivenessStep(): React.ReactElement {
|
|
|
85
93
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
86
94
|
const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
|
|
87
95
|
const nextStep = useKyc((s) => s.nextStep);
|
|
96
|
+
const clearSelfie = useKyc((s) => s.clearSelfie);
|
|
88
97
|
|
|
89
98
|
const device = useCameraDevice('front');
|
|
90
99
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
@@ -176,6 +185,21 @@ export function LivenessStep(): React.ReactElement {
|
|
|
176
185
|
uploadSelfieAndVideo,
|
|
177
186
|
} = upload;
|
|
178
187
|
|
|
188
|
+
// The still the review should flag as soft, held BY URI, so a measurement
|
|
189
|
+
// that lands after a retake can never flag the next photo. A notice only,
|
|
190
|
+
// never a gate: see lib/selfie-sharpness.
|
|
191
|
+
const [softSelfieUri, setSoftSelfieUri] = useState<string | null>(null);
|
|
192
|
+
|
|
193
|
+
// A stored selfie with no uploaded mediaId is an interrupted upload (failed,
|
|
194
|
+
// or the user left mid-flight): resume it once on mount. Idempotent — with a
|
|
195
|
+
// mediaId there is nothing to do.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
|
|
198
|
+
void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
|
|
199
|
+
}
|
|
200
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
179
203
|
// ── Liveness state machine ─────────────────────────────────────────────────
|
|
180
204
|
// `handleCapture` is created after `liveness` (it calls liveness.markComplete),
|
|
181
205
|
// so the machine gets it via a ref to avoid a definition cycle.
|
|
@@ -237,6 +261,11 @@ export function LivenessStep(): React.ReactElement {
|
|
|
237
261
|
const raw = `file://${file.filePath}`;
|
|
238
262
|
const compressed = await compressSelfieImage(raw).catch(() => raw);
|
|
239
263
|
setSelfieUri(compressed);
|
|
264
|
+
// Measured on the still the applicant is about to see. It never holds
|
|
265
|
+
// the review back: the notice appears when the answer does.
|
|
266
|
+
void measureSelfieSharpness(compressed).then((score) => {
|
|
267
|
+
if (isSelfieBlurry(score)) setSoftSelfieUri(compressed);
|
|
268
|
+
});
|
|
240
269
|
// Recorded at capture, not at submit: by then the liveness hook is gone
|
|
241
270
|
// and its flash result with it.
|
|
242
271
|
setCaptureIntegrity({
|
|
@@ -274,6 +303,66 @@ export function LivenessStep(): React.ReactElement {
|
|
|
274
303
|
// always reads the latest state-machine callbacks.
|
|
275
304
|
const livenessRef = useRef(liveness);
|
|
276
305
|
livenessRef.current = liveness;
|
|
306
|
+
|
|
307
|
+
// The circle and the gesture avatar are sized from the WINDOW, not the width
|
|
308
|
+
// alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
|
|
309
|
+
// A hook, so it lives up here with the ring refs, above the gates below.
|
|
310
|
+
const windowSize = useWindowDimensions();
|
|
311
|
+
const layout = livenessLayout(windowSize);
|
|
312
|
+
|
|
313
|
+
// Progress for the ring, read every frame from a ref — never from render.
|
|
314
|
+
// Declared HERE, above the early returns below: a hook after a conditional
|
|
315
|
+
// return renders "more hooks than during the previous render" the moment the
|
|
316
|
+
// gate dismisses, which is exactly what the ready primer does on its way out.
|
|
317
|
+
const phaseStartedRef = useRef(Date.now());
|
|
318
|
+
const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
|
|
319
|
+
const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
|
|
320
|
+
if (phaseKeyRef.current !== phaseKey) {
|
|
321
|
+
phaseKeyRef.current = phaseKey;
|
|
322
|
+
phaseStartedRef.current = Date.now();
|
|
323
|
+
}
|
|
324
|
+
const timeoutRef = useRef(
|
|
325
|
+
liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
|
|
326
|
+
);
|
|
327
|
+
if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
|
|
328
|
+
timeoutRef.current = liveness.timeoutRemaining;
|
|
329
|
+
}
|
|
330
|
+
const getRingTarget = useCallback(() => {
|
|
331
|
+
const l = livenessRef.current;
|
|
332
|
+
return livenessProgress({
|
|
333
|
+
phase: l.phase,
|
|
334
|
+
completedCount: l.completedCount,
|
|
335
|
+
totalCount: l.totalCount,
|
|
336
|
+
elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
|
|
337
|
+
challengeTimeout: timeoutRef.current,
|
|
338
|
+
});
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
// The selfie URI lands BEFORE the machine reaches `complete` (capture →
|
|
342
|
+
// compress → setSelfieUri → markComplete), and the review is gated on that
|
|
343
|
+
// URI — so the camera circle used to unmount on the very frame the ring's
|
|
344
|
+
// target reached the end: the close, the green, the shutter, none of it
|
|
345
|
+
// was ever seen. Hold the frame for the closing beat, then hand over.
|
|
346
|
+
const [closeSeen, setCloseSeen] = useState(false);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (liveness.phase !== 'complete') return;
|
|
349
|
+
const t = setTimeout(() => setCloseSeen(true), 700);
|
|
350
|
+
return () => clearTimeout(t);
|
|
351
|
+
}, [liveness.phase]);
|
|
352
|
+
const holdingForClose =
|
|
353
|
+
(liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
|
|
354
|
+
// The biometric scopes skip the review by default: hand over the moment the
|
|
355
|
+
// ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
|
|
356
|
+
// The upload reports to the store and the submitted step waits on it, so
|
|
357
|
+
// the person sees one loading screen rather than one per step. A restored
|
|
358
|
+
// selfie (media id, no local file) is ready at once; an upload that already
|
|
359
|
+
// failed keeps the review, whose Try Again is the recovery.
|
|
360
|
+
const selfieReview = showsSelfieReview(config);
|
|
361
|
+
useSelfieAutoAdvance({
|
|
362
|
+
enabled: !selfieReview,
|
|
363
|
+
ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
|
|
364
|
+
onAdvance: nextStep,
|
|
365
|
+
});
|
|
277
366
|
// A plain JS function the worklet hands the per-frame result to. It is wrapped
|
|
278
367
|
// with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
|
|
279
368
|
// pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
|
|
@@ -353,6 +442,39 @@ export function LivenessStep(): React.ReactElement {
|
|
|
353
442
|
},
|
|
354
443
|
});
|
|
355
444
|
|
|
445
|
+
// ── Review (selfie already exists) ──────────────────────────────────────────
|
|
446
|
+
// Checked BEFORE every capture-flow gate (ready primer, permission, model):
|
|
447
|
+
// a selfie captured on an earlier visit — or a restored session where only
|
|
448
|
+
// the uploaded mediaId survived — means there is nothing to capture, so the
|
|
449
|
+
// step opens on review exactly as the document steps reopen on theirs.
|
|
450
|
+
// A restored selfie (earlier visit) opens straight on review; the one being
|
|
451
|
+
// captured RIGHT NOW waits for the ring to close first.
|
|
452
|
+
if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
|
|
453
|
+
// Review hidden: the hook above advances on this render; the view is the
|
|
454
|
+
// submitted step's own loading screen, so the hand-over is invisible. A
|
|
455
|
+
// FAILED upload still gets the review, for its retry.
|
|
456
|
+
if (!selfieReview && !uploadError) {
|
|
457
|
+
return <LivenessHandover />;
|
|
458
|
+
}
|
|
459
|
+
return (
|
|
460
|
+
<LivenessComplete
|
|
461
|
+
selfieUri={selfieUri}
|
|
462
|
+
soft={!!selfieUri && softSelfieUri === selfieUri}
|
|
463
|
+
upload={upload}
|
|
464
|
+
onRetake={() => {
|
|
465
|
+
setSelfieUri(null);
|
|
466
|
+
setCloseSeen(false);
|
|
467
|
+
setUploadError(null);
|
|
468
|
+
selfieIdRef.current = null;
|
|
469
|
+
videoPathRef.current = null;
|
|
470
|
+
clearSelfie();
|
|
471
|
+
liveness.reset();
|
|
472
|
+
}}
|
|
473
|
+
onContinue={nextStep}
|
|
474
|
+
/>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
356
478
|
// ── Error states ────────────────────────────────────────────────────────────
|
|
357
479
|
if (cameraUnavailable) {
|
|
358
480
|
return <CameraUnavailableView />;
|
|
@@ -382,22 +504,29 @@ export function LivenessStep(): React.ReactElement {
|
|
|
382
504
|
if (permissionDenied) {
|
|
383
505
|
return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
|
|
384
506
|
}
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
507
|
+
// The model is still arriving. Measured at ~9s on a freshly installed device
|
|
508
|
+
// over fast wifi, and the connections this SDK actually serves are slower, so
|
|
509
|
+
// the head start from opening the flow does not always win the race.
|
|
510
|
+
//
|
|
511
|
+
// Without this the camera opened anyway and `detectFace` returned
|
|
512
|
+
// `faceCount: 0` for every frame, which is indistinguishable from "no face in
|
|
513
|
+
// shot" — so the user was told to position a face that was never going to
|
|
514
|
+
// register, for as long as the download took. That is the exact failure the
|
|
515
|
+
// readiness gate exists to prevent, and MrzScanView already answers it this
|
|
516
|
+
// way; only this screen was missing it.
|
|
517
|
+
//
|
|
518
|
+
// Deliberately AFTER the primer and the permission prompt: those need no
|
|
519
|
+
// model, so letting them run first keeps the download overlapping something
|
|
520
|
+
// the user is already reading. Only the camera itself waits.
|
|
521
|
+
if (modelState === 'preparing') {
|
|
388
522
|
return (
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
videoPathRef.current = null;
|
|
397
|
-
liveness.reset();
|
|
398
|
-
}}
|
|
399
|
-
onContinue={nextStep}
|
|
400
|
-
/>
|
|
523
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }}>
|
|
524
|
+
<MyazaPulseLoader size={24} />
|
|
525
|
+
<View style={{ height: spacing.md }} />
|
|
526
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
527
|
+
Getting face verification ready. This only happens once.
|
|
528
|
+
</MyazaText>
|
|
529
|
+
</View>
|
|
401
530
|
);
|
|
402
531
|
}
|
|
403
532
|
|
|
@@ -416,31 +545,32 @@ export function LivenessStep(): React.ReactElement {
|
|
|
416
545
|
liveness.lightingGuidance != null;
|
|
417
546
|
const isCamLoading = phase === 'loading';
|
|
418
547
|
|
|
548
|
+
// The ring is on the frame from positioning to the shutter, so for that whole
|
|
549
|
+
// stretch the border is its TRACK. Phase colour stays on the instruction text
|
|
550
|
+
// and the passed flash; a warning still turns the track red under the arc.
|
|
551
|
+
// `failed` has already returned above, so loading is the only phase without it.
|
|
552
|
+
const ringOnFrame = !isCamLoading;
|
|
419
553
|
const ringColor = isCamLoading
|
|
420
554
|
? colors.gray300
|
|
421
555
|
: hasWarning
|
|
422
556
|
? colors.error
|
|
423
|
-
:
|
|
424
|
-
? colors.
|
|
425
|
-
:
|
|
426
|
-
? colors.warning
|
|
427
|
-
: liveness.faceDetected
|
|
428
|
-
? colors.primary
|
|
429
|
-
: colors.gray300;
|
|
557
|
+
: ringOnFrame
|
|
558
|
+
? `${colors.primary}33`
|
|
559
|
+
: colors.gray300;
|
|
430
560
|
|
|
431
561
|
const guidance = resolveGuidance(liveness);
|
|
432
562
|
const instrColor = isCamLoading
|
|
433
563
|
? colors.textMuted
|
|
434
564
|
: guidance.tone === 'error'
|
|
435
565
|
? colors.error
|
|
436
|
-
: phase === 'challenge_passed'
|
|
566
|
+
: phase === 'challenge_passed' || phase === 'complete'
|
|
437
567
|
? colors.success
|
|
438
568
|
: phase === 'challenge'
|
|
439
569
|
? colors.warning
|
|
440
570
|
: colors.textDark;
|
|
441
571
|
|
|
442
572
|
// Fixed circle, sized to fit the sheet width (caps at 300).
|
|
443
|
-
const CIRCLE =
|
|
573
|
+
const CIRCLE = layout.circle;
|
|
444
574
|
const lighting = liveness.lightingGuidance;
|
|
445
575
|
|
|
446
576
|
// Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
|
|
@@ -456,7 +586,10 @@ export function LivenessStep(): React.ReactElement {
|
|
|
456
586
|
{phase === 'flash' ? '' : guidance.text}
|
|
457
587
|
</MyazaText>
|
|
458
588
|
|
|
459
|
-
{/* Circular camera
|
|
589
|
+
{/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
|
|
590
|
+
the outer one lets the progress ring sit on that border rather than be
|
|
591
|
+
clipped by it. */}
|
|
592
|
+
<View style={{ width: CIRCLE, height: CIRCLE }}>
|
|
460
593
|
<View
|
|
461
594
|
ref={flashHoleRef}
|
|
462
595
|
collapsable={false}
|
|
@@ -470,21 +603,14 @@ export function LivenessStep(): React.ReactElement {
|
|
|
470
603
|
backgroundColor: '#111111',
|
|
471
604
|
}}
|
|
472
605
|
>
|
|
473
|
-
{
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
mirrorMode="on"
|
|
482
|
-
/>
|
|
483
|
-
) : (
|
|
484
|
-
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
485
|
-
<ActivityIndicator color="#FFFFFF" />
|
|
486
|
-
</View>
|
|
487
|
-
)}
|
|
606
|
+
{/* The Camera lives in its own borderless wrapper — see LivenessCamera
|
|
607
|
+
for why a bordered parent shifts the Android preview by one border. */}
|
|
608
|
+
<LivenessCamera
|
|
609
|
+
device={device}
|
|
610
|
+
active={perm === 'granted'}
|
|
611
|
+
outputs={[photoOutput, videoOutput, frameOutput]}
|
|
612
|
+
circle={CIRCLE}
|
|
613
|
+
/>
|
|
488
614
|
|
|
489
615
|
{/* Dashed face-guide ellipse (taller than wide) */}
|
|
490
616
|
<Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
|
|
@@ -519,12 +645,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
519
645
|
</View>
|
|
520
646
|
) : null}
|
|
521
647
|
|
|
522
|
-
{/*
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
<MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
|
|
526
|
-
</View>
|
|
527
|
-
) : null}
|
|
648
|
+
{/* The shutter: a white flash as the still is taken. It used to sit on
|
|
649
|
+
`capturing` reading "Got it!", announcing a photo not yet taken. */}
|
|
650
|
+
{phase === 'complete' ? <ShutterFlash /> : null}
|
|
528
651
|
|
|
529
652
|
{/* Timeout countdown */}
|
|
530
653
|
{phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
|
|
@@ -534,6 +657,20 @@ export function LivenessStep(): React.ReactElement {
|
|
|
534
657
|
) : null}
|
|
535
658
|
</View>
|
|
536
659
|
|
|
660
|
+
{/* The ring — outside the clipping box, on the frame's own edge. One line,
|
|
661
|
+
building one way for the whole test; primary while it builds, green
|
|
662
|
+
only as it closes, on the same frame as the shutter. */}
|
|
663
|
+
{ringOnFrame ? (
|
|
664
|
+
<CaptureRing
|
|
665
|
+
size={CIRCLE}
|
|
666
|
+
getTarget={getRingTarget}
|
|
667
|
+
color={colors.primary}
|
|
668
|
+
successColor={colors.success}
|
|
669
|
+
complete={phase === 'complete'}
|
|
670
|
+
/>
|
|
671
|
+
) : null}
|
|
672
|
+
</View>
|
|
673
|
+
|
|
537
674
|
{/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
|
|
538
675
|
{lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
|
|
539
676
|
|
|
@@ -541,7 +678,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
541
678
|
<ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
|
|
542
679
|
|
|
543
680
|
{/* Gesture demo avatar */}
|
|
544
|
-
{liveness.activeChallenge ?
|
|
681
|
+
{liveness.activeChallenge ? (
|
|
682
|
+
<LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
|
|
683
|
+
) : null}
|
|
545
684
|
</View>
|
|
546
685
|
);
|
|
547
686
|
}
|
|
@@ -15,6 +15,7 @@ import { MyazaButton } from '../components/MyazaButton';
|
|
|
15
15
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
16
16
|
import { extractMrz } from '../mrz/extract';
|
|
17
17
|
import { recognizeMrzLines, MRZ_BAND_FRACTION } from '../mrz/textRecognizer';
|
|
18
|
+
import { useTextModelReady } from '../mrz/useTextModelReady';
|
|
18
19
|
import type { MrzScan } from '../mrz/parse';
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -45,6 +46,11 @@ export function MrzScanView({
|
|
|
45
46
|
const device = useCameraDevice('back');
|
|
46
47
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
47
48
|
const [denied, setDenied] = useState(false);
|
|
49
|
+
// Android fetches the text model through Play Services rather than bundling
|
|
50
|
+
// it. A recogniser that cannot run reports exactly what a blank page reports
|
|
51
|
+
// — no lines — so without this the user would aim at their passport while a
|
|
52
|
+
// scanner that can never read sat there pulsing. iOS is always ready.
|
|
53
|
+
const modelState = useTextModelReady();
|
|
48
54
|
|
|
49
55
|
// A ref, not state: the worklet callback lands on the JS thread after the
|
|
50
56
|
// frame that found the MRZ, and a stale render would let a second frame
|
|
@@ -113,6 +119,33 @@ export function MrzScanView({
|
|
|
113
119
|
);
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
// Worded like the camera-denied case above, and for the same reason: the chip
|
|
123
|
+
// is optional, so the honest thing is to say the code cannot be read and let
|
|
124
|
+
// the user carry on rather than leave them waiting on something that will not
|
|
125
|
+
// arrive.
|
|
126
|
+
if (modelState === 'unavailable') {
|
|
127
|
+
return (
|
|
128
|
+
<Centered>
|
|
129
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
130
|
+
The text reader could not be set up on this device, so the printed code
|
|
131
|
+
cannot be read. You can still continue without the chip.
|
|
132
|
+
</MyazaText>
|
|
133
|
+
</Centered>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (modelState === 'preparing') {
|
|
138
|
+
return (
|
|
139
|
+
<Centered>
|
|
140
|
+
<MyazaPulseLoader size={24} />
|
|
141
|
+
<View style={{ height: spacing.md }} />
|
|
142
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
|
|
143
|
+
Getting the text reader ready. This only happens once.
|
|
144
|
+
</MyazaText>
|
|
145
|
+
</Centered>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
return (
|
|
117
150
|
<View
|
|
118
151
|
style={{
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../components/runtime';
|
|
5
|
+
import { Icon, type IconName } from '../components/Icon';
|
|
6
|
+
import { OptionRow } from '../components/OptionRow';
|
|
7
|
+
import type { PoaDocumentType } from '../types/workflow';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// The document kinds LISTED OUT as the questionnaire's multi-select cards (a
|
|
11
|
+
// square check, primary when picked, an icon beside the label) rather than
|
|
12
|
+
// hidden behind a select — the options are the information, and a sheet makes
|
|
13
|
+
// the person open it to find out what is on offer (user decision 2026-09-05).
|
|
14
|
+
// ONE kind is picked, because one document is uploaded: the cards are radios
|
|
15
|
+
// wearing the multi-select's square check. Locked once a file is attached, so
|
|
16
|
+
// the kind cannot drift from the document already uploaded. Mirrors the web
|
|
17
|
+
// SDK's steps/PoaDocumentTypeList and Flutter's proof_of_address_kinds.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** One glyph per kind — the SAME Lucide names the web and Flutter maps carry. */
|
|
21
|
+
export const POA_TYPE_ICONS: Record<PoaDocumentType, IconName> = {
|
|
22
|
+
utility_bill: 'zap',
|
|
23
|
+
bank_statement: 'landmark',
|
|
24
|
+
tenancy_agreement: 'house',
|
|
25
|
+
government_document: 'stamp',
|
|
26
|
+
other: 'file-text',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function PoaDocumentTypeList({
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
disabled,
|
|
33
|
+
onChange,
|
|
34
|
+
}: {
|
|
35
|
+
options: Array<{ value: PoaDocumentType; label: string }>;
|
|
36
|
+
value: PoaDocumentType;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
onChange: (value: PoaDocumentType) => void;
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
return (
|
|
42
|
+
<View>
|
|
43
|
+
{options.map((opt) => {
|
|
44
|
+
const selected = opt.value === value;
|
|
45
|
+
return (
|
|
46
|
+
<OptionRow
|
|
47
|
+
key={opt.value}
|
|
48
|
+
label={opt.label}
|
|
49
|
+
selected={selected}
|
|
50
|
+
multi
|
|
51
|
+
role="radio"
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
icon={
|
|
54
|
+
<Icon
|
|
55
|
+
name={POA_TYPE_ICONS[opt.value]}
|
|
56
|
+
size={20}
|
|
57
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
58
|
+
/>
|
|
59
|
+
}
|
|
60
|
+
onPress={() => onChange(opt.value)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|