@hexar/biometric-identity-sdk-react-native 1.0.5 → 1.0.7
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 +5 -0
- package/dist/components/VideoRecorder.d.ts.map +1 -1
- package/dist/components/VideoRecorder.js +115 -58
- package/package.json +3 -4
- package/src/components/BiometricIdentityFlow.tsx +5 -0
- package/src/components/VideoRecorder.tsx +136 -64
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BiometricIdentityFlow.d.ts","sourceRoot":"","sources":["../../src/components/BiometricIdentityFlow.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAA2C,MAAM,OAAO,CAAC;AAChE,OAAO,EAOL,SAAS,EACV,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,cAAc,EAId,iBAAiB,EAClB,MAAM,oCAAoC,CAAC;AAU5C,MAAM,WAAW,0BAA0B;IACzC,oBAAoB,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;IACzD,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,OAAO,CAAC,EAAE,SAAS,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,
|
|
1
|
+
{"version":3,"file":"BiometricIdentityFlow.d.ts","sourceRoot":"","sources":["../../src/components/BiometricIdentityFlow.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAA2C,MAAM,OAAO,CAAC;AAChE,OAAO,EAOL,SAAS,EACV,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,cAAc,EAId,iBAAiB,EAClB,MAAM,oCAAoC,CAAC;AAU5C,MAAM,WAAW,0BAA0B;IACzC,oBAAoB,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;IACzD,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,OAAO,CAAC,EAAE,SAAS,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CA6StE,CAAC;AAiOF,eAAe,qBAAqB,CAAC"}
|
|
@@ -56,11 +56,16 @@ const BiometricIdentityFlow = ({ onValidationComplete, onError, theme, language,
|
|
|
56
56
|
const [currentChallenges, setCurrentChallenges] = (0, react_1.useState)([]);
|
|
57
57
|
const [isLoadingChallenges, setIsLoadingChallenges] = (0, react_1.useState)(false);
|
|
58
58
|
// Set language early, before any components render
|
|
59
|
+
// Priority: language prop > SDK config language > default 'en'
|
|
59
60
|
// Run on mount and whenever language prop changes
|
|
60
61
|
(0, react_1.useEffect)(() => {
|
|
61
62
|
if (language) {
|
|
63
|
+
// If language prop is provided, override the config
|
|
62
64
|
(0, biometric_identity_sdk_core_1.setLanguage)(language);
|
|
63
65
|
}
|
|
66
|
+
// If no language prop, the language should already be set by BiometricIdentitySDK.configure()
|
|
67
|
+
// The global language state is set when configure() is called, so getStrings() will
|
|
68
|
+
// automatically return strings for the configured language (or 'en' as default)
|
|
64
69
|
}, [language]);
|
|
65
70
|
const strings = (0, biometric_identity_sdk_core_1.getStrings)();
|
|
66
71
|
const styles = createStyles(theme);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAaxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAGtF,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,YAAY,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,EAAE,CAAC,SAAS,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACtD,iCAAiC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgDD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,
|
|
1
|
+
{"version":3,"file":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAaxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAGtF,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,YAAY,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,EAAE,CAAC,SAAS,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACtD,iCAAiC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgDD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAmpBtD,CAAC;AA4OF,eAAe,aAAa,CAAC"}
|
|
@@ -107,12 +107,13 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
107
107
|
const pulseAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
108
108
|
const arrowAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
109
109
|
const progressAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
110
|
-
// Refs
|
|
111
110
|
const recordingStartTime = (0, react_1.useRef)(0);
|
|
112
111
|
const frameInterval = (0, react_1.useRef)(null);
|
|
113
112
|
const frameCaptureInterval = (0, react_1.useRef)(null);
|
|
114
|
-
|
|
115
|
-
const
|
|
113
|
+
const videoRecordingRef = (0, react_1.useRef)(null);
|
|
114
|
+
const isRecordingRef = (0, react_1.useRef)(false);
|
|
115
|
+
const minDurationMs = 8000;
|
|
116
|
+
const totalDuration = duration || Math.max(minDurationMs, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
116
117
|
// Check camera permissions
|
|
117
118
|
(0, react_1.useEffect)(() => {
|
|
118
119
|
const checkPermissions = async () => {
|
|
@@ -254,124 +255,180 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
254
255
|
}),
|
|
255
256
|
])).start();
|
|
256
257
|
}, [arrowAnim]);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
258
|
+
const resetAndRetry = (0, react_1.useCallback)(() => {
|
|
259
|
+
setFrames([]);
|
|
260
|
+
setCompletedChallenges([]);
|
|
261
|
+
setCurrentChallengeIndex(0);
|
|
262
|
+
setChallengeProgress(0);
|
|
263
|
+
setOverallProgress(0);
|
|
264
|
+
recordingStartTime.current = 0;
|
|
265
|
+
setPhase('countdown');
|
|
266
|
+
setCountdown(3);
|
|
267
|
+
}, []);
|
|
268
|
+
const handleRecordingError = (0, react_1.useCallback)((error) => {
|
|
269
|
+
setPhase('loading');
|
|
270
|
+
react_native_1.Alert.alert('Recording Error', 'Failed to record video. Please try again.', [{ text: 'OK', onPress: onCancel }]);
|
|
271
|
+
}, [onCancel]);
|
|
272
|
+
const handleVideoComplete = (0, react_1.useCallback)(async (video) => {
|
|
273
|
+
const actualDuration = Date.now() - recordingStartTime.current;
|
|
274
|
+
if (actualDuration < minDurationMs) {
|
|
275
|
+
react_native_1.Alert.alert('Recording Too Short', `Video must be at least ${minDurationMs / 1000} seconds. Please try again.`, [{ text: 'OK', onPress: resetAndRetry }]);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const RNFS = require('react-native-fs');
|
|
280
|
+
const videoBase64 = await RNFS.readFile(video.path, 'base64');
|
|
281
|
+
const result = {
|
|
282
|
+
frames: frames.length > 0 ? frames : [videoBase64],
|
|
283
|
+
duration: actualDuration,
|
|
284
|
+
instructionsFollowed: completedChallenges.length === challenges.length,
|
|
285
|
+
qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
|
|
286
|
+
challengesCompleted: completedChallenges,
|
|
287
|
+
sessionId,
|
|
288
|
+
};
|
|
289
|
+
onComplete(result);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
console.error('Error processing video:', error);
|
|
293
|
+
handleRecordingError(error);
|
|
294
|
+
}
|
|
295
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError]);
|
|
296
|
+
const startFrameCapture = (0, react_1.useCallback)(() => {
|
|
262
297
|
if (cameraRef.current && device) {
|
|
263
|
-
// Capture frames periodically (every 100ms = ~10 FPS)
|
|
264
298
|
frameCaptureInterval.current = setInterval(async () => {
|
|
265
299
|
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
300
|
const photo = await cameraRef.current?.takePhoto({
|
|
269
301
|
flash: 'off',
|
|
270
302
|
});
|
|
271
303
|
if (photo) {
|
|
272
|
-
// Convert to base64 if possible, otherwise use file path
|
|
273
304
|
try {
|
|
274
305
|
const RNFS = require('react-native-fs');
|
|
275
306
|
const base64 = await RNFS.readFile(photo.path, 'base64');
|
|
276
|
-
setFrames(prev =>
|
|
307
|
+
setFrames(prev => {
|
|
308
|
+
if (prev.length < 100) {
|
|
309
|
+
return [...prev, base64];
|
|
310
|
+
}
|
|
311
|
+
return prev;
|
|
312
|
+
});
|
|
277
313
|
}
|
|
278
314
|
catch (fsError) {
|
|
279
|
-
|
|
280
|
-
|
|
315
|
+
setFrames(prev => {
|
|
316
|
+
if (prev.length < 100) {
|
|
317
|
+
return [...prev, photo.path];
|
|
318
|
+
}
|
|
319
|
+
return prev;
|
|
320
|
+
});
|
|
281
321
|
}
|
|
282
322
|
}
|
|
283
323
|
}
|
|
284
324
|
catch (error) {
|
|
285
325
|
console.warn('Frame capture error:', error);
|
|
286
|
-
// Continue even if one frame fails
|
|
287
326
|
}
|
|
288
327
|
}, 100);
|
|
289
328
|
}
|
|
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
329
|
}, [device]);
|
|
299
|
-
|
|
330
|
+
const stopRecording = (0, react_1.useCallback)(async () => {
|
|
331
|
+
if (videoRecordingRef.current) {
|
|
332
|
+
try {
|
|
333
|
+
await videoRecordingRef.current.stop();
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
console.error('Error stopping video recording:', error);
|
|
337
|
+
}
|
|
338
|
+
videoRecordingRef.current = null;
|
|
339
|
+
}
|
|
340
|
+
if (frameCaptureInterval.current) {
|
|
341
|
+
clearInterval(frameCaptureInterval.current);
|
|
342
|
+
frameCaptureInterval.current = null;
|
|
343
|
+
}
|
|
344
|
+
isRecordingRef.current = false;
|
|
345
|
+
}, []);
|
|
300
346
|
const runChallenge = (0, react_1.useCallback)((index) => {
|
|
301
347
|
if (index >= challenges.length) {
|
|
302
|
-
|
|
348
|
+
if (isRecordingRef.current) {
|
|
349
|
+
const elapsed = Date.now() - recordingStartTime.current;
|
|
350
|
+
if (elapsed < minDurationMs) {
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
if (isRecordingRef.current) {
|
|
353
|
+
stopRecording();
|
|
354
|
+
}
|
|
355
|
+
}, minDurationMs - elapsed);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
stopRecording();
|
|
359
|
+
}
|
|
303
360
|
return;
|
|
304
361
|
}
|
|
305
362
|
const challenge = challenges[index];
|
|
306
363
|
setCurrentChallengeIndex(index);
|
|
307
364
|
setChallengeProgress(0);
|
|
308
|
-
// Fade in instruction
|
|
309
365
|
react_native_1.Animated.timing(fadeAnim, {
|
|
310
366
|
toValue: 1,
|
|
311
367
|
duration: 300,
|
|
312
368
|
useNativeDriver: true,
|
|
313
369
|
}).start();
|
|
314
|
-
// Animate arrow for directional challenges
|
|
315
370
|
if (challenge.action.includes('left') || challenge.action.includes('right')) {
|
|
316
371
|
animateArrow(challenge.action);
|
|
317
372
|
}
|
|
318
|
-
// Progress animation for this challenge
|
|
319
373
|
react_native_1.Animated.timing(progressAnim, {
|
|
320
374
|
toValue: 100,
|
|
321
375
|
duration: challenge.duration_ms,
|
|
322
376
|
useNativeDriver: false,
|
|
323
377
|
}).start();
|
|
324
|
-
// Update progress in real-time
|
|
325
378
|
const progressInterval = setInterval(() => {
|
|
326
379
|
setChallengeProgress(prev => {
|
|
327
380
|
const newProgress = prev + (100 / (challenge.duration_ms / 100));
|
|
328
381
|
return Math.min(100, newProgress);
|
|
329
382
|
});
|
|
330
|
-
// Update overall progress
|
|
331
383
|
const elapsed = Date.now() - recordingStartTime.current;
|
|
332
|
-
const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
|
|
384
|
+
const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
333
385
|
setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
|
|
334
386
|
}, 100);
|
|
335
|
-
// Move to next challenge after duration
|
|
336
387
|
setTimeout(() => {
|
|
337
388
|
clearInterval(progressInterval);
|
|
338
|
-
// Mark challenge as completed
|
|
339
389
|
setCompletedChallenges(prev => [...prev, challenge.action]);
|
|
340
|
-
// Fade out current instruction
|
|
341
390
|
react_native_1.Animated.timing(fadeAnim, {
|
|
342
391
|
toValue: 0,
|
|
343
392
|
duration: 200,
|
|
344
393
|
useNativeDriver: true,
|
|
345
394
|
}).start(() => {
|
|
346
|
-
// Reset and start next challenge
|
|
347
395
|
progressAnim.setValue(0);
|
|
348
396
|
runChallenge(index + 1);
|
|
349
397
|
});
|
|
350
398
|
}, challenge.duration_ms);
|
|
351
|
-
}, [challenges, fadeAnim, progressAnim, animateArrow]);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
399
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
|
|
400
|
+
const startRecording = (0, react_1.useCallback)(async () => {
|
|
401
|
+
setPhase('recording');
|
|
402
|
+
recordingStartTime.current = Date.now();
|
|
403
|
+
isRecordingRef.current = true;
|
|
404
|
+
if (cameraRef.current && device) {
|
|
405
|
+
try {
|
|
406
|
+
videoRecordingRef.current = await cameraRef.current.startRecording({
|
|
407
|
+
flash: 'off',
|
|
408
|
+
onRecordingFinished: (video) => {
|
|
409
|
+
handleVideoComplete(video);
|
|
410
|
+
},
|
|
411
|
+
onRecordingError: (error) => {
|
|
412
|
+
console.error('Recording error:', error);
|
|
413
|
+
handleRecordingError(error);
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
console.warn('Video recording not available, falling back to frame capture:', error);
|
|
419
|
+
startFrameCapture();
|
|
420
|
+
}
|
|
356
421
|
}
|
|
357
|
-
|
|
358
|
-
|
|
422
|
+
else {
|
|
423
|
+
startFrameCapture();
|
|
359
424
|
}
|
|
360
|
-
|
|
361
|
-
setOverallProgress(100);
|
|
362
|
-
// Process and return result
|
|
425
|
+
runChallenge(0);
|
|
363
426
|
setTimeout(() => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
challengesCompleted: completedChallenges,
|
|
370
|
-
sessionId,
|
|
371
|
-
};
|
|
372
|
-
onComplete(result);
|
|
373
|
-
}, 500);
|
|
374
|
-
}, [frames, completedChallenges, challenges, sessionId, onComplete]);
|
|
427
|
+
if (isRecordingRef.current) {
|
|
428
|
+
stopRecording();
|
|
429
|
+
}
|
|
430
|
+
}, totalDuration);
|
|
431
|
+
}, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
|
|
375
432
|
// Current challenge
|
|
376
433
|
const currentChallenge = challenges[currentChallengeIndex];
|
|
377
434
|
// Get direction arrow for the current challenge
|
|
@@ -421,7 +478,7 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
|
|
|
421
478
|
}
|
|
422
479
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
423
480
|
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:
|
|
481
|
+
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
482
|
react_1.default.createElement(react_native_1.View, { style: styles.overlay },
|
|
426
483
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
427
484
|
styles.faceOval,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexar/biometric-identity-sdk-react-native",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "React Native wrapper for Biometric Identity SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,14 +11,13 @@
|
|
|
11
11
|
"clean": "rm -rf dist"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
+
"@hexar/biometric-identity-sdk-core": ">=1.0.0",
|
|
14
15
|
"react": ">=18.0.0",
|
|
15
16
|
"react-native": ">=0.70.0",
|
|
16
17
|
"react-native-permissions": ">=4.0.0",
|
|
17
18
|
"react-native-vision-camera": ">=4.0.0"
|
|
18
19
|
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"@hexar/biometric-identity-sdk-core": "file:../core"
|
|
21
|
-
},
|
|
20
|
+
"dependencies": {},
|
|
22
21
|
"devDependencies": {
|
|
23
22
|
"@types/react": "^19.0.0",
|
|
24
23
|
"@types/react-native": "^0.73.0",
|
|
@@ -74,11 +74,16 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
|
|
|
74
74
|
const [isLoadingChallenges, setIsLoadingChallenges] = useState(false);
|
|
75
75
|
|
|
76
76
|
// Set language early, before any components render
|
|
77
|
+
// Priority: language prop > SDK config language > default 'en'
|
|
77
78
|
// Run on mount and whenever language prop changes
|
|
78
79
|
useEffect(() => {
|
|
79
80
|
if (language) {
|
|
81
|
+
// If language prop is provided, override the config
|
|
80
82
|
setLanguage(language);
|
|
81
83
|
}
|
|
84
|
+
// If no language prop, the language should already be set by BiometricIdentitySDK.configure()
|
|
85
|
+
// The global language state is set when configure() is called, so getStrings() will
|
|
86
|
+
// automatically return strings for the configured language (or 'en' as default)
|
|
82
87
|
}, [language]);
|
|
83
88
|
|
|
84
89
|
const strings = getStrings();
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Platform,
|
|
15
15
|
Alert,
|
|
16
16
|
} from 'react-native';
|
|
17
|
-
import { Camera, useCameraDevice, useCameraPermission
|
|
17
|
+
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
18
18
|
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
|
19
19
|
import { ThemeConfig, LivenessInstruction } from '@hexar/biometric-identity-sdk-core';
|
|
20
20
|
|
|
@@ -136,13 +136,16 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
136
136
|
const arrowAnim = useRef(new Animated.Value(0)).current;
|
|
137
137
|
const progressAnim = useRef(new Animated.Value(0)).current;
|
|
138
138
|
|
|
139
|
-
// Refs
|
|
140
139
|
const recordingStartTime = useRef<number>(0);
|
|
141
140
|
const frameInterval = useRef<NodeJS.Timeout | null>(null);
|
|
142
141
|
const frameCaptureInterval = useRef<NodeJS.Timeout | null>(null);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
142
|
+
const videoRecordingRef = useRef<any>(null);
|
|
143
|
+
const isRecordingRef = useRef<boolean>(false);
|
|
144
|
+
const minDurationMs = 8000;
|
|
145
|
+
const totalDuration = duration || Math.max(
|
|
146
|
+
minDurationMs,
|
|
147
|
+
challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000
|
|
148
|
+
);
|
|
146
149
|
|
|
147
150
|
// Check camera permissions
|
|
148
151
|
useEffect(() => {
|
|
@@ -298,53 +301,124 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
298
301
|
).start();
|
|
299
302
|
}, [arrowAnim]);
|
|
300
303
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
const resetAndRetry = useCallback(() => {
|
|
305
|
+
setFrames([]);
|
|
306
|
+
setCompletedChallenges([]);
|
|
307
|
+
setCurrentChallengeIndex(0);
|
|
308
|
+
setChallengeProgress(0);
|
|
309
|
+
setOverallProgress(0);
|
|
310
|
+
recordingStartTime.current = 0;
|
|
311
|
+
setPhase('countdown');
|
|
312
|
+
setCountdown(3);
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
const handleRecordingError = useCallback((error: any) => {
|
|
316
|
+
setPhase('loading');
|
|
317
|
+
Alert.alert(
|
|
318
|
+
'Recording Error',
|
|
319
|
+
'Failed to record video. Please try again.',
|
|
320
|
+
[{ text: 'OK', onPress: onCancel }]
|
|
321
|
+
);
|
|
322
|
+
}, [onCancel]);
|
|
323
|
+
|
|
324
|
+
const handleVideoComplete = useCallback(async (video: any) => {
|
|
325
|
+
const actualDuration = Date.now() - recordingStartTime.current;
|
|
305
326
|
|
|
306
|
-
|
|
327
|
+
if (actualDuration < minDurationMs) {
|
|
328
|
+
Alert.alert(
|
|
329
|
+
'Recording Too Short',
|
|
330
|
+
`Video must be at least ${minDurationMs / 1000} seconds. Please try again.`,
|
|
331
|
+
[{ text: 'OK', onPress: resetAndRetry }]
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const RNFS = require('react-native-fs');
|
|
338
|
+
const videoBase64 = await RNFS.readFile(video.path, 'base64');
|
|
339
|
+
|
|
340
|
+
const result: VideoRecordingResult = {
|
|
341
|
+
frames: frames.length > 0 ? frames : [videoBase64],
|
|
342
|
+
duration: actualDuration,
|
|
343
|
+
instructionsFollowed: completedChallenges.length === challenges.length,
|
|
344
|
+
qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
|
|
345
|
+
challengesCompleted: completedChallenges,
|
|
346
|
+
sessionId,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
onComplete(result);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.error('Error processing video:', error);
|
|
352
|
+
handleRecordingError(error);
|
|
353
|
+
}
|
|
354
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError]);
|
|
355
|
+
|
|
356
|
+
const startFrameCapture = useCallback(() => {
|
|
307
357
|
if (cameraRef.current && device) {
|
|
308
|
-
// Capture frames periodically (every 100ms = ~10 FPS)
|
|
309
358
|
frameCaptureInterval.current = setInterval(async () => {
|
|
310
359
|
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
360
|
const photo = await cameraRef.current?.takePhoto({
|
|
314
361
|
flash: 'off',
|
|
315
362
|
});
|
|
316
363
|
|
|
317
364
|
if (photo) {
|
|
318
|
-
// Convert to base64 if possible, otherwise use file path
|
|
319
365
|
try {
|
|
320
366
|
const RNFS = require('react-native-fs');
|
|
321
367
|
const base64 = await RNFS.readFile(photo.path, 'base64');
|
|
322
|
-
setFrames(prev =>
|
|
368
|
+
setFrames(prev => {
|
|
369
|
+
if (prev.length < 100) {
|
|
370
|
+
return [...prev, base64];
|
|
371
|
+
}
|
|
372
|
+
return prev;
|
|
373
|
+
});
|
|
323
374
|
} catch (fsError) {
|
|
324
|
-
|
|
325
|
-
|
|
375
|
+
setFrames(prev => {
|
|
376
|
+
if (prev.length < 100) {
|
|
377
|
+
return [...prev, photo.path];
|
|
378
|
+
}
|
|
379
|
+
return prev;
|
|
380
|
+
});
|
|
326
381
|
}
|
|
327
382
|
}
|
|
328
383
|
} catch (error) {
|
|
329
384
|
console.warn('Frame capture error:', error);
|
|
330
|
-
// Continue even if one frame fails
|
|
331
385
|
}
|
|
332
386
|
}, 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
387
|
}
|
|
339
|
-
|
|
340
|
-
// Start first challenge
|
|
341
|
-
runChallenge(0);
|
|
342
388
|
}, [device]);
|
|
343
389
|
|
|
344
|
-
|
|
390
|
+
const stopRecording = useCallback(async () => {
|
|
391
|
+
if (videoRecordingRef.current) {
|
|
392
|
+
try {
|
|
393
|
+
await videoRecordingRef.current.stop();
|
|
394
|
+
} catch (error) {
|
|
395
|
+
console.error('Error stopping video recording:', error);
|
|
396
|
+
}
|
|
397
|
+
videoRecordingRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (frameCaptureInterval.current) {
|
|
401
|
+
clearInterval(frameCaptureInterval.current);
|
|
402
|
+
frameCaptureInterval.current = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
isRecordingRef.current = false;
|
|
406
|
+
}, []);
|
|
407
|
+
|
|
345
408
|
const runChallenge = useCallback((index: number) => {
|
|
346
409
|
if (index >= challenges.length) {
|
|
347
|
-
|
|
410
|
+
if (isRecordingRef.current) {
|
|
411
|
+
const elapsed = Date.now() - recordingStartTime.current;
|
|
412
|
+
if (elapsed < minDurationMs) {
|
|
413
|
+
setTimeout(() => {
|
|
414
|
+
if (isRecordingRef.current) {
|
|
415
|
+
stopRecording();
|
|
416
|
+
}
|
|
417
|
+
}, minDurationMs - elapsed);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
stopRecording();
|
|
421
|
+
}
|
|
348
422
|
return;
|
|
349
423
|
}
|
|
350
424
|
|
|
@@ -352,84 +426,82 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
352
426
|
setCurrentChallengeIndex(index);
|
|
353
427
|
setChallengeProgress(0);
|
|
354
428
|
|
|
355
|
-
// Fade in instruction
|
|
356
429
|
Animated.timing(fadeAnim, {
|
|
357
430
|
toValue: 1,
|
|
358
431
|
duration: 300,
|
|
359
432
|
useNativeDriver: true,
|
|
360
433
|
}).start();
|
|
361
434
|
|
|
362
|
-
// Animate arrow for directional challenges
|
|
363
435
|
if (challenge.action.includes('left') || challenge.action.includes('right')) {
|
|
364
436
|
animateArrow(challenge.action);
|
|
365
437
|
}
|
|
366
438
|
|
|
367
|
-
// Progress animation for this challenge
|
|
368
439
|
Animated.timing(progressAnim, {
|
|
369
440
|
toValue: 100,
|
|
370
441
|
duration: challenge.duration_ms,
|
|
371
442
|
useNativeDriver: false,
|
|
372
443
|
}).start();
|
|
373
444
|
|
|
374
|
-
// Update progress in real-time
|
|
375
445
|
const progressInterval = setInterval(() => {
|
|
376
446
|
setChallengeProgress(prev => {
|
|
377
447
|
const newProgress = prev + (100 / (challenge.duration_ms / 100));
|
|
378
448
|
return Math.min(100, newProgress);
|
|
379
449
|
});
|
|
380
450
|
|
|
381
|
-
// Update overall progress
|
|
382
451
|
const elapsed = Date.now() - recordingStartTime.current;
|
|
383
|
-
const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
|
|
452
|
+
const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
384
453
|
setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
|
|
385
454
|
}, 100);
|
|
386
455
|
|
|
387
|
-
// Move to next challenge after duration
|
|
388
456
|
setTimeout(() => {
|
|
389
457
|
clearInterval(progressInterval);
|
|
390
458
|
|
|
391
|
-
// Mark challenge as completed
|
|
392
459
|
setCompletedChallenges(prev => [...prev, challenge.action]);
|
|
393
460
|
|
|
394
|
-
// Fade out current instruction
|
|
395
461
|
Animated.timing(fadeAnim, {
|
|
396
462
|
toValue: 0,
|
|
397
463
|
duration: 200,
|
|
398
464
|
useNativeDriver: true,
|
|
399
465
|
}).start(() => {
|
|
400
|
-
// Reset and start next challenge
|
|
401
466
|
progressAnim.setValue(0);
|
|
402
467
|
runChallenge(index + 1);
|
|
403
468
|
});
|
|
404
469
|
}, challenge.duration_ms);
|
|
405
|
-
}, [challenges, fadeAnim, progressAnim, animateArrow]);
|
|
470
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
|
|
406
471
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (
|
|
413
|
-
|
|
472
|
+
const startRecording = useCallback(async () => {
|
|
473
|
+
setPhase('recording');
|
|
474
|
+
recordingStartTime.current = Date.now();
|
|
475
|
+
isRecordingRef.current = true;
|
|
476
|
+
|
|
477
|
+
if (cameraRef.current && device) {
|
|
478
|
+
try {
|
|
479
|
+
videoRecordingRef.current = await cameraRef.current.startRecording({
|
|
480
|
+
flash: 'off',
|
|
481
|
+
onRecordingFinished: (video: any) => {
|
|
482
|
+
handleVideoComplete(video);
|
|
483
|
+
},
|
|
484
|
+
onRecordingError: (error: any) => {
|
|
485
|
+
console.error('Recording error:', error);
|
|
486
|
+
handleRecordingError(error);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.warn('Video recording not available, falling back to frame capture:', error);
|
|
491
|
+
startFrameCapture();
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
startFrameCapture();
|
|
414
495
|
}
|
|
415
496
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// Process and return result
|
|
497
|
+
runChallenge(0);
|
|
498
|
+
|
|
420
499
|
setTimeout(() => {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
challengesCompleted: completedChallenges,
|
|
427
|
-
sessionId,
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
onComplete(result);
|
|
431
|
-
}, 500);
|
|
432
|
-
}, [frames, completedChallenges, challenges, sessionId, onComplete]);
|
|
500
|
+
if (isRecordingRef.current) {
|
|
501
|
+
stopRecording();
|
|
502
|
+
}
|
|
503
|
+
}, totalDuration);
|
|
504
|
+
}, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
|
|
433
505
|
|
|
434
506
|
// Current challenge
|
|
435
507
|
const currentChallenge = challenges[currentChallengeIndex];
|
|
@@ -532,8 +604,8 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
532
604
|
style={StyleSheet.absoluteFill}
|
|
533
605
|
device={device}
|
|
534
606
|
isActive={phase === 'recording' || phase === 'countdown'}
|
|
535
|
-
video={
|
|
536
|
-
|
|
607
|
+
video={true}
|
|
608
|
+
audio={false}
|
|
537
609
|
/>
|
|
538
610
|
|
|
539
611
|
{/* Face Oval Overlay */}
|