@myazahq/kyc-sdk-react-native 2.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/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- 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
- package/src/components/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- package/src/utils/uuid.ts +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @myazahq/kyc-sdk-react-native — public API barrel.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors the Flutter SDK's `kyc_sdk_flutter.dart`. The UI entry points
|
|
5
|
+
// (`MyazaKYC`, `useMyazaKYC`) are added in Step 2; this currently exports the
|
|
6
|
+
// public types, the ID-type matrix, validators, the typed error, and the
|
|
7
|
+
// liveness face-detector registry (for custom/test detector overrides).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
// UI entry points
|
|
11
|
+
export { MyazaKYC, useMyazaKYC } from './MyazaKYC';
|
|
12
|
+
export type { MyazaKYCProps, UseMyazaKYCReturn } from './MyazaKYC';
|
|
13
|
+
|
|
14
|
+
// Public config + callback types
|
|
15
|
+
export type {
|
|
16
|
+
MyazaKYCConfig,
|
|
17
|
+
SupportedCountry,
|
|
18
|
+
IdType,
|
|
19
|
+
IdTypeForCountry,
|
|
20
|
+
IdTypeDefinition,
|
|
21
|
+
KYCStep,
|
|
22
|
+
KYCAppearance,
|
|
23
|
+
KYCConsentContent,
|
|
24
|
+
KYCSuccessContent,
|
|
25
|
+
VoiceGuidanceConfig,
|
|
26
|
+
VoiceGuidanceOption,
|
|
27
|
+
} from './types/config';
|
|
28
|
+
export type { KYCSubmission, KYCErrorCode, KYCErrorDetails } from './types/verification';
|
|
29
|
+
export { KYCError } from './types/verification';
|
|
30
|
+
|
|
31
|
+
// ID-type matrix + helpers
|
|
32
|
+
export {
|
|
33
|
+
ID_TYPES,
|
|
34
|
+
COUNTRY_LABELS,
|
|
35
|
+
isNumberOnlyIdType,
|
|
36
|
+
requiresDocumentCapture,
|
|
37
|
+
getScanSides,
|
|
38
|
+
} from './config/idTypes';
|
|
39
|
+
|
|
40
|
+
// Client-side validation
|
|
41
|
+
export { validateIdNumber, maskIdNumber, type ValidationResult } from './services/validators';
|
|
42
|
+
|
|
43
|
+
// Liveness — types + the face-detector seam (custom/test detector override)
|
|
44
|
+
export type { LivenessChallenge, LivenessConfig, LivenessFaceData } from './liveness/types';
|
|
45
|
+
export {
|
|
46
|
+
type FaceDetectorService,
|
|
47
|
+
StubFaceDetectorService,
|
|
48
|
+
registerFaceDetectorFactory,
|
|
49
|
+
createFaceDetectorService,
|
|
50
|
+
hasFaceDetectorFactory,
|
|
51
|
+
} from './liveness/faceDetector';
|
|
52
|
+
|
|
53
|
+
// SDK version
|
|
54
|
+
export { SDK_VERSION } from './services/deviceMetadata';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Challenge manager — randomly picks 2–3 challenges and tracks progress.
|
|
3
|
+
// Port of the web SDK's `challenge-manager.ts`, adapted to the RN/Flutter
|
|
4
|
+
// `ChallengeConfig` shape.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CHALLENGE_POOL,
|
|
9
|
+
DEFAULT_LIVENESS_CONFIG,
|
|
10
|
+
type ChallengeConfig,
|
|
11
|
+
type LivenessChallenge,
|
|
12
|
+
type LivenessConfig,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
// Gestures within a similarity group should never appear together — one can
|
|
16
|
+
// accidentally satisfy the other (head motion overlaps).
|
|
17
|
+
const SIMILARITY_GROUPS: LivenessChallenge[][] = [['nod', 'turn']];
|
|
18
|
+
|
|
19
|
+
function areSimilar(a: LivenessChallenge, b: LivenessChallenge): boolean {
|
|
20
|
+
return SIMILARITY_GROUPS.some((group) => group.includes(a) && group.includes(b));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function shuffle<T>(arr: readonly T[]): T[] {
|
|
24
|
+
const copy = [...arr];
|
|
25
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
26
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
27
|
+
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
|
28
|
+
}
|
|
29
|
+
return copy;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Picks `challengeCount` non-similar challenges from the pool, in random order. */
|
|
33
|
+
export function pickChallenges(config: Partial<LivenessConfig> = {}): ChallengeConfig[] {
|
|
34
|
+
const merged = { ...DEFAULT_LIVENESS_CONFIG, ...config };
|
|
35
|
+
|
|
36
|
+
let pool = CHALLENGE_POOL;
|
|
37
|
+
if (merged.challengePool && merged.challengePool.length > 0) {
|
|
38
|
+
const allowed = new Set<LivenessChallenge>(merged.challengePool);
|
|
39
|
+
pool = CHALLENGE_POOL.filter((c) => allowed.has(c.type));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const count = Math.min(merged.challengeCount, pool.length);
|
|
43
|
+
const shuffled = shuffle(pool);
|
|
44
|
+
|
|
45
|
+
// Greedily pick challenges that aren't similar to already-picked ones.
|
|
46
|
+
const picked: ChallengeConfig[] = [];
|
|
47
|
+
for (const candidate of shuffled) {
|
|
48
|
+
if (picked.length >= count) break;
|
|
49
|
+
if (!picked.some((p) => areSimilar(p.type, candidate.type))) {
|
|
50
|
+
picked.push({ ...candidate, timeoutSeconds: merged.timeoutPerChallenge });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback: if similarity rules were too strict, fill remaining slots.
|
|
55
|
+
if (picked.length < count) {
|
|
56
|
+
for (const candidate of shuffled) {
|
|
57
|
+
if (picked.length >= count) break;
|
|
58
|
+
if (!picked.some((p) => p.type === candidate.type)) {
|
|
59
|
+
picked.push({ ...candidate, timeoutSeconds: merged.timeoutPerChallenge });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return picked;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Progress tracker
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export type ChallengeProgress = 'pending' | 'active' | 'passed' | 'failed';
|
|
72
|
+
|
|
73
|
+
export interface ChallengeEntry {
|
|
74
|
+
config: ChallengeConfig;
|
|
75
|
+
progress: ChallengeProgress;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class ChallengeTracker {
|
|
79
|
+
private entries: ChallengeEntry[];
|
|
80
|
+
private _currentIndex: number;
|
|
81
|
+
|
|
82
|
+
constructor(challenges: ChallengeConfig[]) {
|
|
83
|
+
this.entries = challenges.map((config, i) => ({
|
|
84
|
+
config,
|
|
85
|
+
progress: i === 0 ? 'active' : 'pending',
|
|
86
|
+
}));
|
|
87
|
+
this._currentIndex = 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get current(): ChallengeEntry | null {
|
|
91
|
+
return this.entries[this._currentIndex] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get currentIndex(): number {
|
|
95
|
+
return this._currentIndex;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get all(): readonly ChallengeEntry[] {
|
|
99
|
+
return this.entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get isComplete(): boolean {
|
|
103
|
+
return this.entries.every((e) => e.progress === 'passed');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get totalCount(): number {
|
|
107
|
+
return this.entries.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
markCurrentPassed(): void {
|
|
111
|
+
const entry = this.entries[this._currentIndex];
|
|
112
|
+
if (entry) entry.progress = 'passed';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
markCurrentFailed(): void {
|
|
116
|
+
const entry = this.entries[this._currentIndex];
|
|
117
|
+
if (entry) entry.progress = 'failed';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Advances to the next challenge; returns true if more remain. */
|
|
121
|
+
advance(): boolean {
|
|
122
|
+
this._currentIndex++;
|
|
123
|
+
const next = this.entries[this._currentIndex];
|
|
124
|
+
if (next) {
|
|
125
|
+
next.progress = 'active';
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// FaceDetectorService — the clean native boundary for liveness.
|
|
3
|
+
//
|
|
4
|
+
// This is the ONLY thing the liveness screen/store sees. It mirrors the Flutter
|
|
5
|
+
// SDK's `FaceDetectorService`: the core SDK depends on this interface, never on
|
|
6
|
+
// a concrete ML library, so liveness math, anti-spoofing thresholds, and camera
|
|
7
|
+
// assumptions never leak into screens — and a shared native core can be
|
|
8
|
+
// extracted later without touching the flow.
|
|
9
|
+
//
|
|
10
|
+
// Implementations:
|
|
11
|
+
// • VisionCameraFaceDetector (default, wired in Step 4) — a
|
|
12
|
+
// react-native-vision-camera frame-processor plugin: Apple Vision on iOS,
|
|
13
|
+
// Google ML Kit on Android. No cross-platform ML Kit iOS pod, so it builds
|
|
14
|
+
// on Apple-Silicon iOS simulators.
|
|
15
|
+
// • StubFaceDetectorService — no-op fallback (always "no face"). The Step-1/2
|
|
16
|
+
// default and the test override.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import type { LivenessFaceData } from './types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An opaque camera frame. Typed as `unknown` here so the core stays free of the
|
|
23
|
+
* react-native-vision-camera dependency; the native detector narrows it to a
|
|
24
|
+
* VisionCamera `Frame` inside its worklet.
|
|
25
|
+
*/
|
|
26
|
+
export type FaceDetectorFrame = unknown;
|
|
27
|
+
|
|
28
|
+
export interface FaceDetectorService {
|
|
29
|
+
/** Prepares the detector for use. Called once before {@link detect}. */
|
|
30
|
+
initialize(): void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Processes a single camera frame and returns the dominant face's gesture
|
|
34
|
+
* data, or `null` when no usable face is present (or this is the stub).
|
|
35
|
+
* Runs in a frame-processor worklet, so it is synchronous.
|
|
36
|
+
*/
|
|
37
|
+
detect(frame: FaceDetectorFrame): LivenessFaceData | null;
|
|
38
|
+
|
|
39
|
+
/** Releases any native resources. Safe to call multiple times. */
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** No-op detector — always reports "no face", so liveness never self-advances. */
|
|
44
|
+
export class StubFaceDetectorService implements FaceDetectorService {
|
|
45
|
+
initialize(): void {}
|
|
46
|
+
detect(): LivenessFaceData | null {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
dispose(): void {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Detector registry — a process-wide factory hook.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
let faceDetectorFactory: (() => FaceDetectorService) | null = null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Overrides the factory used to create the SDK's {@link FaceDetectorService}.
|
|
60
|
+
* Optional — call once at startup, before launching the KYC flow. Used by the
|
|
61
|
+
* native detector to install itself, and by tests to install the stub.
|
|
62
|
+
*/
|
|
63
|
+
export function registerFaceDetectorFactory(factory: () => FaceDetectorService): void {
|
|
64
|
+
faceDetectorFactory = factory;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether a host/native module has overridden the detector factory. */
|
|
68
|
+
export function hasFaceDetectorFactory(): boolean {
|
|
69
|
+
return faceDetectorFactory !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a {@link FaceDetectorService} — the registered factory if one is set,
|
|
74
|
+
* otherwise the {@link StubFaceDetectorService}. (Step 4 registers the
|
|
75
|
+
* VisionCamera detector as the default at module load.)
|
|
76
|
+
*/
|
|
77
|
+
export function createFaceDetectorService(): FaceDetectorService {
|
|
78
|
+
return (faceDetectorFactory ?? (() => new StubFaceDetectorService()))();
|
|
79
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Gesture detection — pure functions over native face signals.
|
|
3
|
+
//
|
|
4
|
+
// Direct port of the Flutter SDK's gesture functions (face_detection.dart). They
|
|
5
|
+
// operate on `LivenessFaceData`-derived histories (degrees / probabilities), so
|
|
6
|
+
// the thresholds match Flutter's on-device-tuned values — NOT the web SDK's
|
|
7
|
+
// normalized-landmark thresholds. Kept pure so they unit-test without a camera.
|
|
8
|
+
//
|
|
9
|
+
// Histories are sliding windows (~20 frames) maintained by the liveness store.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detects a nod: `headEulerAngleX` (pitch) dips below −10° then recovers above
|
|
14
|
+
* −5°. The caller maintains `xHistory` as a sliding window. ≥2 frames required.
|
|
15
|
+
*/
|
|
16
|
+
export function detectNod(xHistory: readonly number[]): boolean {
|
|
17
|
+
if (xHistory.length < 2) return false;
|
|
18
|
+
const minAngle = Math.min(...xHistory);
|
|
19
|
+
const currentAngle = xHistory[xHistory.length - 1]!;
|
|
20
|
+
return minAngle < -10 && currentAngle > -5;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Detects a head turn when `headEulerAngleY` (yaw) exceeds ±25°. Single frame. */
|
|
24
|
+
export function detectTurn(eulerY: number): boolean {
|
|
25
|
+
return Math.abs(eulerY) > 25;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detects a blink: the average eye-open probability drops below 0.2 then
|
|
30
|
+
* recovers above 0.5. ≥2 frames required. (Threshold relaxed 0.15→0.2 from
|
|
31
|
+
* on-device data, where a real blink only briefly bottoms out.)
|
|
32
|
+
*/
|
|
33
|
+
export function detectBlink(earHistory: readonly number[]): boolean {
|
|
34
|
+
if (earHistory.length < 2) return false;
|
|
35
|
+
const hadClose = earHistory.some((v) => v < 0.2);
|
|
36
|
+
const recovered = earHistory[earHistory.length - 1]! > 0.5;
|
|
37
|
+
return hadClose && recovered;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Detects a smile when `smilingProbability` exceeds 0.5. Single frame. */
|
|
41
|
+
export function detectSmile(smilingProbability: number): boolean {
|
|
42
|
+
return smilingProbability > 0.5;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
import type { LivenessChallenge, LivenessFaceData } from './types';
|
|
46
|
+
import { eyeAverageOpenProbability } from './types';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Evaluates whether the given challenge passes for the current frame/history.
|
|
50
|
+
* `xHistory` is the pitch window (nod); `earHistory` is the avg-eye-open window
|
|
51
|
+
* (blink). Single-frame gestures (turn/smile) read the latest `face`.
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateChallenge(
|
|
54
|
+
challenge: LivenessChallenge,
|
|
55
|
+
face: LivenessFaceData,
|
|
56
|
+
xHistory: readonly number[],
|
|
57
|
+
earHistory: readonly number[],
|
|
58
|
+
): boolean {
|
|
59
|
+
switch (challenge) {
|
|
60
|
+
case 'nod':
|
|
61
|
+
return detectNod(xHistory);
|
|
62
|
+
case 'turn':
|
|
63
|
+
return detectTurn(face.headEulerAngleY);
|
|
64
|
+
case 'blink':
|
|
65
|
+
return detectBlink(earHistory);
|
|
66
|
+
case 'smile':
|
|
67
|
+
return detectSmile(face.smilingProbability);
|
|
68
|
+
default:
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Appends to a sliding window, trimming to `maxLen` (default 20 frames). */
|
|
74
|
+
export function pushHistory(history: number[], value: number, maxLen = 20): number[] {
|
|
75
|
+
const next = [...history, value];
|
|
76
|
+
return next.length > maxLen ? next.slice(next.length - maxLen) : next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Re-export so callers can read the eye-open average without reaching into types. */
|
|
80
|
+
export { eyeAverageOpenProbability };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Spoken liveness guidance — TTS OUTPUT ONLY (no microphone). Mirrors the web
|
|
3
|
+
// SDK's liveness/speech.ts and the Flutter SDK's _TtsService. Backed by
|
|
4
|
+
// expo-speech. Voice guidance exists for accessibility; it never records audio,
|
|
5
|
+
// so there is no microphone permission and no microphone error code.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import * as Speech from 'expo-speech';
|
|
9
|
+
|
|
10
|
+
import type { VoiceGuidanceOption } from '../types/config';
|
|
11
|
+
|
|
12
|
+
export interface ResolvedVoiceGuidance {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
language: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalises the `voiceGuidance` config (bool | object | undefined) → object. */
|
|
18
|
+
export function resolveVoiceGuidance(option: VoiceGuidanceOption | undefined): ResolvedVoiceGuidance {
|
|
19
|
+
if (option === false) return { enabled: false, language: 'en-US' };
|
|
20
|
+
if (option === true || option == null) return { enabled: true, language: 'en-US' };
|
|
21
|
+
return { enabled: option.enabled !== false, language: option.language ?? 'en-US' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A tiny speaker that de-dupes consecutive identical phrases (so a guidance
|
|
26
|
+
* string repeated every frame is only spoken once) and no-ops when disabled.
|
|
27
|
+
* One instance per liveness session.
|
|
28
|
+
*/
|
|
29
|
+
export class LivenessSpeaker {
|
|
30
|
+
private readonly enabled: boolean;
|
|
31
|
+
private readonly language: string;
|
|
32
|
+
private lastSpoken: string | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(option: VoiceGuidanceOption | undefined) {
|
|
35
|
+
const resolved = resolveVoiceGuidance(option);
|
|
36
|
+
this.enabled = resolved.enabled;
|
|
37
|
+
this.language = resolved.language;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Speaks `text` unless guidance is off or it equals the last phrase spoken. */
|
|
41
|
+
speak(text: string): void {
|
|
42
|
+
if (!this.enabled || !text || text === this.lastSpoken) return;
|
|
43
|
+
this.lastSpoken = text;
|
|
44
|
+
try {
|
|
45
|
+
Speech.stop();
|
|
46
|
+
Speech.speak(text, { language: this.language });
|
|
47
|
+
} catch {
|
|
48
|
+
/* TTS is best-effort — a failure must never block the flow */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Clears the de-dupe memory so the next `speak` always plays (e.g. on retry). */
|
|
53
|
+
reset(): void {
|
|
54
|
+
this.lastSpoken = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stops any in-flight speech. Call when the liveness screen unmounts. */
|
|
58
|
+
stop(): void {
|
|
59
|
+
this.lastSpoken = null;
|
|
60
|
+
try {
|
|
61
|
+
Speech.stop();
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Liveness challenge types, state machine, and the per-frame face signal.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors the Flutter SDK's `liveness_types.dart` + the `LivenessFaceData` value
|
|
5
|
+
// object. The RN SDK consumes the SAME native signals as Flutter (head euler
|
|
6
|
+
// angles + smile/eye probabilities from Apple Vision / ML Kit) — NOT the web
|
|
7
|
+
// SDK's MediaPipe landmarks — so the gesture thresholds match Flutter's.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export type LivenessChallenge = 'nod' | 'turn' | 'blink' | 'smile';
|
|
11
|
+
|
|
12
|
+
export interface ChallengeConfig {
|
|
13
|
+
type: LivenessChallenge;
|
|
14
|
+
instruction: string;
|
|
15
|
+
timeoutSeconds: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default challenge pool — same instructions/timeouts as the Flutter + web SDKs. */
|
|
19
|
+
export const CHALLENGE_POOL: ChallengeConfig[] = [
|
|
20
|
+
{ type: 'nod', instruction: 'Kindly nod your head', timeoutSeconds: 8 },
|
|
21
|
+
{ type: 'turn', instruction: 'Kindly turn your head', timeoutSeconds: 8 },
|
|
22
|
+
{ type: 'blink', instruction: 'Blink your eyes', timeoutSeconds: 6 },
|
|
23
|
+
{ type: 'smile', instruction: 'Smile please', timeoutSeconds: 6 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// State machine (mirrors Flutter's LivenessPhase)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export type LivenessPhase =
|
|
31
|
+
| 'loading' // Initializing camera + detector
|
|
32
|
+
| 'positioning' // "Position your face in the circle"
|
|
33
|
+
| 'challenge' // Active gesture challenge
|
|
34
|
+
| 'challenge_passed' // Brief green flash
|
|
35
|
+
| 'capturing' // Auto-capturing selfie
|
|
36
|
+
| 'complete' // All done — selfie review
|
|
37
|
+
| 'failed'; // Timeout or face lost
|
|
38
|
+
|
|
39
|
+
export type LivenessFailureReason = 'timeout' | 'face_lost' | 'no_camera' | 'load_error';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Configuration
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface LivenessConfig {
|
|
46
|
+
challengeCount: 2 | 3;
|
|
47
|
+
challengePool?: LivenessChallenge[];
|
|
48
|
+
timeoutPerChallenge: number;
|
|
49
|
+
positioningTimeout: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_LIVENESS_CONFIG: LivenessConfig = {
|
|
53
|
+
challengeCount: 2,
|
|
54
|
+
timeoutPerChallenge: 8,
|
|
55
|
+
positioningTimeout: 15,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// LivenessFaceData — per-frame signals from the native detector (both platforms)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A snapshot of the face attributes from a single camera frame. Intentionally
|
|
64
|
+
* free of any ML Kit / Vision dependency so the gesture logic stays pure and
|
|
65
|
+
* unit-testable. The native VisionCamera frame-processor plugin produces this:
|
|
66
|
+
* Apple Vision on iOS, Google ML Kit on Android.
|
|
67
|
+
*/
|
|
68
|
+
export interface LivenessFaceData {
|
|
69
|
+
/** Head pitch (degrees): positive = up, negative = down. Drives nod detection. */
|
|
70
|
+
headEulerAngleX: number;
|
|
71
|
+
/** Head yaw (degrees): positive = left, negative = right. Drives turn detection. */
|
|
72
|
+
headEulerAngleY: number;
|
|
73
|
+
/** Head roll/tilt (degrees). Available but unused for gesture detection. */
|
|
74
|
+
headEulerAngleZ: number;
|
|
75
|
+
/** 0.0 (not smiling) → 1.0 (smiling). */
|
|
76
|
+
smilingProbability: number;
|
|
77
|
+
/** 0.0 (closed) → 1.0 (open). */
|
|
78
|
+
leftEyeOpenProbability: number;
|
|
79
|
+
/** 0.0 (closed) → 1.0 (open). */
|
|
80
|
+
rightEyeOpenProbability: number;
|
|
81
|
+
/** Face width / frame width (0–1): <0.2 too far, >0.7 too close. */
|
|
82
|
+
faceSizeRatio: number;
|
|
83
|
+
/** # faces in frame. `> 1` pauses challenges. Defaults to 1 for older plugins. */
|
|
84
|
+
faceCount: number;
|
|
85
|
+
/** Mean frame luma (0–255). Feeds the low-light gate (<62 dark, >200 bright). */
|
|
86
|
+
brightness: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Convenience: average of both eye-open probabilities. */
|
|
90
|
+
export function eyeAverageOpenProbability(face: LivenessFaceData): number {
|
|
91
|
+
return (face.leftEyeOpenProbability + face.rightEyeOpenProbability) / 2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Face positioning thresholds (distance), mirroring Flutter
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export const FACE_TOO_FAR_RATIO = 0.2;
|
|
99
|
+
export const FACE_TOO_CLOSE_RATIO = 0.7;
|