@hexar/biometric-identity-sdk-react-native 1.0.6 ā 1.0.8
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/dist/components/BiometricIdentityFlow.d.ts.map +1 -1
- package/dist/components/BiometricIdentityFlow.js +12 -3
- package/dist/components/CameraCapture.d.ts +2 -1
- package/dist/components/CameraCapture.d.ts.map +1 -1
- package/dist/components/CameraCapture.js +22 -16
- package/dist/components/InstructionsScreen.d.ts +1 -0
- package/dist/components/InstructionsScreen.d.ts.map +1 -1
- package/dist/components/InstructionsScreen.js +18 -1
- package/dist/components/VideoRecorder.d.ts +2 -1
- package/dist/components/VideoRecorder.d.ts.map +1 -1
- package/dist/components/VideoRecorder.js +184 -95
- package/package.json +1 -1
- package/src/components/BiometricIdentityFlow.tsx +13 -0
- package/src/components/CameraCapture.tsx +26 -13
- package/src/components/InstructionsScreen.tsx +28 -1
- package/src/components/VideoRecorder.tsx +215 -95
|
@@ -42,6 +42,7 @@ const react_1 = __importStar(require("react"));
|
|
|
42
42
|
const react_native_1 = require("react-native");
|
|
43
43
|
const react_native_vision_camera_1 = require("react-native-vision-camera");
|
|
44
44
|
const react_native_permissions_1 = require("react-native-permissions");
|
|
45
|
+
const biometric_identity_sdk_core_1 = require("@hexar/biometric-identity-sdk-core");
|
|
45
46
|
// Default challenge set (used if backend not available)
|
|
46
47
|
const DEFAULT_CHALLENGES = [
|
|
47
48
|
{
|
|
@@ -73,20 +74,23 @@ const DEFAULT_CHALLENGES = [
|
|
|
73
74
|
icon: 'š',
|
|
74
75
|
},
|
|
75
76
|
];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
77
|
+
const getInstructionMap = (strings) => ({
|
|
78
|
+
look_left: { text: strings.liveness.instructions.lookLeft || 'Slowly turn your head LEFT', icon: 'ā' },
|
|
79
|
+
look_right: { text: strings.liveness.instructions.lookRight || 'Slowly turn your head RIGHT', icon: 'ā' },
|
|
80
|
+
look_up: { text: strings.liveness.instructions.lookUp || 'Look UP', icon: 'ā' },
|
|
81
|
+
look_down: { text: strings.liveness.instructions.lookDown || 'Look DOWN', icon: 'ā' },
|
|
82
|
+
turn_head_left: { text: strings.liveness.instructions.turnHeadLeft || 'Turn your head LEFT', icon: 'ā' },
|
|
83
|
+
turn_head_right: { text: strings.liveness.instructions.turnHeadRight || 'Turn your head RIGHT', icon: 'ā' },
|
|
84
|
+
smile: { text: strings.liveness.instructions.smile || 'Smile š', icon: 'š' },
|
|
85
|
+
blink: { text: strings.liveness.instructions.blink || 'Blink your eyes naturally', icon: 'š' },
|
|
86
|
+
open_mouth: { text: strings.liveness.instructions.openMouth || 'Open your mouth slightly', icon: 'š®' },
|
|
87
|
+
stay_still: { text: strings.liveness.instructions.stayStill || 'Look at the camera and stay still', icon: 'š·' },
|
|
88
|
+
});
|
|
89
|
+
const VideoRecorder = ({ theme, language, duration, instructions, challenges: propChallenges, sessionId, smartMode = true, onComplete, onCancel, onFetchChallenges, }) => {
|
|
90
|
+
if (language) {
|
|
91
|
+
(0, biometric_identity_sdk_core_1.setLanguage)(language);
|
|
92
|
+
}
|
|
93
|
+
const strings = (0, biometric_identity_sdk_core_1.getStrings)();
|
|
90
94
|
// State
|
|
91
95
|
const [phase, setPhase] = (0, react_1.useState)('loading');
|
|
92
96
|
const [countdown, setCountdown] = (0, react_1.useState)(3);
|
|
@@ -107,12 +111,13 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
107
111
|
const pulseAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
108
112
|
const arrowAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
109
113
|
const progressAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
110
|
-
// Refs
|
|
111
114
|
const recordingStartTime = (0, react_1.useRef)(0);
|
|
112
115
|
const frameInterval = (0, react_1.useRef)(null);
|
|
113
116
|
const frameCaptureInterval = (0, react_1.useRef)(null);
|
|
114
|
-
|
|
115
|
-
const
|
|
117
|
+
const videoRecordingRef = (0, react_1.useRef)(null);
|
|
118
|
+
const isRecordingRef = (0, react_1.useRef)(false);
|
|
119
|
+
const minDurationMs = 8000;
|
|
120
|
+
const totalDuration = duration || Math.max(minDurationMs, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
116
121
|
// Check camera permissions
|
|
117
122
|
(0, react_1.useEffect)(() => {
|
|
118
123
|
const checkPermissions = async () => {
|
|
@@ -159,13 +164,13 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
159
164
|
challengeList = await onFetchChallenges();
|
|
160
165
|
}
|
|
161
166
|
else if (instructions && instructions.length > 0) {
|
|
162
|
-
|
|
167
|
+
const instructionMap = getInstructionMap(strings);
|
|
163
168
|
challengeList = instructions.map((inst, idx) => ({
|
|
164
169
|
action: inst,
|
|
165
|
-
instruction:
|
|
170
|
+
instruction: instructionMap[inst]?.text || inst,
|
|
166
171
|
duration_ms: 2000,
|
|
167
172
|
order: idx + 1,
|
|
168
|
-
icon:
|
|
173
|
+
icon: instructionMap[inst]?.icon,
|
|
169
174
|
}));
|
|
170
175
|
}
|
|
171
176
|
else {
|
|
@@ -254,124 +259,213 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
254
259
|
}),
|
|
255
260
|
])).start();
|
|
256
261
|
}, [arrowAnim]);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
const resetAndRetry = (0, react_1.useCallback)(() => {
|
|
263
|
+
setFrames([]);
|
|
264
|
+
setCompletedChallenges([]);
|
|
265
|
+
setCurrentChallengeIndex(0);
|
|
266
|
+
setChallengeProgress(0);
|
|
267
|
+
setOverallProgress(0);
|
|
268
|
+
recordingStartTime.current = 0;
|
|
269
|
+
setPhase('countdown');
|
|
270
|
+
setCountdown(3);
|
|
271
|
+
}, []);
|
|
272
|
+
const handleRecordingError = (0, react_1.useCallback)((error) => {
|
|
273
|
+
setPhase('loading');
|
|
274
|
+
react_native_1.Alert.alert('Recording Error', 'Failed to record video. Please try again.', [{ text: 'OK', onPress: onCancel }]);
|
|
275
|
+
}, [onCancel]);
|
|
276
|
+
const handleVideoComplete = (0, react_1.useCallback)(async (video) => {
|
|
277
|
+
console.log('handleVideoComplete called with video:', video?.path);
|
|
278
|
+
try {
|
|
279
|
+
setPhase('processing');
|
|
280
|
+
const actualDuration = Date.now() - recordingStartTime.current;
|
|
281
|
+
console.log('Video duration:', actualDuration, 'min required:', minDurationMs);
|
|
282
|
+
if (actualDuration < minDurationMs) {
|
|
283
|
+
setPhase('recording');
|
|
284
|
+
react_native_1.Alert.alert(strings.errors.videoTooShort?.title || 'Recording Too Short', strings.errors.videoTooShort?.message || `Video must be at least ${minDurationMs / 1000} seconds. Please try again.`, [{ text: strings.common.retry || 'OK', onPress: resetAndRetry }]);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let videoBase64 = '';
|
|
288
|
+
if (video?.path) {
|
|
289
|
+
try {
|
|
290
|
+
const RNFS = require('react-native-fs');
|
|
291
|
+
videoBase64 = await RNFS.readFile(video.path, 'base64');
|
|
292
|
+
console.log('Video file read successfully, size:', videoBase64.length);
|
|
293
|
+
}
|
|
294
|
+
catch (fsError) {
|
|
295
|
+
console.warn('Could not read video file, using captured frames:', fsError);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const result = {
|
|
299
|
+
frames: frames.length > 0 ? frames : (videoBase64 ? [videoBase64] : []),
|
|
300
|
+
duration: actualDuration,
|
|
301
|
+
instructionsFollowed: completedChallenges.length === challenges.length,
|
|
302
|
+
qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
|
|
303
|
+
challengesCompleted: completedChallenges,
|
|
304
|
+
sessionId,
|
|
305
|
+
};
|
|
306
|
+
console.log('Video recording completed successfully:', {
|
|
307
|
+
duration: actualDuration,
|
|
308
|
+
frames: result.frames.length,
|
|
309
|
+
challengesCompleted: completedChallenges.length,
|
|
310
|
+
instructionsFollowed: result.instructionsFollowed
|
|
311
|
+
});
|
|
312
|
+
onComplete(result);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
console.error('Error processing video:', error);
|
|
316
|
+
setPhase('recording');
|
|
317
|
+
handleRecordingError(error);
|
|
318
|
+
}
|
|
319
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs]);
|
|
320
|
+
const startFrameCapture = (0, react_1.useCallback)(() => {
|
|
262
321
|
if (cameraRef.current && device) {
|
|
263
|
-
// Capture frames periodically (every 100ms = ~10 FPS)
|
|
264
322
|
frameCaptureInterval.current = setInterval(async () => {
|
|
265
323
|
try {
|
|
266
|
-
// Take a photo frame (react-native-vision-camera doesn't have direct frame access in v4)
|
|
267
|
-
// We'll use takeSnapshot or capture frames via photo
|
|
268
324
|
const photo = await cameraRef.current?.takePhoto({
|
|
269
325
|
flash: 'off',
|
|
270
326
|
});
|
|
271
327
|
if (photo) {
|
|
272
|
-
// Convert to base64 if possible, otherwise use file path
|
|
273
328
|
try {
|
|
274
329
|
const RNFS = require('react-native-fs');
|
|
275
330
|
const base64 = await RNFS.readFile(photo.path, 'base64');
|
|
276
|
-
setFrames(prev =>
|
|
331
|
+
setFrames(prev => {
|
|
332
|
+
if (prev.length < 100) {
|
|
333
|
+
return [...prev, base64];
|
|
334
|
+
}
|
|
335
|
+
return prev;
|
|
336
|
+
});
|
|
277
337
|
}
|
|
278
338
|
catch (fsError) {
|
|
279
|
-
|
|
280
|
-
|
|
339
|
+
setFrames(prev => {
|
|
340
|
+
if (prev.length < 100) {
|
|
341
|
+
return [...prev, photo.path];
|
|
342
|
+
}
|
|
343
|
+
return prev;
|
|
344
|
+
});
|
|
281
345
|
}
|
|
282
346
|
}
|
|
283
347
|
}
|
|
284
348
|
catch (error) {
|
|
285
349
|
console.warn('Frame capture error:', error);
|
|
286
|
-
// Continue even if one frame fails
|
|
287
350
|
}
|
|
288
351
|
}, 100);
|
|
289
352
|
}
|
|
290
|
-
else {
|
|
291
|
-
// Fallback: simulate frames if camera not available
|
|
292
|
-
frameInterval.current = setInterval(() => {
|
|
293
|
-
setFrames(prev => [...prev, `frame_${Date.now()}`]);
|
|
294
|
-
}, 100);
|
|
295
|
-
}
|
|
296
|
-
// Start first challenge
|
|
297
|
-
runChallenge(0);
|
|
298
353
|
}, [device]);
|
|
299
|
-
|
|
354
|
+
const stopRecording = (0, react_1.useCallback)(async () => {
|
|
355
|
+
console.log('Stopping recording...');
|
|
356
|
+
isRecordingRef.current = false;
|
|
357
|
+
if (videoRecordingRef.current) {
|
|
358
|
+
try {
|
|
359
|
+
console.log('Stopping video recording');
|
|
360
|
+
await videoRecordingRef.current.stop();
|
|
361
|
+
console.log('Video recording stopped');
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
console.error('Error stopping video recording:', error);
|
|
365
|
+
}
|
|
366
|
+
videoRecordingRef.current = null;
|
|
367
|
+
}
|
|
368
|
+
if (frameCaptureInterval.current) {
|
|
369
|
+
clearInterval(frameCaptureInterval.current);
|
|
370
|
+
frameCaptureInterval.current = null;
|
|
371
|
+
}
|
|
372
|
+
}, []);
|
|
300
373
|
const runChallenge = (0, react_1.useCallback)((index) => {
|
|
301
374
|
if (index >= challenges.length) {
|
|
302
|
-
|
|
375
|
+
if (isRecordingRef.current) {
|
|
376
|
+
const elapsed = Date.now() - recordingStartTime.current;
|
|
377
|
+
if (elapsed < minDurationMs) {
|
|
378
|
+
setTimeout(() => {
|
|
379
|
+
if (isRecordingRef.current) {
|
|
380
|
+
stopRecording();
|
|
381
|
+
}
|
|
382
|
+
}, minDurationMs - elapsed);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
stopRecording();
|
|
386
|
+
}
|
|
303
387
|
return;
|
|
304
388
|
}
|
|
305
389
|
const challenge = challenges[index];
|
|
306
390
|
setCurrentChallengeIndex(index);
|
|
307
391
|
setChallengeProgress(0);
|
|
308
|
-
// Fade in instruction
|
|
309
392
|
react_native_1.Animated.timing(fadeAnim, {
|
|
310
393
|
toValue: 1,
|
|
311
394
|
duration: 300,
|
|
312
395
|
useNativeDriver: true,
|
|
313
396
|
}).start();
|
|
314
|
-
// Animate arrow for directional challenges
|
|
315
397
|
if (challenge.action.includes('left') || challenge.action.includes('right')) {
|
|
316
398
|
animateArrow(challenge.action);
|
|
317
399
|
}
|
|
318
|
-
// Progress animation for this challenge
|
|
319
400
|
react_native_1.Animated.timing(progressAnim, {
|
|
320
401
|
toValue: 100,
|
|
321
402
|
duration: challenge.duration_ms,
|
|
322
403
|
useNativeDriver: false,
|
|
323
404
|
}).start();
|
|
324
|
-
// Update progress in real-time
|
|
325
405
|
const progressInterval = setInterval(() => {
|
|
326
406
|
setChallengeProgress(prev => {
|
|
327
407
|
const newProgress = prev + (100 / (challenge.duration_ms / 100));
|
|
328
408
|
return Math.min(100, newProgress);
|
|
329
409
|
});
|
|
330
|
-
// Update overall progress
|
|
331
410
|
const elapsed = Date.now() - recordingStartTime.current;
|
|
332
|
-
const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
|
|
411
|
+
const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
333
412
|
setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
|
|
334
413
|
}, 100);
|
|
335
|
-
// Move to next challenge after duration
|
|
336
414
|
setTimeout(() => {
|
|
337
415
|
clearInterval(progressInterval);
|
|
338
|
-
// Mark challenge as completed
|
|
339
416
|
setCompletedChallenges(prev => [...prev, challenge.action]);
|
|
340
|
-
// Fade out current instruction
|
|
341
417
|
react_native_1.Animated.timing(fadeAnim, {
|
|
342
418
|
toValue: 0,
|
|
343
419
|
duration: 200,
|
|
344
420
|
useNativeDriver: true,
|
|
345
421
|
}).start(() => {
|
|
346
|
-
// Reset and start next challenge
|
|
347
422
|
progressAnim.setValue(0);
|
|
348
423
|
runChallenge(index + 1);
|
|
349
424
|
});
|
|
350
425
|
}, challenge.duration_ms);
|
|
351
|
-
}, [challenges, fadeAnim, progressAnim, animateArrow]);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
426
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
|
|
427
|
+
const startRecording = (0, react_1.useCallback)(async () => {
|
|
428
|
+
setPhase('recording');
|
|
429
|
+
recordingStartTime.current = Date.now();
|
|
430
|
+
isRecordingRef.current = true;
|
|
431
|
+
console.log('Starting video recording, total duration:', totalDuration);
|
|
432
|
+
if (cameraRef.current && device) {
|
|
433
|
+
try {
|
|
434
|
+
videoRecordingRef.current = await cameraRef.current.startRecording({
|
|
435
|
+
flash: 'off',
|
|
436
|
+
onRecordingFinished: (video) => {
|
|
437
|
+
console.log('Video recording finished callback called', video);
|
|
438
|
+
if (isRecordingRef.current) {
|
|
439
|
+
handleVideoComplete(video);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
onRecordingError: (error) => {
|
|
443
|
+
console.error('Recording error:', error);
|
|
444
|
+
handleRecordingError(error);
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
console.log('Video recording started successfully');
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
console.warn('Video recording not available, falling back to frame capture:', error);
|
|
451
|
+
startFrameCapture();
|
|
452
|
+
}
|
|
356
453
|
}
|
|
357
|
-
|
|
358
|
-
|
|
454
|
+
else {
|
|
455
|
+
console.log('Camera not available, using frame capture');
|
|
456
|
+
startFrameCapture();
|
|
359
457
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
};
|
|
372
|
-
onComplete(result);
|
|
373
|
-
}, 500);
|
|
374
|
-
}, [frames, completedChallenges, challenges, sessionId, onComplete]);
|
|
458
|
+
runChallenge(0);
|
|
459
|
+
const timeoutId = setTimeout(() => {
|
|
460
|
+
console.log('Recording timeout reached, stopping recording');
|
|
461
|
+
if (isRecordingRef.current) {
|
|
462
|
+
stopRecording().catch(err => {
|
|
463
|
+
console.error('Error stopping recording on timeout:', err);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}, totalDuration);
|
|
467
|
+
return () => clearTimeout(timeoutId);
|
|
468
|
+
}, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
|
|
375
469
|
// Current challenge
|
|
376
470
|
const currentChallenge = challenges[currentChallengeIndex];
|
|
377
471
|
// Get direction arrow for the current challenge
|
|
@@ -408,20 +502,22 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
408
502
|
if (!hasPermission) {
|
|
409
503
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
410
504
|
react_1.default.createElement(react_native_1.View, { style: styles.permissionContainer },
|
|
411
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.permissionText },
|
|
505
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.permissionText }, typeof strings.errors.cameraPermissionDenied === 'string'
|
|
506
|
+
? strings.errors.cameraPermissionDenied
|
|
507
|
+
: strings.errors.cameraPermissionDenied?.message || 'Camera permission is required'),
|
|
412
508
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }], onPress: onCancel },
|
|
413
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] },
|
|
509
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] }, strings.common.cancel || 'Cancel')))));
|
|
414
510
|
}
|
|
415
511
|
if (!device) {
|
|
416
512
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
417
513
|
react_1.default.createElement(react_native_1.View, { style: styles.permissionContainer },
|
|
418
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.permissionText },
|
|
514
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.permissionText }, strings.errors.cameraNotAvailable || 'Camera not available'),
|
|
419
515
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }], onPress: onCancel },
|
|
420
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] },
|
|
516
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] }, strings.common.cancel || 'Cancel')))));
|
|
421
517
|
}
|
|
422
518
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
423
519
|
react_1.default.createElement(react_native_1.View, { style: styles.cameraContainer },
|
|
424
|
-
react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: phase === 'recording' || phase === 'countdown', video:
|
|
520
|
+
react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: phase === 'recording' || phase === 'countdown', video: true, audio: false }),
|
|
425
521
|
react_1.default.createElement(react_native_1.View, { style: styles.overlay },
|
|
426
522
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
427
523
|
styles.faceOval,
|
|
@@ -432,9 +528,9 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
432
528
|
] }),
|
|
433
529
|
phase === 'recording' && getDirectionIndicator()),
|
|
434
530
|
phase === 'loading' && (react_1.default.createElement(react_native_1.View, { style: styles.centeredOverlay },
|
|
435
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.loadingText },
|
|
531
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.loadingText }, strings.liveness.preparing || 'Preparing challenges...'))),
|
|
436
532
|
phase === 'countdown' && (react_1.default.createElement(react_native_1.View, { style: styles.countdownContainer },
|
|
437
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.getReadyText },
|
|
533
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.getReadyText }, strings.liveness.getReady || 'Get Ready!'),
|
|
438
534
|
react_1.default.createElement(react_native_1.Animated.Text, { style: [
|
|
439
535
|
styles.countdownText,
|
|
440
536
|
{ transform: [{ scale: scaleAnim }] }
|
|
@@ -444,7 +540,7 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
444
540
|
styles.recordingDot,
|
|
445
541
|
{ transform: [{ scale: pulseAnim }] }
|
|
446
542
|
] }),
|
|
447
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.recordingText },
|
|
543
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.recordingText }, strings.liveness.recording || 'Recording'))),
|
|
448
544
|
(phase === 'recording' || phase === 'processing') && (react_1.default.createElement(react_native_1.View, { style: styles.progressContainer },
|
|
449
545
|
react_1.default.createElement(react_native_1.View, { style: styles.progressBar },
|
|
450
546
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
@@ -478,21 +574,14 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
478
574
|
" / ",
|
|
479
575
|
challenges.length))),
|
|
480
576
|
phase === 'processing' && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
|
|
481
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.processingText },
|
|
577
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.processingText }, strings.liveness.processing || 'Processing video...')))),
|
|
482
578
|
react_1.default.createElement(react_native_1.View, { style: styles.bottomContainer },
|
|
483
579
|
phase === 'countdown' && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
484
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.bottomText },
|
|
485
|
-
"You'll perform ",
|
|
486
|
-
challenges.length,
|
|
487
|
-
" action",
|
|
488
|
-
challenges.length > 1 ? 's' : '',
|
|
489
|
-
".",
|
|
490
|
-
'\n',
|
|
491
|
-
"Follow the on-screen instructions."),
|
|
580
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.bottomText }, strings.liveness.countdownMessage || `You'll perform ${challenges.length} action${challenges.length > 1 ? 's' : ''}.\nFollow the on-screen instructions.`),
|
|
492
581
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }], onPress: onCancel },
|
|
493
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] },
|
|
494
|
-
phase === 'recording' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText },
|
|
495
|
-
phase === 'processing' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText },
|
|
582
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] }, strings.common.cancel || 'Cancel')))),
|
|
583
|
+
phase === 'recording' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText }, strings.liveness.recordingInstructions || 'Keep your face visible and follow the instructions')),
|
|
584
|
+
phase === 'processing' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText }, strings.validation.almostDone || 'Almost done...')))));
|
|
496
585
|
};
|
|
497
586
|
exports.VideoRecorder = VideoRecorder;
|
|
498
587
|
const styles = react_native_1.StyleSheet.create({
|
package/package.json
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ValidationResult,
|
|
18
18
|
ThemeConfig,
|
|
19
19
|
BiometricError,
|
|
20
|
+
BiometricErrorCode,
|
|
20
21
|
SDKStep,
|
|
21
22
|
getStrings,
|
|
22
23
|
setLanguage,
|
|
@@ -74,11 +75,16 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
|
|
|
74
75
|
const [isLoadingChallenges, setIsLoadingChallenges] = useState(false);
|
|
75
76
|
|
|
76
77
|
// Set language early, before any components render
|
|
78
|
+
// Priority: language prop > SDK config language > default 'en'
|
|
77
79
|
// Run on mount and whenever language prop changes
|
|
78
80
|
useEffect(() => {
|
|
79
81
|
if (language) {
|
|
82
|
+
// If language prop is provided, override the config
|
|
80
83
|
setLanguage(language);
|
|
81
84
|
}
|
|
85
|
+
// If no language prop, the language should already be set by BiometricIdentitySDK.configure()
|
|
86
|
+
// The global language state is set when configure() is called, so getStrings() will
|
|
87
|
+
// automatically return strings for the configured language (or 'en' as default)
|
|
82
88
|
}, [language]);
|
|
83
89
|
|
|
84
90
|
const strings = getStrings();
|
|
@@ -188,6 +194,11 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
|
|
|
188
194
|
theme={theme}
|
|
189
195
|
language={language}
|
|
190
196
|
onStart={() => setShowInstructions(false)}
|
|
197
|
+
onCancel={onError ? () => onError({
|
|
198
|
+
name: 'BiometricError',
|
|
199
|
+
message: 'User cancelled',
|
|
200
|
+
code: BiometricErrorCode.USER_CANCELLED,
|
|
201
|
+
} as BiometricError) : undefined}
|
|
191
202
|
styles={customStyles}
|
|
192
203
|
/>
|
|
193
204
|
);
|
|
@@ -199,6 +210,7 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
|
|
|
199
210
|
return (
|
|
200
211
|
<VideoRecorder
|
|
201
212
|
theme={theme}
|
|
213
|
+
language={language}
|
|
202
214
|
challenges={currentChallenges}
|
|
203
215
|
smartMode={smartLivenessMode}
|
|
204
216
|
sessionId={sdk.getSessionId() || undefined}
|
|
@@ -216,6 +228,7 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
|
|
|
216
228
|
<CameraCapture
|
|
217
229
|
mode={cameraMode}
|
|
218
230
|
theme={theme}
|
|
231
|
+
language={language}
|
|
219
232
|
onCapture={handleCaptureComplete}
|
|
220
233
|
onCancel={() => setShowCamera(false)}
|
|
221
234
|
/>
|
|
@@ -15,13 +15,14 @@ import {
|
|
|
15
15
|
} from 'react-native';
|
|
16
16
|
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
17
17
|
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
|
18
|
-
import { ThemeConfig } from '@hexar/biometric-identity-sdk-core';
|
|
18
|
+
import { ThemeConfig, SupportedLanguage, getStrings, setLanguage } from '@hexar/biometric-identity-sdk-core';
|
|
19
19
|
|
|
20
20
|
const { width, height } = Dimensions.get('window');
|
|
21
21
|
|
|
22
22
|
export interface CameraCaptureProps {
|
|
23
23
|
mode: 'front' | 'back';
|
|
24
24
|
theme?: ThemeConfig;
|
|
25
|
+
language?: SupportedLanguage;
|
|
25
26
|
onCapture: (imageData: string) => void;
|
|
26
27
|
onCancel: () => void;
|
|
27
28
|
}
|
|
@@ -29,6 +30,7 @@ export interface CameraCaptureProps {
|
|
|
29
30
|
export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
30
31
|
mode,
|
|
31
32
|
theme,
|
|
33
|
+
language,
|
|
32
34
|
onCapture,
|
|
33
35
|
onCancel,
|
|
34
36
|
}) => {
|
|
@@ -37,6 +39,11 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
37
39
|
const cameraRef = useRef<Camera>(null);
|
|
38
40
|
const { hasPermission: cameraPermission, requestPermission } = useCameraPermission();
|
|
39
41
|
|
|
42
|
+
if (language) {
|
|
43
|
+
setLanguage(language);
|
|
44
|
+
}
|
|
45
|
+
const strings = getStrings();
|
|
46
|
+
|
|
40
47
|
// Get camera device (back camera for document capture)
|
|
41
48
|
const device = useCameraDevice('back');
|
|
42
49
|
|
|
@@ -69,9 +76,12 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
69
76
|
} catch (error) {
|
|
70
77
|
console.error('Permission check error:', error);
|
|
71
78
|
setHasPermission(false);
|
|
79
|
+
const errorMsg = typeof strings.errors.cameraPermissionDenied === 'string'
|
|
80
|
+
? strings.errors.cameraPermissionDenied
|
|
81
|
+
: strings.errors.cameraPermissionDenied?.message || 'Please enable camera access in your device settings to capture your ID document.';
|
|
72
82
|
Alert.alert(
|
|
73
83
|
'Camera Permission Required',
|
|
74
|
-
|
|
84
|
+
errorMsg
|
|
75
85
|
);
|
|
76
86
|
}
|
|
77
87
|
};
|
|
@@ -133,25 +143,29 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
133
143
|
};
|
|
134
144
|
|
|
135
145
|
const instructions = mode === 'front'
|
|
136
|
-
?
|
|
137
|
-
:
|
|
146
|
+
? strings.capture.frontId.instruction
|
|
147
|
+
: strings.capture.backId.instruction;
|
|
138
148
|
|
|
139
149
|
if (!hasPermission) {
|
|
140
150
|
return (
|
|
141
151
|
<View style={styles.container}>
|
|
142
152
|
<View style={styles.permissionContainer}>
|
|
143
|
-
<Text style={styles.permissionText}>
|
|
153
|
+
<Text style={styles.permissionText}>
|
|
154
|
+
{typeof strings.errors.cameraPermissionDenied === 'string'
|
|
155
|
+
? strings.errors.cameraPermissionDenied
|
|
156
|
+
: strings.errors.cameraPermissionDenied?.message || 'Camera permission is required'}
|
|
157
|
+
</Text>
|
|
144
158
|
<TouchableOpacity
|
|
145
159
|
style={[styles.button, { backgroundColor: theme?.primaryColor || '#6366F1' }]}
|
|
146
160
|
onPress={checkPermissions}
|
|
147
161
|
>
|
|
148
|
-
<Text style={styles.buttonText}>Grant Permission</Text>
|
|
162
|
+
<Text style={styles.buttonText}>{strings.common.grantPermission || 'Grant Permission'}</Text>
|
|
149
163
|
</TouchableOpacity>
|
|
150
164
|
<TouchableOpacity
|
|
151
165
|
style={[styles.button, styles.cancelButton]}
|
|
152
166
|
onPress={onCancel}
|
|
153
167
|
>
|
|
154
|
-
<Text style={styles.buttonText}>Cancel</Text>
|
|
168
|
+
<Text style={styles.buttonText}>{strings.common.cancel || 'Cancel'}</Text>
|
|
155
169
|
</TouchableOpacity>
|
|
156
170
|
</View>
|
|
157
171
|
</View>
|
|
@@ -162,12 +176,12 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
162
176
|
return (
|
|
163
177
|
<View style={styles.container}>
|
|
164
178
|
<View style={styles.permissionContainer}>
|
|
165
|
-
<Text style={styles.permissionText}>Camera not available</Text>
|
|
179
|
+
<Text style={styles.permissionText}>{strings.errors.cameraNotAvailable || 'Camera not available'}</Text>
|
|
166
180
|
<TouchableOpacity
|
|
167
181
|
style={[styles.button, styles.cancelButton]}
|
|
168
182
|
onPress={onCancel}
|
|
169
183
|
>
|
|
170
|
-
<Text style={styles.buttonText}>Cancel</Text>
|
|
184
|
+
<Text style={styles.buttonText}>{strings.common.cancel || 'Cancel'}</Text>
|
|
171
185
|
</TouchableOpacity>
|
|
172
186
|
</View>
|
|
173
187
|
</View>
|
|
@@ -203,9 +217,8 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
203
217
|
<View style={styles.instructionsContainer}>
|
|
204
218
|
<Text style={styles.instructionsText}>{instructions}</Text>
|
|
205
219
|
<Text style={styles.tipsText}>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
⢠Keep document flat and complete
|
|
220
|
+
{mode === 'front' ? strings.capture.frontId.tips : strings.capture.backId.tips ||
|
|
221
|
+
'⢠Ensure good lighting\n⢠Avoid glare and shadows\n⢠Keep document flat and complete'}
|
|
209
222
|
</Text>
|
|
210
223
|
</View>
|
|
211
224
|
|
|
@@ -215,7 +228,7 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
215
228
|
style={[styles.button, styles.cancelButton]}
|
|
216
229
|
onPress={onCancel}
|
|
217
230
|
>
|
|
218
|
-
<Text style={styles.buttonText}>Cancel</Text>
|
|
231
|
+
<Text style={styles.buttonText}>{strings.common.cancel || 'Cancel'}</Text>
|
|
219
232
|
</TouchableOpacity>
|
|
220
233
|
|
|
221
234
|
<TouchableOpacity
|