@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.
@@ -18,6 +18,7 @@ export interface InstructionsScreenProps {
18
18
  theme?: ThemeConfig;
19
19
  language?: SupportedLanguage;
20
20
  onStart: () => void;
21
+ onCancel?: () => void;
21
22
  styles?: {
22
23
  container?: ViewStyle;
23
24
  content?: ViewStyle;
@@ -28,6 +29,7 @@ export const InstructionsScreen: React.FC<InstructionsScreenProps> = ({
28
29
  theme,
29
30
  language = 'en',
30
31
  onStart,
32
+ onCancel,
31
33
  styles: customStyles,
32
34
  }) => {
33
35
  const [strings, setStrings] = useState(() => {
@@ -102,12 +104,27 @@ export const InstructionsScreen: React.FC<InstructionsScreenProps> = ({
102
104
  </View>
103
105
  </ScrollView>
104
106
 
105
- {/* Start Button */}
107
+ {/* Footer Buttons */}
106
108
  <View style={styles.footer}>
109
+ {onCancel && (
110
+ <TouchableOpacity
111
+ style={[
112
+ styles.button,
113
+ styles.cancelButton,
114
+ { borderColor: theme?.errorColor || '#EF4444' },
115
+ ]}
116
+ onPress={onCancel}
117
+ >
118
+ <Text style={[styles.buttonText, { color: theme?.errorColor || '#EF4444' }]}>
119
+ {strings.common.cancel || 'Cancel'}
120
+ </Text>
121
+ </TouchableOpacity>
122
+ )}
107
123
  <TouchableOpacity
108
124
  style={[
109
125
  styles.button,
110
126
  { backgroundColor: theme?.primaryColor || '#6366F1' },
127
+ onCancel && styles.startButton,
111
128
  ]}
112
129
  onPress={onStart}
113
130
  >
@@ -235,12 +252,22 @@ const styles = StyleSheet.create({
235
252
  padding: 24,
236
253
  borderTopWidth: 1,
237
254
  borderTopColor: '#E5E7EB',
255
+ flexDirection: 'row',
256
+ gap: 12,
238
257
  },
239
258
  button: {
240
259
  paddingVertical: 16,
241
260
  paddingHorizontal: 32,
242
261
  borderRadius: 8,
243
262
  alignItems: 'center',
263
+ flex: 1,
264
+ },
265
+ cancelButton: {
266
+ borderWidth: 2,
267
+ backgroundColor: 'transparent',
268
+ },
269
+ startButton: {
270
+ flex: 1,
244
271
  },
245
272
  buttonText: {
246
273
  color: '#FFFFFF',
@@ -14,9 +14,9 @@ import {
14
14
  Platform,
15
15
  Alert,
16
16
  } from 'react-native';
17
- import { Camera, useCameraDevice, useCameraPermission, useFrameProcessor } from 'react-native-vision-camera';
17
+ import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
18
18
  import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
19
- import { ThemeConfig, LivenessInstruction } from '@hexar/biometric-identity-sdk-core';
19
+ import { ThemeConfig, LivenessInstruction, SupportedLanguage, getStrings, setLanguage } from '@hexar/biometric-identity-sdk-core';
20
20
 
21
21
  // Challenge action configuration (matches backend response)
22
22
  export interface ChallengeAction {
@@ -29,6 +29,7 @@ export interface ChallengeAction {
29
29
 
30
30
  export interface VideoRecorderProps {
31
31
  theme?: ThemeConfig;
32
+ language?: SupportedLanguage;
32
33
  /** Total recording duration in ms (default: 8000ms for smart mode) */
33
34
  duration?: number;
34
35
  /** Instructions for user to follow */
@@ -88,22 +89,22 @@ const DEFAULT_CHALLENGES: ChallengeAction[] = [
88
89
  },
89
90
  ];
90
91
 
91
- // Instruction text mapping
92
- const INSTRUCTION_MAP: Record<string, { text: string; icon: string }> = {
93
- look_left: { text: 'Slowly turn your head LEFT', icon: '' },
94
- look_right: { text: 'Slowly turn your head RIGHT', icon: '' },
95
- look_up: { text: 'Look UP', icon: '' },
96
- look_down: { text: 'Look DOWN', icon: '' },
97
- turn_head_left: { text: 'Turn your head LEFT', icon: '' },
98
- turn_head_right: { text: 'Turn your head RIGHT', icon: '' },
99
- smile: { text: 'Smile 😊', icon: '😊' },
100
- blink: { text: 'Blink your eyes naturally', icon: '👁' },
101
- open_mouth: { text: 'Open your mouth slightly', icon: '😮' },
102
- stay_still: { text: 'Look at the camera and stay still', icon: '📷' },
103
- };
92
+ const getInstructionMap = (strings: any): Record<string, { text: string; icon: string }> => ({
93
+ look_left: { text: strings.liveness.instructions.lookLeft || 'Slowly turn your head LEFT', icon: '←' },
94
+ look_right: { text: strings.liveness.instructions.lookRight || 'Slowly turn your head RIGHT', icon: '' },
95
+ look_up: { text: strings.liveness.instructions.lookUp || 'Look UP', icon: '' },
96
+ look_down: { text: strings.liveness.instructions.lookDown || 'Look DOWN', icon: '' },
97
+ turn_head_left: { text: strings.liveness.instructions.turnHeadLeft || 'Turn your head LEFT', icon: '' },
98
+ turn_head_right: { text: strings.liveness.instructions.turnHeadRight || 'Turn your head RIGHT', icon: '' },
99
+ smile: { text: strings.liveness.instructions.smile || 'Smile 😊', icon: '😊' },
100
+ blink: { text: strings.liveness.instructions.blink || 'Blink your eyes naturally', icon: '👁' },
101
+ open_mouth: { text: strings.liveness.instructions.openMouth || 'Open your mouth slightly', icon: '😮' },
102
+ stay_still: { text: strings.liveness.instructions.stayStill || 'Look at the camera and stay still', icon: '📷' },
103
+ });
104
104
 
105
105
  export const VideoRecorder: React.FC<VideoRecorderProps> = ({
106
106
  theme,
107
+ language,
107
108
  duration,
108
109
  instructions,
109
110
  challenges: propChallenges,
@@ -113,6 +114,11 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
113
114
  onCancel,
114
115
  onFetchChallenges,
115
116
  }) => {
117
+ if (language) {
118
+ setLanguage(language);
119
+ }
120
+ const strings = getStrings();
121
+
116
122
  // State
117
123
  const [phase, setPhase] = useState<'loading' | 'countdown' | 'recording' | 'processing'>('loading');
118
124
  const [countdown, setCountdown] = useState(3);
@@ -136,13 +142,16 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
136
142
  const arrowAnim = useRef(new Animated.Value(0)).current;
137
143
  const progressAnim = useRef(new Animated.Value(0)).current;
138
144
 
139
- // Refs
140
145
  const recordingStartTime = useRef<number>(0);
141
146
  const frameInterval = useRef<NodeJS.Timeout | null>(null);
142
147
  const frameCaptureInterval = useRef<NodeJS.Timeout | null>(null);
143
-
144
- // Calculate total duration from challenges
145
- const totalDuration = duration || challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000;
148
+ const videoRecordingRef = useRef<any>(null);
149
+ const isRecordingRef = useRef<boolean>(false);
150
+ const minDurationMs = 8000;
151
+ const totalDuration = duration || Math.max(
152
+ minDurationMs,
153
+ challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000
154
+ );
146
155
 
147
156
  // Check camera permissions
148
157
  useEffect(() => {
@@ -194,13 +203,13 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
194
203
  // Fetch from backend
195
204
  challengeList = await onFetchChallenges();
196
205
  } else if (instructions && instructions.length > 0) {
197
- // Convert instructions to challenges
206
+ const instructionMap = getInstructionMap(strings);
198
207
  challengeList = instructions.map((inst, idx) => ({
199
208
  action: inst,
200
- instruction: INSTRUCTION_MAP[inst]?.text || inst,
209
+ instruction: instructionMap[inst]?.text || inst,
201
210
  duration_ms: 2000,
202
211
  order: idx + 1,
203
- icon: INSTRUCTION_MAP[inst]?.icon,
212
+ icon: instructionMap[inst]?.icon,
204
213
  }));
205
214
  } else {
206
215
  // Use default challenges
@@ -298,53 +307,149 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
298
307
  ).start();
299
308
  }, [arrowAnim]);
300
309
 
301
- // Start recording
302
- const startRecording = useCallback(async () => {
303
- setPhase('recording');
304
- recordingStartTime.current = Date.now();
310
+ const resetAndRetry = useCallback(() => {
311
+ setFrames([]);
312
+ setCompletedChallenges([]);
313
+ setCurrentChallengeIndex(0);
314
+ setChallengeProgress(0);
315
+ setOverallProgress(0);
316
+ recordingStartTime.current = 0;
317
+ setPhase('countdown');
318
+ setCountdown(3);
319
+ }, []);
320
+
321
+ const handleRecordingError = useCallback((error: any) => {
322
+ setPhase('loading');
323
+ Alert.alert(
324
+ 'Recording Error',
325
+ 'Failed to record video. Please try again.',
326
+ [{ text: 'OK', onPress: onCancel }]
327
+ );
328
+ }, [onCancel]);
329
+
330
+ const handleVideoComplete = useCallback(async (video: any) => {
331
+ console.log('handleVideoComplete called with video:', video?.path);
305
332
 
306
- // Start capturing frames from camera
333
+ try {
334
+ setPhase('processing');
335
+ const actualDuration = Date.now() - recordingStartTime.current;
336
+
337
+ console.log('Video duration:', actualDuration, 'min required:', minDurationMs);
338
+
339
+ if (actualDuration < minDurationMs) {
340
+ setPhase('recording');
341
+ Alert.alert(
342
+ strings.errors.videoTooShort?.title || 'Recording Too Short',
343
+ strings.errors.videoTooShort?.message || `Video must be at least ${minDurationMs / 1000} seconds. Please try again.`,
344
+ [{ text: strings.common.retry || 'OK', onPress: resetAndRetry }]
345
+ );
346
+ return;
347
+ }
348
+
349
+ let videoBase64 = '';
350
+ if (video?.path) {
351
+ try {
352
+ const RNFS = require('react-native-fs');
353
+ videoBase64 = await RNFS.readFile(video.path, 'base64');
354
+ console.log('Video file read successfully, size:', videoBase64.length);
355
+ } catch (fsError) {
356
+ console.warn('Could not read video file, using captured frames:', fsError);
357
+ }
358
+ }
359
+
360
+ const result: VideoRecordingResult = {
361
+ frames: frames.length > 0 ? frames : (videoBase64 ? [videoBase64] : []),
362
+ duration: actualDuration,
363
+ instructionsFollowed: completedChallenges.length === challenges.length,
364
+ qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
365
+ challengesCompleted: completedChallenges,
366
+ sessionId,
367
+ };
368
+
369
+ console.log('Video recording completed successfully:', {
370
+ duration: actualDuration,
371
+ frames: result.frames.length,
372
+ challengesCompleted: completedChallenges.length,
373
+ instructionsFollowed: result.instructionsFollowed
374
+ });
375
+
376
+ onComplete(result);
377
+ } catch (error) {
378
+ console.error('Error processing video:', error);
379
+ setPhase('recording');
380
+ handleRecordingError(error);
381
+ }
382
+ }, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs]);
383
+
384
+ const startFrameCapture = useCallback(() => {
307
385
  if (cameraRef.current && device) {
308
- // Capture frames periodically (every 100ms = ~10 FPS)
309
386
  frameCaptureInterval.current = setInterval(async () => {
310
387
  try {
311
- // Take a photo frame (react-native-vision-camera doesn't have direct frame access in v4)
312
- // We'll use takeSnapshot or capture frames via photo
313
388
  const photo = await cameraRef.current?.takePhoto({
314
389
  flash: 'off',
315
390
  });
316
391
 
317
392
  if (photo) {
318
- // Convert to base64 if possible, otherwise use file path
319
393
  try {
320
394
  const RNFS = require('react-native-fs');
321
395
  const base64 = await RNFS.readFile(photo.path, 'base64');
322
- setFrames(prev => [...prev, base64]);
396
+ setFrames(prev => {
397
+ if (prev.length < 100) {
398
+ return [...prev, base64];
399
+ }
400
+ return prev;
401
+ });
323
402
  } catch (fsError) {
324
- // Fallback: use file path
325
- setFrames(prev => [...prev, photo.path]);
403
+ setFrames(prev => {
404
+ if (prev.length < 100) {
405
+ return [...prev, photo.path];
406
+ }
407
+ return prev;
408
+ });
326
409
  }
327
410
  }
328
411
  } catch (error) {
329
412
  console.warn('Frame capture error:', error);
330
- // Continue even if one frame fails
331
413
  }
332
414
  }, 100);
333
- } else {
334
- // Fallback: simulate frames if camera not available
335
- frameInterval.current = setInterval(() => {
336
- setFrames(prev => [...prev, `frame_${Date.now()}`]);
337
- }, 100);
338
415
  }
339
-
340
- // Start first challenge
341
- runChallenge(0);
342
416
  }, [device]);
343
417
 
344
- // Run a specific challenge
418
+ const stopRecording = useCallback(async () => {
419
+ console.log('Stopping recording...');
420
+ isRecordingRef.current = false;
421
+
422
+ if (videoRecordingRef.current) {
423
+ try {
424
+ console.log('Stopping video recording');
425
+ await videoRecordingRef.current.stop();
426
+ console.log('Video recording stopped');
427
+ } catch (error) {
428
+ console.error('Error stopping video recording:', error);
429
+ }
430
+ videoRecordingRef.current = null;
431
+ }
432
+
433
+ if (frameCaptureInterval.current) {
434
+ clearInterval(frameCaptureInterval.current);
435
+ frameCaptureInterval.current = null;
436
+ }
437
+ }, []);
438
+
345
439
  const runChallenge = useCallback((index: number) => {
346
440
  if (index >= challenges.length) {
347
- completeRecording();
441
+ if (isRecordingRef.current) {
442
+ const elapsed = Date.now() - recordingStartTime.current;
443
+ if (elapsed < minDurationMs) {
444
+ setTimeout(() => {
445
+ if (isRecordingRef.current) {
446
+ stopRecording();
447
+ }
448
+ }, minDurationMs - elapsed);
449
+ return;
450
+ }
451
+ stopRecording();
452
+ }
348
453
  return;
349
454
  }
350
455
 
@@ -352,84 +457,94 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
352
457
  setCurrentChallengeIndex(index);
353
458
  setChallengeProgress(0);
354
459
 
355
- // Fade in instruction
356
460
  Animated.timing(fadeAnim, {
357
461
  toValue: 1,
358
462
  duration: 300,
359
463
  useNativeDriver: true,
360
464
  }).start();
361
465
 
362
- // Animate arrow for directional challenges
363
466
  if (challenge.action.includes('left') || challenge.action.includes('right')) {
364
467
  animateArrow(challenge.action);
365
468
  }
366
469
 
367
- // Progress animation for this challenge
368
470
  Animated.timing(progressAnim, {
369
471
  toValue: 100,
370
472
  duration: challenge.duration_ms,
371
473
  useNativeDriver: false,
372
474
  }).start();
373
475
 
374
- // Update progress in real-time
375
476
  const progressInterval = setInterval(() => {
376
477
  setChallengeProgress(prev => {
377
478
  const newProgress = prev + (100 / (challenge.duration_ms / 100));
378
479
  return Math.min(100, newProgress);
379
480
  });
380
481
 
381
- // Update overall progress
382
482
  const elapsed = Date.now() - recordingStartTime.current;
383
- const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
483
+ const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
384
484
  setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
385
485
  }, 100);
386
486
 
387
- // Move to next challenge after duration
388
487
  setTimeout(() => {
389
488
  clearInterval(progressInterval);
390
489
 
391
- // Mark challenge as completed
392
490
  setCompletedChallenges(prev => [...prev, challenge.action]);
393
491
 
394
- // Fade out current instruction
395
492
  Animated.timing(fadeAnim, {
396
493
  toValue: 0,
397
494
  duration: 200,
398
495
  useNativeDriver: true,
399
496
  }).start(() => {
400
- // Reset and start next challenge
401
497
  progressAnim.setValue(0);
402
498
  runChallenge(index + 1);
403
499
  });
404
500
  }, challenge.duration_ms);
405
- }, [challenges, fadeAnim, progressAnim, animateArrow]);
501
+ }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
406
502
 
407
- // Complete recording
408
- const completeRecording = useCallback(() => {
409
- if (frameInterval.current) {
410
- clearInterval(frameInterval.current);
411
- }
412
- if (frameCaptureInterval.current) {
413
- clearInterval(frameCaptureInterval.current);
503
+ const startRecording = useCallback(async () => {
504
+ setPhase('recording');
505
+ recordingStartTime.current = Date.now();
506
+ isRecordingRef.current = true;
507
+
508
+ console.log('Starting video recording, total duration:', totalDuration);
509
+
510
+ if (cameraRef.current && device) {
511
+ try {
512
+ videoRecordingRef.current = await cameraRef.current.startRecording({
513
+ flash: 'off',
514
+ onRecordingFinished: (video: any) => {
515
+ console.log('Video recording finished callback called', video);
516
+ if (isRecordingRef.current) {
517
+ handleVideoComplete(video);
518
+ }
519
+ },
520
+ onRecordingError: (error: any) => {
521
+ console.error('Recording error:', error);
522
+ handleRecordingError(error);
523
+ },
524
+ });
525
+ console.log('Video recording started successfully');
526
+ } catch (error) {
527
+ console.warn('Video recording not available, falling back to frame capture:', error);
528
+ startFrameCapture();
529
+ }
530
+ } else {
531
+ console.log('Camera not available, using frame capture');
532
+ startFrameCapture();
414
533
  }
415
534
 
416
- setPhase('processing');
417
- setOverallProgress(100);
418
-
419
- // Process and return result
420
- setTimeout(() => {
421
- const result: VideoRecordingResult = {
422
- frames,
423
- duration: Date.now() - recordingStartTime.current,
424
- instructionsFollowed: completedChallenges.length === challenges.length,
425
- qualityScore: frames.length > 0 ? 85 + Math.random() * 10 : 0, // Quality based on frames captured
426
- challengesCompleted: completedChallenges,
427
- sessionId,
428
- };
429
-
430
- onComplete(result);
431
- }, 500);
432
- }, [frames, completedChallenges, challenges, sessionId, onComplete]);
535
+ runChallenge(0);
536
+
537
+ const timeoutId = setTimeout(() => {
538
+ console.log('Recording timeout reached, stopping recording');
539
+ if (isRecordingRef.current) {
540
+ stopRecording().catch(err => {
541
+ console.error('Error stopping recording on timeout:', err);
542
+ });
543
+ }
544
+ }, totalDuration);
545
+
546
+ return () => clearTimeout(timeoutId);
547
+ }, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
433
548
 
434
549
  // Current challenge
435
550
  const currentChallenge = challenges[currentChallengeIndex];
@@ -491,13 +606,17 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
491
606
  return (
492
607
  <View style={styles.container}>
493
608
  <View style={styles.permissionContainer}>
494
- <Text style={styles.permissionText}>Camera permission is required</Text>
609
+ <Text style={styles.permissionText}>
610
+ {typeof strings.errors.cameraPermissionDenied === 'string'
611
+ ? strings.errors.cameraPermissionDenied
612
+ : strings.errors.cameraPermissionDenied?.message || 'Camera permission is required'}
613
+ </Text>
495
614
  <TouchableOpacity
496
615
  style={[styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }]}
497
616
  onPress={onCancel}
498
617
  >
499
618
  <Text style={[styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }]}>
500
- Cancel
619
+ {strings.common.cancel || 'Cancel'}
501
620
  </Text>
502
621
  </TouchableOpacity>
503
622
  </View>
@@ -509,13 +628,15 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
509
628
  return (
510
629
  <View style={styles.container}>
511
630
  <View style={styles.permissionContainer}>
512
- <Text style={styles.permissionText}>Camera not available</Text>
631
+ <Text style={styles.permissionText}>
632
+ {strings.errors.cameraNotAvailable || 'Camera not available'}
633
+ </Text>
513
634
  <TouchableOpacity
514
635
  style={[styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }]}
515
636
  onPress={onCancel}
516
637
  >
517
638
  <Text style={[styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }]}>
518
- Cancel
639
+ {strings.common.cancel || 'Cancel'}
519
640
  </Text>
520
641
  </TouchableOpacity>
521
642
  </View>
@@ -532,8 +653,8 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
532
653
  style={StyleSheet.absoluteFill}
533
654
  device={device}
534
655
  isActive={phase === 'recording' || phase === 'countdown'}
535
- video={false}
536
- photo={true}
656
+ video={true}
657
+ audio={false}
537
658
  />
538
659
 
539
660
  {/* Face Oval Overlay */}
@@ -555,14 +676,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
555
676
  {/* Loading Phase */}
556
677
  {phase === 'loading' && (
557
678
  <View style={styles.centeredOverlay}>
558
- <Text style={styles.loadingText}>Preparing challenges...</Text>
679
+ <Text style={styles.loadingText}>{strings.liveness.preparing || 'Preparing challenges...'}</Text>
559
680
  </View>
560
681
  )}
561
682
 
562
683
  {/* Countdown */}
563
684
  {phase === 'countdown' && (
564
685
  <View style={styles.countdownContainer}>
565
- <Text style={styles.getReadyText}>Get Ready!</Text>
686
+ <Text style={styles.getReadyText}>{strings.liveness.getReady || 'Get Ready!'}</Text>
566
687
  <Animated.Text
567
688
  style={[
568
689
  styles.countdownText,
@@ -583,7 +704,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
583
704
  { transform: [{ scale: pulseAnim }] }
584
705
  ]}
585
706
  />
586
- <Text style={styles.recordingText}>Recording</Text>
707
+ <Text style={styles.recordingText}>{strings.liveness.recording || 'Recording'}</Text>
587
708
  </View>
588
709
  )}
589
710
 
@@ -651,7 +772,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
651
772
  {/* Processing Overlay */}
652
773
  {phase === 'processing' && (
653
774
  <View style={styles.processingOverlay}>
654
- <Text style={styles.processingText}>Processing video...</Text>
775
+ <Text style={styles.processingText}>{strings.liveness.processing || 'Processing video...'}</Text>
655
776
  </View>
656
777
  )}
657
778
  </View>
@@ -661,15 +782,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
661
782
  {phase === 'countdown' && (
662
783
  <>
663
784
  <Text style={styles.bottomText}>
664
- You'll perform {challenges.length} action{challenges.length > 1 ? 's' : ''}.{'\n'}
665
- Follow the on-screen instructions.
785
+ {strings.liveness.countdownMessage || `You'll perform ${challenges.length} action${challenges.length > 1 ? 's' : ''}.\nFollow the on-screen instructions.`}
666
786
  </Text>
667
787
  <TouchableOpacity
668
788
  style={[styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }]}
669
789
  onPress={onCancel}
670
790
  >
671
791
  <Text style={[styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }]}>
672
- Cancel
792
+ {strings.common.cancel || 'Cancel'}
673
793
  </Text>
674
794
  </TouchableOpacity>
675
795
  </>
@@ -677,13 +797,13 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
677
797
 
678
798
  {phase === 'recording' && (
679
799
  <Text style={styles.bottomText}>
680
- Keep your face visible and follow the instructions
800
+ {strings.liveness.recordingInstructions || 'Keep your face visible and follow the instructions'}
681
801
  </Text>
682
802
  )}
683
803
 
684
804
  {phase === 'processing' && (
685
805
  <Text style={styles.bottomText}>
686
- Almost done...
806
+ {strings.validation.almostDone || 'Almost done...'}
687
807
  </Text>
688
808
  )}
689
809
  </View>