@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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { KYCApiError } from '../services/api';
|
|
2
|
+
import { mapToKycError } from '../services/errors';
|
|
3
|
+
|
|
4
|
+
describe('mapToKycError', () => {
|
|
5
|
+
it('maps 401 → invalid_api_key', () => {
|
|
6
|
+
const e = mapToKycError(new KYCApiError('nope', 401), 'verify');
|
|
7
|
+
expect(e.code).toBe('invalid_api_key');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('maps 402 → insufficient_credits with details', () => {
|
|
11
|
+
const e = mapToKycError(new KYCApiError('no credits', 402, undefined, { required: 5, balance: 1, currency: 'USD' }), 'verify');
|
|
12
|
+
expect(e.code).toBe('insufficient_credits');
|
|
13
|
+
expect(e.details).toEqual({ required: 5, balance: 1, currency: 'USD' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('maps 403 id_type_not_allowed and feature_disabled → feature_disabled', () => {
|
|
17
|
+
expect(mapToKycError(new KYCApiError('x', 403, 'id_type_not_allowed'), 'verify').code).toBe('feature_disabled');
|
|
18
|
+
expect(mapToKycError(new KYCApiError('x', 403, 'feature_disabled', { feature: 'gov_db_check' }), 'verify').code).toBe('feature_disabled');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('maps 5xx by context (upload_failed vs network_error)', () => {
|
|
22
|
+
expect(mapToKycError(new KYCApiError('x', 503), 'upload').code).toBe('upload_failed');
|
|
23
|
+
expect(mapToKycError(new KYCApiError('x', 503), 'verify').code).toBe('network_error');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('maps a network TypeError → network_error', () => {
|
|
27
|
+
expect(mapToKycError(new TypeError('Network request failed'), 'verify').code).toBe('network_error');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to the context code for unknown errors', () => {
|
|
31
|
+
expect(mapToKycError(new Error('weird'), 'upload').code).toBe('upload_failed');
|
|
32
|
+
expect(mapToKycError(new Error('weird'), 'verify').code).toBe('unknown');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { nextStepAfter, previousStepBefore, livenessEnabled, type KycState } from '../store/kycStore';
|
|
2
|
+
import { INITIAL_SERVER_CONFIG } from '../store/serverConfig';
|
|
3
|
+
import type { MyazaKYCConfig } from '../types/config';
|
|
4
|
+
|
|
5
|
+
// Build a minimal KycState for exercising the pure navigation helpers. The
|
|
6
|
+
// helpers only read currentStep/selectedIdType/serverConfig/config, so we cast a
|
|
7
|
+
// partial object (the unused action fields are never touched).
|
|
8
|
+
function makeState(over: Partial<KycState>): KycState {
|
|
9
|
+
const config = { apiKey: 'pk_test_x', country: 'NG', ...(over.config ?? {}) } as MyazaKYCConfig;
|
|
10
|
+
return {
|
|
11
|
+
config,
|
|
12
|
+
selectedIdType: null,
|
|
13
|
+
serverConfig: INITIAL_SERVER_CONFIG,
|
|
14
|
+
currentStep: 'consent',
|
|
15
|
+
...over,
|
|
16
|
+
} as KycState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('flow navigation', () => {
|
|
20
|
+
it('number-only IDs skip document capture (id-type → id-input)', () => {
|
|
21
|
+
const s = makeState({ selectedIdType: 'bvn' });
|
|
22
|
+
expect(nextStepAfter('id-type', s)).toBe('id-input');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('document IDs go to document capture (id-type → document-capture)', () => {
|
|
26
|
+
const s = makeState({ selectedIdType: 'passport' });
|
|
27
|
+
expect(nextStepAfter('id-type', s)).toBe('document-capture');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reaches liveness when enabled, else jumps to submitted', () => {
|
|
31
|
+
const enabled = makeState({ selectedIdType: 'bvn', config: { apiKey: 'pk_test_x', country: 'NG', enableLiveness: true } as MyazaKYCConfig });
|
|
32
|
+
expect(nextStepAfter('id-input', enabled)).toBe('liveness');
|
|
33
|
+
|
|
34
|
+
const disabled = makeState({ selectedIdType: 'bvn', config: { apiKey: 'pk_test_x', country: 'NG', enableLiveness: false } as MyazaKYCConfig });
|
|
35
|
+
expect(nextStepAfter('id-input', disabled)).toBe('submitted');
|
|
36
|
+
expect(livenessEnabled(disabled)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('server livenessCheck=false wins over the consumer baseline', () => {
|
|
40
|
+
const s = makeState({
|
|
41
|
+
selectedIdType: 'bvn',
|
|
42
|
+
config: { apiKey: 'pk_test_x', country: 'NG', enableLiveness: true } as MyazaKYCConfig,
|
|
43
|
+
serverConfig: {
|
|
44
|
+
status: 'ready',
|
|
45
|
+
fatal: false,
|
|
46
|
+
idTypes: [{ country: 'NG', idType: 'bvn', features: { documentVerification: true, livenessCheck: false, govDbCheck: true } }],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(livenessEnabled(s)).toBe(false);
|
|
50
|
+
expect(nextStepAfter('id-input', s)).toBe('submitted');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('back navigation mirrors the forward path', () => {
|
|
54
|
+
const doc = makeState({ selectedIdType: 'passport' });
|
|
55
|
+
expect(previousStepBefore('document-capture', doc)).toBe('id-type');
|
|
56
|
+
expect(previousStepBefore('liveness', doc)).toBe('document-capture');
|
|
57
|
+
|
|
58
|
+
const num = makeState({ selectedIdType: 'bvn' });
|
|
59
|
+
expect(previousStepBefore('liveness', num)).toBe('id-input');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { detectNod, detectTurn, detectBlink, detectSmile, pushHistory } from '../liveness/gestureDetector';
|
|
2
|
+
|
|
3
|
+
describe('gesture detectors (native-signal thresholds, ported from Flutter)', () => {
|
|
4
|
+
it('detectNod passes when pitch dips below −10° then recovers above −5°', () => {
|
|
5
|
+
expect(detectNod([0, -12, -3])).toBe(true);
|
|
6
|
+
expect(detectNod([0, -6, -3])).toBe(false); // never dipped far enough
|
|
7
|
+
expect(detectNod([0, -12, -8])).toBe(false); // hasn't recovered
|
|
8
|
+
expect(detectNod([-12])).toBe(false); // <2 frames
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('detectTurn passes when yaw exceeds ±25°', () => {
|
|
12
|
+
expect(detectTurn(30)).toBe(true);
|
|
13
|
+
expect(detectTurn(-26)).toBe(true);
|
|
14
|
+
expect(detectTurn(20)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('detectBlink passes when eye-open drops below 0.2 then recovers above 0.5', () => {
|
|
18
|
+
expect(detectBlink([0.9, 0.1, 0.8])).toBe(true);
|
|
19
|
+
expect(detectBlink([0.9, 0.3, 0.8])).toBe(false); // never closed enough
|
|
20
|
+
expect(detectBlink([0.9, 0.1, 0.4])).toBe(false); // not recovered
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('detectSmile passes above 0.5', () => {
|
|
24
|
+
expect(detectSmile(0.6)).toBe(true);
|
|
25
|
+
expect(detectSmile(0.4)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('pushHistory', () => {
|
|
30
|
+
it('appends and trims to maxLen', () => {
|
|
31
|
+
let h: number[] = [];
|
|
32
|
+
for (let i = 0; i < 25; i++) h = pushHistory(h, i, 20);
|
|
33
|
+
expect(h.length).toBe(20);
|
|
34
|
+
expect(h[h.length - 1]).toBe(24);
|
|
35
|
+
expect(h[0]).toBe(5);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// expo-speech is an ESM native module; the pure-logic tests here don't exercise
|
|
2
|
+
// TTS, so stub it (matches jest.config's "native modules tested later" note).
|
|
3
|
+
jest.mock('expo-speech', () => ({ speak: jest.fn(), stop: jest.fn() }));
|
|
4
|
+
|
|
5
|
+
import { resolveVoiceGuidance } from '../liveness/speech';
|
|
6
|
+
import { evaluateChallenge } from '../liveness/gestureDetector';
|
|
7
|
+
import { pickChallenges, ChallengeTracker } from '../liveness/challengeManager';
|
|
8
|
+
import type { LivenessFaceData } from '../liveness/types';
|
|
9
|
+
|
|
10
|
+
// Pure-logic tests for the liveness machine's building blocks. The stateful hook
|
|
11
|
+
// (useLiveness) and the native frame-processor bridge are exercised on-device;
|
|
12
|
+
// here we lock down the ML-free pieces that drive them.
|
|
13
|
+
|
|
14
|
+
function face(partial: Partial<LivenessFaceData> = {}): LivenessFaceData {
|
|
15
|
+
return {
|
|
16
|
+
headEulerAngleX: 0,
|
|
17
|
+
headEulerAngleY: 0,
|
|
18
|
+
headEulerAngleZ: 0,
|
|
19
|
+
smilingProbability: 0,
|
|
20
|
+
leftEyeOpenProbability: 1,
|
|
21
|
+
rightEyeOpenProbability: 1,
|
|
22
|
+
faceSizeRatio: 0.4,
|
|
23
|
+
faceCount: 1,
|
|
24
|
+
brightness: 128,
|
|
25
|
+
...partial,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('resolveVoiceGuidance', () => {
|
|
30
|
+
it('treats undefined / true as enabled en-US', () => {
|
|
31
|
+
expect(resolveVoiceGuidance(undefined)).toEqual({ enabled: true, language: 'en-US' });
|
|
32
|
+
expect(resolveVoiceGuidance(true)).toEqual({ enabled: true, language: 'en-US' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('treats false as disabled', () => {
|
|
36
|
+
expect(resolveVoiceGuidance(false)).toEqual({ enabled: false, language: 'en-US' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('honours an object with enabled + language', () => {
|
|
40
|
+
expect(resolveVoiceGuidance({ enabled: true, language: 'fr-FR' })).toEqual({
|
|
41
|
+
enabled: true,
|
|
42
|
+
language: 'fr-FR',
|
|
43
|
+
});
|
|
44
|
+
// enabled defaults to true when omitted
|
|
45
|
+
expect(resolveVoiceGuidance({ language: 'en-GB' })).toEqual({ enabled: true, language: 'en-GB' });
|
|
46
|
+
expect(resolveVoiceGuidance({ enabled: false })).toEqual({ enabled: false, language: 'en-US' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('evaluateChallenge (drives the state machine gesture switch)', () => {
|
|
51
|
+
it('nod reads the pitch history', () => {
|
|
52
|
+
expect(evaluateChallenge('nod', face(), [0, -12, -3], [])).toBe(true);
|
|
53
|
+
expect(evaluateChallenge('nod', face(), [0, -6], [])).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('turn reads the latest yaw', () => {
|
|
57
|
+
expect(evaluateChallenge('turn', face({ headEulerAngleY: 30 }), [], [])).toBe(true);
|
|
58
|
+
expect(evaluateChallenge('turn', face({ headEulerAngleY: 10 }), [], [])).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('blink reads the eye-open history', () => {
|
|
62
|
+
expect(evaluateChallenge('blink', face(), [], [0.9, 0.1, 0.8])).toBe(true);
|
|
63
|
+
expect(evaluateChallenge('blink', face(), [], [0.9, 0.3, 0.8])).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('smile reads the latest smile probability', () => {
|
|
67
|
+
expect(evaluateChallenge('smile', face({ smilingProbability: 0.6 }), [], [])).toBe(true);
|
|
68
|
+
expect(evaluateChallenge('smile', face({ smilingProbability: 0.4 }), [], [])).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('pickChallenges (session selection)', () => {
|
|
73
|
+
it('picks the configured count of distinct challenges', () => {
|
|
74
|
+
const picked = pickChallenges({ challengeCount: 2 });
|
|
75
|
+
expect(picked).toHaveLength(2);
|
|
76
|
+
const types = picked.map((c) => c.type);
|
|
77
|
+
expect(new Set(types).size).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('never pairs nod with turn (similarity group)', () => {
|
|
81
|
+
for (let i = 0; i < 50; i++) {
|
|
82
|
+
const types = pickChallenges({ challengeCount: 2 }).map((c) => c.type);
|
|
83
|
+
const hasNod = types.includes('nod');
|
|
84
|
+
const hasTurn = types.includes('turn');
|
|
85
|
+
expect(hasNod && hasTurn).toBe(false);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('respects a restricted challengePool', () => {
|
|
90
|
+
const picked = pickChallenges({ challengeCount: 2, challengePool: ['blink', 'smile'] });
|
|
91
|
+
expect(picked.every((c) => c.type === 'blink' || c.type === 'smile')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('applies the per-challenge timeout override', () => {
|
|
95
|
+
const picked = pickChallenges({ challengeCount: 2, timeoutPerChallenge: 5 });
|
|
96
|
+
expect(picked.every((c) => c.timeoutSeconds === 5)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('ChallengeTracker (progress)', () => {
|
|
101
|
+
it('advances through challenges and reports completion', () => {
|
|
102
|
+
const tracker = new ChallengeTracker(pickChallenges({ challengeCount: 2 }));
|
|
103
|
+
expect(tracker.totalCount).toBe(2);
|
|
104
|
+
expect(tracker.isComplete).toBe(false);
|
|
105
|
+
|
|
106
|
+
tracker.markCurrentPassed();
|
|
107
|
+
expect(tracker.advance()).toBe(true); // a second challenge exists
|
|
108
|
+
tracker.markCurrentPassed();
|
|
109
|
+
expect(tracker.advance()).toBe(false); // none left
|
|
110
|
+
expect(tracker.isComplete).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { detectEnvironment, resolveBaseUrl, normalizeDevAssetUrl } from '../services/resolveUrl';
|
|
2
|
+
|
|
3
|
+
describe('detectEnvironment', () => {
|
|
4
|
+
it('maps each prefix (pk_ and sk_) to its environment', () => {
|
|
5
|
+
expect(detectEnvironment('pk_dev_abc')).toBe('development');
|
|
6
|
+
expect(detectEnvironment('sk_dev_abc')).toBe('development');
|
|
7
|
+
expect(detectEnvironment('pk_test_abc')).toBe('sandbox');
|
|
8
|
+
expect(detectEnvironment('sk_test_abc')).toBe('sandbox');
|
|
9
|
+
expect(detectEnvironment('pk_live_abc')).toBe('production');
|
|
10
|
+
expect(detectEnvironment('sk_live_abc')).toBe('production');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('throws on an unrecognized / malformed key', () => {
|
|
14
|
+
expect(() => detectEnvironment('nope')).toThrow(/Invalid Myaza API key/);
|
|
15
|
+
expect(() => detectEnvironment('pk_prod_abc')).toThrow();
|
|
16
|
+
expect(() => detectEnvironment('')).toThrow();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('resolveBaseUrl', () => {
|
|
21
|
+
it('uses the hardcoded sandbox/production URLs and ignores devUrl', () => {
|
|
22
|
+
expect(resolveBaseUrl('pk_test_abc')).toBe('https://sandbox.identity.myaza.app');
|
|
23
|
+
expect(resolveBaseUrl('pk_test_abc', 'http://example.test')).toBe('https://sandbox.identity.myaza.app');
|
|
24
|
+
expect(resolveBaseUrl('pk_live_abc')).toBe('https://identity.myaza.app');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uses devUrl for development keys, defaulting to localhost', () => {
|
|
28
|
+
expect(resolveBaseUrl('pk_dev_abc', 'http://192.168.1.5:3001')).toBe('http://192.168.1.5:3001');
|
|
29
|
+
// Under the Node test runner Platform falls back to iOS → localhost.
|
|
30
|
+
expect(resolveBaseUrl('pk_dev_abc')).toBe('http://localhost:3001');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('normalizeDevAssetUrl', () => {
|
|
35
|
+
it('rewrites a localhost-family asset origin to the dev base origin', () => {
|
|
36
|
+
expect(normalizeDevAssetUrl('http://localhost:3001/api/kyc/branding/logo/x', 'http://10.0.2.2:3001')).toBe(
|
|
37
|
+
'http://10.0.2.2:3001/api/kyc/branding/logo/x',
|
|
38
|
+
);
|
|
39
|
+
expect(normalizeDevAssetUrl('http://127.0.0.1:3001/logo.png', 'http://10.0.2.2:3001')).toBe(
|
|
40
|
+
'http://10.0.2.2:3001/logo.png',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is a no-op when the base origin already matches', () => {
|
|
45
|
+
expect(normalizeDevAssetUrl('http://localhost:3001/logo.png', 'http://localhost:3001')).toBe(
|
|
46
|
+
'http://localhost:3001/logo.png',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('never touches production/CDN URLs or an https base', () => {
|
|
51
|
+
// Public CDN asset, dev base → left alone (not a localhost host).
|
|
52
|
+
expect(normalizeDevAssetUrl('https://cdn.myaza.app/logo.png', 'http://10.0.2.2:3001')).toBe(
|
|
53
|
+
'https://cdn.myaza.app/logo.png',
|
|
54
|
+
);
|
|
55
|
+
// https base (sandbox/prod) → never rewrites, even a localhost asset.
|
|
56
|
+
expect(normalizeDevAssetUrl('http://localhost:3001/logo.png', 'https://sandbox.identity.myaza.app')).toBe(
|
|
57
|
+
'http://localhost:3001/logo.png',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes through undefined', () => {
|
|
62
|
+
expect(normalizeDevAssetUrl(undefined, 'http://10.0.2.2:3001')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { validateIdNumber, maskIdNumber } from '../services/validators';
|
|
2
|
+
|
|
3
|
+
describe('validateIdNumber', () => {
|
|
4
|
+
it('validates Nigerian number-only IDs', () => {
|
|
5
|
+
expect(validateIdNumber('NG', 'bvn', '12345678901').valid).toBe(true);
|
|
6
|
+
expect(validateIdNumber('NG', 'bvn', '123').valid).toBe(false);
|
|
7
|
+
expect(validateIdNumber('NG', 'nin', '12345678901').valid).toBe(true);
|
|
8
|
+
expect(validateIdNumber('NG', 'vnin', 'ABCD123456789012').valid).toBe(true);
|
|
9
|
+
expect(validateIdNumber('NG', 'vnin', 'short').valid).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('validates country-prefixed shared keys (passport, national-id)', () => {
|
|
13
|
+
expect(validateIdNumber('NG', 'passport', 'A12345678').valid).toBe(true);
|
|
14
|
+
expect(validateIdNumber('NG', 'passport', 'A1234567').valid).toBe(false); // GH format, not NG
|
|
15
|
+
expect(validateIdNumber('GH', 'passport', 'A1234567').valid).toBe(true);
|
|
16
|
+
expect(validateIdNumber('KE', 'national-id', '12345678').valid).toBe(true);
|
|
17
|
+
expect(validateIdNumber('ZA', 'national-id', '1234567890123').valid).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('validates Ghana Card and PVC patterns', () => {
|
|
21
|
+
expect(validateIdNumber('GH', 'ghana-card', 'GHA-123456789-0').valid).toBe(true);
|
|
22
|
+
expect(validateIdNumber('GH', 'ghana-card', '123456789').valid).toBe(false);
|
|
23
|
+
expect(validateIdNumber('NG', 'pvc', '1234567890123456789').valid).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects empty input', () => {
|
|
27
|
+
expect(validateIdNumber('NG', 'bvn', ' ').valid).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('maskIdNumber', () => {
|
|
32
|
+
it('masks the middle of a long number', () => {
|
|
33
|
+
expect(maskIdNumber('12345678901')).toBe('1234****901');
|
|
34
|
+
});
|
|
35
|
+
it('leaves short values untouched', () => {
|
|
36
|
+
expect(maskIdNumber('1234567')).toBe('1234567');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Linking, Platform, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useTheme } from './runtime';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
import { MyazaButton } from './MyazaButton';
|
|
8
|
+
import { Icon, type IconName } from './Icon';
|
|
9
|
+
|
|
10
|
+
// Camera error states for the document-capture step. Two variants share one
|
|
11
|
+
// scaffold (an 80×80 error-tinted circle + heading + message + actions):
|
|
12
|
+
// • CameraPermissionView — OS permission denied ("Camera access needed")
|
|
13
|
+
// → Open Settings + Try Again (Android only) + Upload
|
|
14
|
+
// • CameraUnavailableView — no camera device / init failure ("Camera not available")
|
|
15
|
+
// → Upload a photo instead
|
|
16
|
+
// Both mirror the Flutter SDK's permission/error treatment.
|
|
17
|
+
|
|
18
|
+
interface ScaffoldProps {
|
|
19
|
+
icon: IconName;
|
|
20
|
+
title: string;
|
|
21
|
+
message: string;
|
|
22
|
+
/** When true (default), vertically centers; false flows top-down below a heading. */
|
|
23
|
+
centered?: boolean;
|
|
24
|
+
children: React.ReactNode; // action buttons
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CameraErrorScaffold({ icon, title, message, centered = true, children }: ScaffoldProps): React.ReactElement {
|
|
28
|
+
const { colors } = useTheme();
|
|
29
|
+
return (
|
|
30
|
+
<View
|
|
31
|
+
style={
|
|
32
|
+
centered
|
|
33
|
+
? { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }
|
|
34
|
+
: { alignItems: 'center', paddingTop: spacing.lg, paddingBottom: spacing.xl }
|
|
35
|
+
}
|
|
36
|
+
>
|
|
37
|
+
<View
|
|
38
|
+
style={{
|
|
39
|
+
width: 80,
|
|
40
|
+
height: 80,
|
|
41
|
+
borderRadius: radius.full,
|
|
42
|
+
backgroundColor: `${colors.error}1A`,
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
justifyContent: 'center',
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<Icon name={icon} size={38} color={colors.error} />
|
|
48
|
+
</View>
|
|
49
|
+
<View style={{ height: spacing.lg }} />
|
|
50
|
+
<MyazaText variant="heading2" style={{ textAlign: 'center' }}>
|
|
51
|
+
{title}
|
|
52
|
+
</MyazaText>
|
|
53
|
+
<View style={{ height: spacing.xs }} />
|
|
54
|
+
<MyazaText variant="bodyMedium" color={colors.textSecondary} style={{ textAlign: 'center' }}>
|
|
55
|
+
{message}
|
|
56
|
+
</MyazaText>
|
|
57
|
+
<View style={{ height: spacing.lg }} />
|
|
58
|
+
<View style={{ width: '100%' }}>{children}</View>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Permission denied ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const DEFAULT_PERMISSION_MESSAGE =
|
|
66
|
+
'Camera access is required to photograph your document. Please allow camera access, or upload a photo instead.';
|
|
67
|
+
|
|
68
|
+
export interface CameraPermissionViewProps {
|
|
69
|
+
message?: string;
|
|
70
|
+
onRetry: () => void;
|
|
71
|
+
/** Secondary action — e.g. "Upload a photo instead" on document capture. */
|
|
72
|
+
onUpload?: () => void;
|
|
73
|
+
centered?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function CameraPermissionView({ message, onRetry, onUpload, centered = true }: CameraPermissionViewProps): React.ReactElement {
|
|
77
|
+
// iOS: re-checking in place can't succeed (only Settings grants, which
|
|
78
|
+
// relaunches the app), so omit "Try Again" there — matches Flutter.
|
|
79
|
+
const showRetry = Platform.OS !== 'ios';
|
|
80
|
+
return (
|
|
81
|
+
<CameraErrorScaffold icon="camera-off" title="Camera access needed" message={message ?? DEFAULT_PERMISSION_MESSAGE} centered={centered}>
|
|
82
|
+
<MyazaButton label="Open Settings" onPress={() => void Linking.openSettings()} />
|
|
83
|
+
{showRetry ? (
|
|
84
|
+
<>
|
|
85
|
+
<View style={{ height: spacing.sm }} />
|
|
86
|
+
<MyazaButton label="Try Again" variant="outline" onPress={onRetry} />
|
|
87
|
+
</>
|
|
88
|
+
) : null}
|
|
89
|
+
{onUpload ? (
|
|
90
|
+
<>
|
|
91
|
+
<View style={{ height: spacing.sm }} />
|
|
92
|
+
<MyazaButton label="Upload a photo instead" variant="ghost" leadingIcon="upload" onPress={onUpload} />
|
|
93
|
+
</>
|
|
94
|
+
) : null}
|
|
95
|
+
</CameraErrorScaffold>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Camera unavailable (no device / init failure) ────────────────────────────
|
|
100
|
+
|
|
101
|
+
const DEFAULT_UNAVAILABLE_MESSAGE =
|
|
102
|
+
"We couldn't start the camera on this device. Please upload a photo of your document instead.";
|
|
103
|
+
|
|
104
|
+
export interface CameraUnavailableViewProps {
|
|
105
|
+
message?: string;
|
|
106
|
+
onUpload?: () => void;
|
|
107
|
+
centered?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function CameraUnavailableView({ message, onUpload, centered = true }: CameraUnavailableViewProps): React.ReactElement {
|
|
111
|
+
return (
|
|
112
|
+
<CameraErrorScaffold icon="video-off" title="Camera not available" message={message ?? DEFAULT_UNAVAILABLE_MESSAGE} centered={centered}>
|
|
113
|
+
{onUpload ? <MyazaButton label="Upload a photo instead" leadingIcon="upload" onPress={onUpload} /> : null}
|
|
114
|
+
</CameraErrorScaffold>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View } from 'react-native';
|
|
3
|
+
import Svg, { Defs, Mask, Path, Rect } from 'react-native-svg';
|
|
4
|
+
import { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from 'react-native-vision-camera';
|
|
5
|
+
|
|
6
|
+
import { radius, spacing } from '../config/theme';
|
|
7
|
+
import { DOCUMENT_VIDEO_BITRATE, VIDEO_CAPTURE_RESOLUTION } from '../config/captureSettings';
|
|
8
|
+
import { useTheme } from './runtime';
|
|
9
|
+
import { useVideoRecorder } from './useVideoRecorder';
|
|
10
|
+
import { MyazaText } from './Typography';
|
|
11
|
+
import { Icon } from './Icon';
|
|
12
|
+
|
|
13
|
+
// Back-camera viewfinder for document capture — the RN mirror of the Flutter
|
|
14
|
+
// SDK's _DocumentViewfinder. Shows a VisionCamera preview with an ID-card guide
|
|
15
|
+
// overlay + manual shutter. This component is only rendered once a camera device
|
|
16
|
+
// exists (or while it's still resolving — a brief spinner); the "Camera not
|
|
17
|
+
// available" error (no device / init failure) is owned by the parent step, which
|
|
18
|
+
// shows a prominent error + upload fallback. Capture is manual (no edge detection).
|
|
19
|
+
|
|
20
|
+
// Card window within the 3:4 viewBox (centred). Height follows the per-ID guide
|
|
21
|
+
// aspect so the overlay matches the auto-crop (cropCardRegion).
|
|
22
|
+
const VB_W = 100;
|
|
23
|
+
const VB_H = 133;
|
|
24
|
+
const GX = 6;
|
|
25
|
+
const GW = 88;
|
|
26
|
+
const C = 7; // corner accent length
|
|
27
|
+
|
|
28
|
+
export interface CameraViewfinderProps {
|
|
29
|
+
active: boolean;
|
|
30
|
+
side: 'front' | 'back';
|
|
31
|
+
documentLabel: string;
|
|
32
|
+
/** Receives the captured still + a best-effort short document video (or null). */
|
|
33
|
+
onCapture: (uri: string, videoPath: string | null) => void;
|
|
34
|
+
/** Card-guide aspect (width ÷ height): 1.586 for ID cards, 1.42 for passports. */
|
|
35
|
+
guideAspect?: number;
|
|
36
|
+
/** Capture is disabled while a previous capture/compress is in flight. */
|
|
37
|
+
busy?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CameraViewfinder({
|
|
41
|
+
active,
|
|
42
|
+
side,
|
|
43
|
+
documentLabel,
|
|
44
|
+
onCapture,
|
|
45
|
+
guideAspect = 1.586,
|
|
46
|
+
busy = false,
|
|
47
|
+
}: CameraViewfinderProps): React.ReactElement {
|
|
48
|
+
const { colors } = useTheme();
|
|
49
|
+
const device = useCameraDevice('back');
|
|
50
|
+
// v5: document-conservative still (OCR). Capture via the photo output, not a
|
|
51
|
+
// camera ref. Quality is prioritised so small text stays legible.
|
|
52
|
+
const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality' });
|
|
53
|
+
// A short, bitrate-capped document video recorded alongside the still (uploaded
|
|
54
|
+
// best-effort as document_front_video / document_back_video). Mirrors Flutter.
|
|
55
|
+
const videoOutput = useVideoOutput({
|
|
56
|
+
enableAudio: false,
|
|
57
|
+
fileType: 'mp4',
|
|
58
|
+
targetBitRate: DOCUMENT_VIDEO_BITRATE,
|
|
59
|
+
targetResolution: VIDEO_CAPTURE_RESOLUTION,
|
|
60
|
+
// iOS only honours targetBitRate/targetResolution on the AVAssetWriter
|
|
61
|
+
// pipeline; without this it records full-quality (the front clip exceeded the
|
|
62
|
+
// 25MB upload cap). This keeps the document video small.
|
|
63
|
+
enablePersistentRecorder: true,
|
|
64
|
+
});
|
|
65
|
+
const ready = !!device;
|
|
66
|
+
const recorder = useVideoRecorder(videoOutput, ready);
|
|
67
|
+
|
|
68
|
+
// Record while the side's camera is live; restart when the side changes.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (active && ready) recorder.start();
|
|
71
|
+
return () => {
|
|
72
|
+
void recorder.stop();
|
|
73
|
+
};
|
|
74
|
+
// `side` in the deps restarts the recording for the back side.
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [active, ready, side]);
|
|
77
|
+
|
|
78
|
+
const GH = GW / guideAspect;
|
|
79
|
+
const GY = (VB_H - GH) / 2;
|
|
80
|
+
|
|
81
|
+
const capture = async () => {
|
|
82
|
+
if (busy || !ready) return;
|
|
83
|
+
try {
|
|
84
|
+
// Stop the video first (and grab its path) so the still and the clip don't
|
|
85
|
+
// contend for the camera, then capture the OCR still.
|
|
86
|
+
const videoPath = await recorder.stop();
|
|
87
|
+
const file = await photoOutput.capturePhotoToFile({ flashMode: 'off' }, {});
|
|
88
|
+
onCapture(`file://${file.filePath}`, videoPath);
|
|
89
|
+
} catch {
|
|
90
|
+
/* a failed shutter is a no-op — the user can tap again */
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View style={{ width: '100%', aspectRatio: 3 / 4, borderRadius: radius.lg, overflow: 'hidden', backgroundColor: '#111111' }}>
|
|
96
|
+
{ready ? (
|
|
97
|
+
<Camera style={{ flex: 1 }} device={device} isActive={active} outputs={[photoOutput, videoOutput]} />
|
|
98
|
+
) : (
|
|
99
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
100
|
+
<ActivityIndicator color="#FFFFFF" />
|
|
101
|
+
</View>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Card-guide overlay (only over a live preview) */}
|
|
105
|
+
{ready ? (
|
|
106
|
+
<>
|
|
107
|
+
<Svg style={{ position: 'absolute', inset: 0 }} viewBox={`0 0 ${VB_W} ${VB_H}`} preserveAspectRatio="none">
|
|
108
|
+
<Defs>
|
|
109
|
+
<Mask id="cardMask">
|
|
110
|
+
<Rect width={VB_W} height={VB_H} fill="white" />
|
|
111
|
+
<Rect x={GX} y={GY} width={GW} height={GH} rx={3} fill="black" />
|
|
112
|
+
</Mask>
|
|
113
|
+
</Defs>
|
|
114
|
+
<Rect width={VB_W} height={VB_H} fill="rgba(0,0,0,0.55)" mask="url(#cardMask)" />
|
|
115
|
+
<Rect x={GX} y={GY} width={GW} height={GH} rx={3} fill="none" stroke="rgba(255,255,255,0.85)" strokeWidth={0.6} strokeDasharray="5 3" />
|
|
116
|
+
<Path d={`M${GX},${GY + C} L${GX},${GY} L${GX + C},${GY}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
|
|
117
|
+
<Path d={`M${GX + GW - C},${GY} L${GX + GW},${GY} L${GX + GW},${GY + C}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
|
|
118
|
+
<Path d={`M${GX + GW},${GY + GH - C} L${GX + GW},${GY + GH} L${GX + GW - C},${GY + GH}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
|
|
119
|
+
<Path d={`M${GX + C},${GY + GH} L${GX},${GY + GH} L${GX},${GY + GH - C}`} stroke={colors.primary} strokeWidth={1.6} fill="none" strokeLinecap="round" />
|
|
120
|
+
</Svg>
|
|
121
|
+
<View style={{ position: 'absolute', top: spacing.md, left: 0, right: 0, alignItems: 'center' }}>
|
|
122
|
+
<View style={{ backgroundColor: 'rgba(0,0,0,0.4)', paddingHorizontal: spacing.sm + 4, paddingVertical: 6, borderRadius: radius.full }}>
|
|
123
|
+
<MyazaText variant="bodySmall" color="#FFFFFF">
|
|
124
|
+
{`Align the ${side === 'back' ? 'BACK' : 'FRONT'} of your ${documentLabel}`}
|
|
125
|
+
</MyazaText>
|
|
126
|
+
</View>
|
|
127
|
+
</View>
|
|
128
|
+
</>
|
|
129
|
+
) : null}
|
|
130
|
+
|
|
131
|
+
{/* Shutter — disabled when the camera isn't ready */}
|
|
132
|
+
<View style={{ position: 'absolute', bottom: spacing.lg, left: 0, right: 0, alignItems: 'center' }}>
|
|
133
|
+
<Pressable
|
|
134
|
+
onPress={capture}
|
|
135
|
+
disabled={busy || !ready}
|
|
136
|
+
accessibilityRole="button"
|
|
137
|
+
accessibilityLabel="Capture photo"
|
|
138
|
+
style={({ pressed }) => ({
|
|
139
|
+
width: 64,
|
|
140
|
+
height: 64,
|
|
141
|
+
borderRadius: radius.full,
|
|
142
|
+
backgroundColor: ready ? '#FFFFFF' : colors.gray400,
|
|
143
|
+
borderWidth: 4,
|
|
144
|
+
borderColor: ready ? colors.primary : colors.gray300,
|
|
145
|
+
alignItems: 'center',
|
|
146
|
+
justifyContent: 'center',
|
|
147
|
+
opacity: busy ? 0.6 : 1,
|
|
148
|
+
transform: [{ scale: pressed ? 0.94 : 1 }],
|
|
149
|
+
})}
|
|
150
|
+
>
|
|
151
|
+
{busy ? <ActivityIndicator color={colors.primary} /> : <Icon name="camera" size={24} color={ready ? colors.primary : '#FFFFFF'} />}
|
|
152
|
+
</Pressable>
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|