@hexar/biometric-identity-sdk-react-native 1.14.0 → 1.16.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/dist/components/ProfilePictureCapture.d.ts.map +1 -1
- package/dist/components/ProfilePictureCapture.js +44 -28
- package/dist/components/ValidationProgress.js +10 -9
- package/dist/components/VideoRecorder.d.ts.map +1 -1
- package/dist/components/VideoRecorder.js +87 -22
- package/dist/hooks/useBiometricSDK.d.ts.map +1 -1
- package/dist/hooks/useBiometricSDK.js +11 -2
- package/package.json +2 -2
- package/src/components/ProfilePictureCapture.tsx +47 -32
- package/src/components/ValidationProgress.tsx +12 -12
- package/src/components/VideoRecorder.tsx +99 -23
- package/src/hooks/useBiometricSDK.ts +11 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProfilePictureCapture.d.ts","sourceRoot":"","sources":["../../src/components/ProfilePictureCapture.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ProfilePictureCapture.d.ts","sourceRoot":"","sources":["../../src/components/ProfilePictureCapture.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAaxE,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAmC,cAAc,EAAsB,MAAM,oCAAoC,CAAC;AAGzJ,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,CAAC,MAAM,EAAE,8BAA8B,KAAK,IAAI,CAAC;IAC7D,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAuZtE,CAAC;AA4EF,eAAe,qBAAqB,CAAC"}
|
|
@@ -51,6 +51,12 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
51
51
|
const [displayProgress, setDisplayProgress] = (0, react_1.useState)(0);
|
|
52
52
|
const animationRef = (0, react_1.useRef)(null);
|
|
53
53
|
const hasStartedAnimation = (0, react_1.useRef)(false);
|
|
54
|
+
const isMountedRef = (0, react_1.useRef)(true);
|
|
55
|
+
const challengeSessionIdRef = (0, react_1.useRef)(null);
|
|
56
|
+
(0, react_1.useEffect)(() => {
|
|
57
|
+
isMountedRef.current = true;
|
|
58
|
+
return () => { isMountedRef.current = false; };
|
|
59
|
+
}, []);
|
|
54
60
|
const handleFetchChallenges = (0, react_1.useCallback)(async () => {
|
|
55
61
|
return await fetchChallenges('active');
|
|
56
62
|
}, [fetchChallenges]);
|
|
@@ -78,8 +84,12 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
78
84
|
setIsLoadingChallenges(true);
|
|
79
85
|
try {
|
|
80
86
|
const challenges = await fetchChallenges('active');
|
|
87
|
+
// Capture session ID right after challenge fetch — this is the session
|
|
88
|
+
// the backend expects when we submit the video for validation.
|
|
89
|
+
challengeSessionIdRef.current = sdk.getSessionId();
|
|
81
90
|
biometric_identity_sdk_core_1.logger.info('ProfilePictureCapture: Challenges loaded', {
|
|
82
91
|
challengeCount: challenges.length,
|
|
92
|
+
sessionId: challengeSessionIdRef.current,
|
|
83
93
|
challenges: challenges.map(c => c.action)
|
|
84
94
|
});
|
|
85
95
|
setCurrentChallenges(challenges);
|
|
@@ -101,29 +111,26 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
101
111
|
const strings = (0, biometric_identity_sdk_core_1.getStrings)();
|
|
102
112
|
const validateWithBackend = (0, react_1.useCallback)(async (videoResult) => {
|
|
103
113
|
try {
|
|
104
|
-
if (!isInitialized) {
|
|
105
|
-
biometric_identity_sdk_core_1.logger.error('SDK not
|
|
106
|
-
throw
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
let sessionId = videoResult.sessionId || sdk.getSessionId();
|
|
113
|
-
if (!sessionId) {
|
|
114
|
-
biometric_identity_sdk_core_1.logger.info('No session ID available, generating challenge to create one');
|
|
115
|
-
try {
|
|
116
|
-
const challengeResponse = await sdk.generateLivenessChallenge('active');
|
|
117
|
-
sessionId = challengeResponse.session_id;
|
|
118
|
-
biometric_identity_sdk_core_1.logger.info('Session ID generated', { sessionId });
|
|
119
|
-
}
|
|
120
|
-
catch (challengeError) {
|
|
121
|
-
biometric_identity_sdk_core_1.logger.error('Failed to generate challenge for session ID', challengeError);
|
|
122
|
-
}
|
|
114
|
+
if (!isInitialized || !isUsingBackend) {
|
|
115
|
+
biometric_identity_sdk_core_1.logger.error('SDK not ready', { isInitialized, isUsingBackend });
|
|
116
|
+
throw {
|
|
117
|
+
name: 'BiometricError',
|
|
118
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
119
|
+
code: biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR,
|
|
120
|
+
};
|
|
123
121
|
}
|
|
122
|
+
// Use the session ID from the original challenge fetch (stored in ref),
|
|
123
|
+
// falling back to the video result or SDK state. NEVER generate a new
|
|
124
|
+
// challenge here — that creates a session mismatch where the backend
|
|
125
|
+
// expects challenges from the old session but receives a new one.
|
|
126
|
+
const sessionId = videoResult.sessionId || challengeSessionIdRef.current || sdk.getSessionId();
|
|
124
127
|
if (!sessionId) {
|
|
125
|
-
biometric_identity_sdk_core_1.logger.error('
|
|
126
|
-
throw
|
|
128
|
+
biometric_identity_sdk_core_1.logger.error('No session ID available — challenge was not properly loaded');
|
|
129
|
+
throw {
|
|
130
|
+
name: 'BiometricError',
|
|
131
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
132
|
+
code: biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR,
|
|
133
|
+
};
|
|
127
134
|
}
|
|
128
135
|
if (!videoResult.frames || videoResult.frames.length === 0) {
|
|
129
136
|
throw new Error('No video frames available for validation');
|
|
@@ -165,10 +172,11 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
165
172
|
hasStartedAnimation.current = true;
|
|
166
173
|
animatedProgress.setValue(0);
|
|
167
174
|
setDisplayProgress(0);
|
|
168
|
-
//
|
|
175
|
+
// Steady linear fill to 90% over 35 seconds — feels like real progress
|
|
169
176
|
animationRef.current = react_native_1.Animated.timing(animatedProgress, {
|
|
170
177
|
toValue: 90,
|
|
171
|
-
duration:
|
|
178
|
+
duration: 35000,
|
|
179
|
+
easing: react_native_1.Easing.linear,
|
|
172
180
|
useNativeDriver: false,
|
|
173
181
|
});
|
|
174
182
|
animationRef.current.start();
|
|
@@ -204,6 +212,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
204
212
|
setIsValidating(true);
|
|
205
213
|
try {
|
|
206
214
|
const result = await validateWithBackend(videoResult);
|
|
215
|
+
if (!isMountedRef.current)
|
|
216
|
+
return;
|
|
207
217
|
if (animationRef.current) {
|
|
208
218
|
animationRef.current.stop();
|
|
209
219
|
}
|
|
@@ -212,10 +222,13 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
212
222
|
duration: 500,
|
|
213
223
|
useNativeDriver: false,
|
|
214
224
|
}).start(() => {
|
|
215
|
-
|
|
225
|
+
if (isMountedRef.current)
|
|
226
|
+
setDisplayProgress(100);
|
|
216
227
|
});
|
|
217
228
|
// Small delay to show 100% before completing
|
|
218
229
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
230
|
+
if (!isMountedRef.current)
|
|
231
|
+
return;
|
|
219
232
|
if (!result.isValid) {
|
|
220
233
|
if (animationRef.current) {
|
|
221
234
|
animationRef.current.stop();
|
|
@@ -233,6 +246,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
233
246
|
onComplete(result);
|
|
234
247
|
}
|
|
235
248
|
catch (error) {
|
|
249
|
+
if (!isMountedRef.current)
|
|
250
|
+
return;
|
|
236
251
|
// Stop animation on error
|
|
237
252
|
if (animationRef.current) {
|
|
238
253
|
animationRef.current.stop();
|
|
@@ -262,15 +277,16 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
262
277
|
}
|
|
263
278
|
let errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR;
|
|
264
279
|
let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
|
|
265
|
-
|
|
280
|
+
const msg = error?.message?.toLowerCase() || '';
|
|
281
|
+
if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
|
|
266
282
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.NETWORK_ERROR;
|
|
267
283
|
errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
|
|
268
284
|
}
|
|
269
|
-
else if (
|
|
285
|
+
else if (msg.includes('timeout') || msg.includes('aborterror')) {
|
|
270
286
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.VALIDATION_TIMEOUT;
|
|
271
287
|
errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
|
|
272
288
|
}
|
|
273
|
-
else if (
|
|
289
|
+
else if (msg.includes('liveness')) {
|
|
274
290
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.LIVENESS_CHECK_FAILED;
|
|
275
291
|
errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
|
|
276
292
|
}
|
|
@@ -332,7 +348,7 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
332
348
|
if (showFacePositioningGuide) {
|
|
333
349
|
return (react_1.default.createElement(FacePositioningGuide_1.FacePositioningGuide, { theme: theme, language: language, onContinue: () => setShowFacePositioningGuide(false), onCancel: onCancel }));
|
|
334
350
|
}
|
|
335
|
-
return (react_1.default.createElement(VideoRecorder_1.VideoRecorder, { theme: theme, language: language, smartMode: true, challenges: currentChallenges, sessionId: sdk.getSessionId() || undefined, onComplete: handleVideoComplete, onCancel: handleVideoCancel, onFetchChallenges: handleFetchChallenges }));
|
|
351
|
+
return (react_1.default.createElement(VideoRecorder_1.VideoRecorder, { theme: theme, language: language, smartMode: true, challenges: currentChallenges, sessionId: challengeSessionIdRef.current || sdk.getSessionId() || undefined, onComplete: handleVideoComplete, onCancel: handleVideoCancel, onFetchChallenges: handleFetchChallenges }));
|
|
336
352
|
};
|
|
337
353
|
exports.ProfilePictureCapture = ProfilePictureCapture;
|
|
338
354
|
const styles = react_native_1.StyleSheet.create({
|
|
@@ -52,29 +52,30 @@ const ValidationProgress = ({ progress, theme, language = 'en', }) => {
|
|
|
52
52
|
}
|
|
53
53
|
}, [language]);
|
|
54
54
|
(0, react_1.useEffect)(() => {
|
|
55
|
-
//
|
|
55
|
+
// Animate to whichever is higher: the fake "filling" target or real progress.
|
|
56
|
+
// The fake fill reaches 80% in 60s so the bar never looks stuck even if the
|
|
57
|
+
// backend is slow, but real progress always takes precedence.
|
|
56
58
|
if (!hasStartedAnimation.current) {
|
|
57
59
|
hasStartedAnimation.current = true;
|
|
58
|
-
//
|
|
60
|
+
// Slow fill to 80% over 60 seconds — purely cosmetic
|
|
59
61
|
animationRef.current = react_native_1.Animated.timing(animatedProgress, {
|
|
60
|
-
toValue:
|
|
61
|
-
duration:
|
|
62
|
+
toValue: 80,
|
|
63
|
+
duration: 60000,
|
|
62
64
|
useNativeDriver: false,
|
|
63
65
|
});
|
|
64
66
|
animationRef.current.start();
|
|
65
67
|
}
|
|
66
|
-
//
|
|
67
|
-
if (progress
|
|
68
|
+
// When real progress arrives and exceeds the current animated value, snap to it
|
|
69
|
+
if (progress > 0) {
|
|
68
70
|
if (animationRef.current) {
|
|
69
71
|
animationRef.current.stop();
|
|
70
72
|
}
|
|
71
73
|
react_native_1.Animated.timing(animatedProgress, {
|
|
72
|
-
toValue: progress,
|
|
73
|
-
duration: 500,
|
|
74
|
+
toValue: Math.max(progress, 80), // never go backwards
|
|
75
|
+
duration: progress >= 90 ? 500 : 300,
|
|
74
76
|
useNativeDriver: false,
|
|
75
77
|
}).start();
|
|
76
78
|
}
|
|
77
|
-
// Update display value
|
|
78
79
|
const listener = animatedProgress.addListener(({ value }) => {
|
|
79
80
|
setDisplayProgress(Math.round(value));
|
|
80
81
|
});
|
|
@@ -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;AAcxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAE1I,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,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,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;AA2CD,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;AAcxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAE1I,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,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,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;AA2CD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAq+BtD,CAAC;AA4PF,eAAe,aAAa,CAAC"}
|
|
@@ -99,6 +99,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
99
99
|
const completedChallengesRef = (0, react_1.useRef)([]);
|
|
100
100
|
const [frames, setFrames] = (0, react_1.useState)([]);
|
|
101
101
|
const framesRef = (0, react_1.useRef)([]);
|
|
102
|
+
const [guidanceText, setGuidanceText] = (0, react_1.useState)(null);
|
|
102
103
|
const [hasPermission, setHasPermission] = (0, react_1.useState)(false);
|
|
103
104
|
const cameraRef = (0, react_1.useRef)(null);
|
|
104
105
|
const { hasPermission: cameraPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
|
|
@@ -337,7 +338,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
337
338
|
frames: finalFrames,
|
|
338
339
|
duration: actualDuration,
|
|
339
340
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
340
|
-
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length /
|
|
341
|
+
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length / 20) * 100) : 85,
|
|
341
342
|
challengesCompleted: currentCompletedChallenges,
|
|
342
343
|
sessionId,
|
|
343
344
|
};
|
|
@@ -358,7 +359,43 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
358
359
|
framesRef.current = [];
|
|
359
360
|
let consecutiveErrors = 0;
|
|
360
361
|
const maxConsecutiveErrors = 10;
|
|
361
|
-
|
|
362
|
+
// Backend samples down to 15 anyway — 20 gives margin without OOM risk
|
|
363
|
+
const MAX_FRAMES = 20;
|
|
364
|
+
const FRAME_MAX_PX = 640;
|
|
365
|
+
const JPEG_QUALITY = 60;
|
|
366
|
+
// Quality gate: reject dark/corrupt frames (< 5KB) and abnormally large ones (> 500KB)
|
|
367
|
+
const MIN_FRAME_KB = 5;
|
|
368
|
+
const MAX_FRAME_KB = 500;
|
|
369
|
+
// Compress + read a photo to base64 — resize to 640px JPEG60
|
|
370
|
+
// to keep each frame at ~30-50KB instead of ~3MB raw.
|
|
371
|
+
const compressAndRead = async (photoPath) => {
|
|
372
|
+
const RNFS = require('react-native-fs');
|
|
373
|
+
const cleanPath = photoPath.startsWith('file://') ? photoPath.replace('file://', '') : photoPath;
|
|
374
|
+
let readPath = cleanPath;
|
|
375
|
+
// Try resizing with react-native-image-resizer (already a project dep)
|
|
376
|
+
try {
|
|
377
|
+
const ImageResizer = require('react-native-image-resizer').default;
|
|
378
|
+
const resized = await ImageResizer.createResizedImage(photoPath, FRAME_MAX_PX, FRAME_MAX_PX, 'JPEG', JPEG_QUALITY, 0, undefined, false, { mode: 'contain', onlyScaleDown: true });
|
|
379
|
+
readPath = resized.uri.startsWith('file://') ? resized.uri.replace('file://', '') : resized.uri;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Resizer not available or failed — fall back to raw photo
|
|
383
|
+
}
|
|
384
|
+
// Read with one retry (transient I/O on some Android OEMs)
|
|
385
|
+
try {
|
|
386
|
+
return await RNFS.readFile(readPath, 'base64');
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
await new Promise(r => setTimeout(r, 50));
|
|
390
|
+
try {
|
|
391
|
+
return await RNFS.readFile(readPath, 'base64');
|
|
392
|
+
}
|
|
393
|
+
catch (retryErr) {
|
|
394
|
+
biometric_identity_sdk_core_1.logger.error('RNFS readFile failed after retry', { path: readPath, error: retryErr });
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
362
399
|
// Serial capture: each frame is captured only after the previous one
|
|
363
400
|
// finishes, preventing overlapping takePhoto() calls that cause the
|
|
364
401
|
// camera to throw "busy" errors and kill the capture loop.
|
|
@@ -372,25 +409,35 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
372
409
|
});
|
|
373
410
|
if (photo) {
|
|
374
411
|
consecutiveErrors = 0;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
412
|
+
const base64Data = await compressAndRead(photo.path);
|
|
413
|
+
if (base64Data) {
|
|
414
|
+
const sizeKB = base64Data.length / 1024;
|
|
415
|
+
if (sizeKB >= MIN_FRAME_KB && sizeKB <= MAX_FRAME_KB) {
|
|
416
|
+
framesRef.current = [...framesRef.current, base64Data];
|
|
417
|
+
// Clear guidance when we get a good frame
|
|
418
|
+
if (guidanceText)
|
|
419
|
+
setGuidanceText(null);
|
|
420
|
+
}
|
|
421
|
+
else if (sizeKB < MIN_FRAME_KB) {
|
|
422
|
+
// Very small frame = dark/black image
|
|
423
|
+
setGuidanceText('Mejorá la iluminación');
|
|
424
|
+
biometric_identity_sdk_core_1.logger.warn(`Frame rejected (too dark): ${sizeKB.toFixed(1)}KB`);
|
|
381
425
|
}
|
|
382
|
-
|
|
383
|
-
|
|
426
|
+
else {
|
|
427
|
+
biometric_identity_sdk_core_1.logger.warn(`Frame rejected by quality gate: ${sizeKB.toFixed(1)}KB`);
|
|
384
428
|
}
|
|
385
429
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
430
|
+
else {
|
|
431
|
+
// RNFS failed — likely device I/O issue
|
|
432
|
+
setGuidanceText('Mantené el celular quieto');
|
|
389
433
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
434
|
+
// Clean up temp photo to free disk space on low-storage devices
|
|
435
|
+
try {
|
|
436
|
+
const RNFS = require('react-native-fs');
|
|
437
|
+
const cleanPath = photo.path.startsWith('file://') ? photo.path.replace('file://', '') : photo.path;
|
|
438
|
+
RNFS.unlink(cleanPath).catch(() => { });
|
|
393
439
|
}
|
|
440
|
+
catch { }
|
|
394
441
|
}
|
|
395
442
|
}
|
|
396
443
|
catch (error) {
|
|
@@ -443,7 +490,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
443
490
|
frames: currentFrames,
|
|
444
491
|
duration: actualDuration,
|
|
445
492
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
446
|
-
qualityScore: Math.min(100, (currentFrames.length /
|
|
493
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
447
494
|
challengesCompleted: currentCompletedChallenges,
|
|
448
495
|
sessionId,
|
|
449
496
|
};
|
|
@@ -455,7 +502,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
455
502
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
456
503
|
duration: actualDuration,
|
|
457
504
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
458
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
505
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
459
506
|
challengesCompleted: currentCompletedChallenges,
|
|
460
507
|
sessionId,
|
|
461
508
|
};
|
|
@@ -486,7 +533,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
486
533
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
487
534
|
duration: actualDuration,
|
|
488
535
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
489
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
536
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
490
537
|
challengesCompleted: currentCompletedChallenges,
|
|
491
538
|
sessionId,
|
|
492
539
|
};
|
|
@@ -525,12 +572,13 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
525
572
|
else {
|
|
526
573
|
const actualDuration = Date.now() - recordingStartTime.current;
|
|
527
574
|
const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
|
|
528
|
-
|
|
575
|
+
const currentFrames = framesRef.current.length > 0 ? framesRef.current : frames;
|
|
576
|
+
if (actualDuration >= minDurationMs && currentFrames.length > 0) {
|
|
529
577
|
const result = {
|
|
530
|
-
frames,
|
|
578
|
+
frames: currentFrames,
|
|
531
579
|
duration: actualDuration,
|
|
532
580
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
533
|
-
qualityScore: Math.min(100, (
|
|
581
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
534
582
|
challengesCompleted: currentCompletedChallenges,
|
|
535
583
|
sessionId,
|
|
536
584
|
};
|
|
@@ -776,6 +824,8 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
776
824
|
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.`),
|
|
777
825
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.cancelButton, { borderColor: theme?.errorColor || '#EF4444' }], onPress: onCancel },
|
|
778
826
|
react_1.default.createElement(react_native_1.Text, { style: [styles.cancelButtonText, { color: theme?.errorColor || '#EF4444' }] }, strings.common.cancel || 'Cancel')))),
|
|
827
|
+
phase === 'recording' && guidanceText && (react_1.default.createElement(react_native_1.View, { style: styles.guidanceContainer },
|
|
828
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.guidanceText }, guidanceText))),
|
|
779
829
|
phase === 'recording' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText }, strings.liveness.recordingInstructions || 'Keep your face visible and follow the instructions')),
|
|
780
830
|
phase === 'processing' && (react_1.default.createElement(react_native_1.Text, { style: styles.bottomText }, strings.validation.almostDone || 'Almost done...')))));
|
|
781
831
|
};
|
|
@@ -996,6 +1046,21 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
996
1046
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
997
1047
|
alignItems: 'center',
|
|
998
1048
|
},
|
|
1049
|
+
guidanceContainer: {
|
|
1050
|
+
backgroundColor: 'rgba(239, 68, 68, 0.85)',
|
|
1051
|
+
borderRadius: 8,
|
|
1052
|
+
paddingVertical: 8,
|
|
1053
|
+
paddingHorizontal: 16,
|
|
1054
|
+
marginHorizontal: 20,
|
|
1055
|
+
marginBottom: 8,
|
|
1056
|
+
alignItems: 'center',
|
|
1057
|
+
},
|
|
1058
|
+
guidanceText: {
|
|
1059
|
+
color: '#FFFFFF',
|
|
1060
|
+
fontSize: 14,
|
|
1061
|
+
fontWeight: '600',
|
|
1062
|
+
textAlign: 'center',
|
|
1063
|
+
},
|
|
999
1064
|
bottomText: {
|
|
1000
1065
|
color: '#FFFFFF',
|
|
1001
1066
|
fontSize: 16,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useBiometricSDK.d.ts","sourceRoot":"","sources":["../../src/hooks/useBiometricSDK.ts"],"names":[],"mappings":"AACA,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,WAAW,EAGZ,MAAM,oCAAoC,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,oBAAoB,CAAC;IAC1B,KAAK,EAAE,QAAQ,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,YAAY,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,mBAAmB,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,eAAe,EAAE,CAAC,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAC7E,gBAAgB,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;IACrC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,WAAW,EAAE,CAAC,SAAS,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,eAAO,MAAM,eAAe,QAAO,
|
|
1
|
+
{"version":3,"file":"useBiometricSDK.d.ts","sourceRoot":"","sources":["../../src/hooks/useBiometricSDK.ts"],"names":[],"mappings":"AACA,OAAO,EACL,oBAAoB,EACpB,QAAQ,EACR,WAAW,EAGZ,MAAM,oCAAoC,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,oBAAoB,CAAC;IAC1B,KAAK,EAAE,QAAQ,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,YAAY,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,mBAAmB,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,eAAe,EAAE,CAAC,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAC7E,gBAAgB,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;IACrC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,WAAW,EAAE,CAAC,SAAS,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,eAAO,MAAM,eAAe,QAAO,qBAmRlC,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
|
@@ -11,7 +11,8 @@ const useBiometricSDK = () => {
|
|
|
11
11
|
const isMounted = (0, react_1.useRef)(true);
|
|
12
12
|
(0, react_1.useEffect)(() => {
|
|
13
13
|
isMounted.current = true;
|
|
14
|
-
|
|
14
|
+
let retryTimeout = null;
|
|
15
|
+
const init = async (attempt = 1) => {
|
|
15
16
|
try {
|
|
16
17
|
await sdk.initialize();
|
|
17
18
|
if (isMounted.current) {
|
|
@@ -20,12 +21,20 @@ const useBiometricSDK = () => {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
catch (error) {
|
|
23
|
-
biometric_identity_sdk_core_1.logger.error(
|
|
24
|
+
biometric_identity_sdk_core_1.logger.error(`SDK initialization failed (attempt ${attempt}):`, error);
|
|
25
|
+
// Retry up to 3 times with exponential backoff (1s, 2s, 4s)
|
|
26
|
+
if (attempt < 4 && isMounted.current) {
|
|
27
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 4000);
|
|
28
|
+
biometric_identity_sdk_core_1.logger.info(`Retrying SDK init in ${delay}ms`);
|
|
29
|
+
retryTimeout = setTimeout(() => init(attempt + 1), delay);
|
|
30
|
+
}
|
|
24
31
|
}
|
|
25
32
|
};
|
|
26
33
|
init();
|
|
27
34
|
return () => {
|
|
28
35
|
isMounted.current = false;
|
|
36
|
+
if (retryTimeout)
|
|
37
|
+
clearTimeout(retryTimeout);
|
|
29
38
|
sdk.dispose();
|
|
30
39
|
};
|
|
31
40
|
}, [sdk]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexar/biometric-identity-sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "React Native wrapper for Biometric Identity SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"clean": "rm -rf dist"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@hexar/biometric-identity-sdk-core": ">=1.
|
|
14
|
+
"@hexar/biometric-identity-sdk-core": ">=1.8.0",
|
|
15
15
|
"react": ">=18.0.0",
|
|
16
16
|
"react-native": ">=0.70.0",
|
|
17
17
|
"react-native-fs": ">=2.20.0",
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ActivityIndicator,
|
|
7
7
|
SafeAreaView,
|
|
8
8
|
Animated,
|
|
9
|
+
Easing,
|
|
9
10
|
TouchableOpacity,
|
|
10
11
|
} from 'react-native';
|
|
11
12
|
import { VideoRecorder, VideoRecordingResult } from './VideoRecorder';
|
|
@@ -53,6 +54,13 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
53
54
|
const [displayProgress, setDisplayProgress] = useState(0);
|
|
54
55
|
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
55
56
|
const hasStartedAnimation = useRef(false);
|
|
57
|
+
const isMountedRef = useRef(true);
|
|
58
|
+
const challengeSessionIdRef = useRef<string | null>(null);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
isMountedRef.current = true;
|
|
62
|
+
return () => { isMountedRef.current = false; };
|
|
63
|
+
}, []);
|
|
56
64
|
|
|
57
65
|
const handleFetchChallenges = useCallback(async () => {
|
|
58
66
|
return await fetchChallenges('active');
|
|
@@ -83,8 +91,12 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
83
91
|
setIsLoadingChallenges(true);
|
|
84
92
|
try {
|
|
85
93
|
const challenges = await fetchChallenges('active');
|
|
94
|
+
// Capture session ID right after challenge fetch — this is the session
|
|
95
|
+
// the backend expects when we submit the video for validation.
|
|
96
|
+
challengeSessionIdRef.current = sdk.getSessionId();
|
|
86
97
|
logger.info('ProfilePictureCapture: Challenges loaded', {
|
|
87
98
|
challengeCount: challenges.length,
|
|
99
|
+
sessionId: challengeSessionIdRef.current,
|
|
88
100
|
challenges: challenges.map(c => c.action)
|
|
89
101
|
});
|
|
90
102
|
setCurrentChallenges(challenges);
|
|
@@ -106,32 +118,28 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
106
118
|
|
|
107
119
|
const validateWithBackend = useCallback(async (videoResult: VideoRecordingResult): Promise<ProfilePictureValidationResult> => {
|
|
108
120
|
try {
|
|
109
|
-
if (!isInitialized) {
|
|
110
|
-
logger.error('SDK not
|
|
111
|
-
throw
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
throw new Error('NETWORK_ERROR');
|
|
121
|
+
if (!isInitialized || !isUsingBackend) {
|
|
122
|
+
logger.error('SDK not ready', { isInitialized, isUsingBackend });
|
|
123
|
+
throw {
|
|
124
|
+
name: 'BiometricError',
|
|
125
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
126
|
+
code: BiometricErrorCode.UNKNOWN_ERROR,
|
|
127
|
+
};
|
|
117
128
|
}
|
|
118
129
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const challengeResponse = await sdk.generateLivenessChallenge('active');
|
|
125
|
-
sessionId = challengeResponse.session_id;
|
|
126
|
-
logger.info('Session ID generated', { sessionId });
|
|
127
|
-
} catch (challengeError) {
|
|
128
|
-
logger.error('Failed to generate challenge for session ID', challengeError);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
130
|
+
// Use the session ID from the original challenge fetch (stored in ref),
|
|
131
|
+
// falling back to the video result or SDK state. NEVER generate a new
|
|
132
|
+
// challenge here — that creates a session mismatch where the backend
|
|
133
|
+
// expects challenges from the old session but receives a new one.
|
|
134
|
+
const sessionId = videoResult.sessionId || challengeSessionIdRef.current || sdk.getSessionId();
|
|
131
135
|
|
|
132
136
|
if (!sessionId) {
|
|
133
|
-
logger.error('
|
|
134
|
-
throw
|
|
137
|
+
logger.error('No session ID available — challenge was not properly loaded');
|
|
138
|
+
throw {
|
|
139
|
+
name: 'BiometricError',
|
|
140
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
141
|
+
code: BiometricErrorCode.UNKNOWN_ERROR,
|
|
142
|
+
};
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
if (!videoResult.frames || videoResult.frames.length === 0) {
|
|
@@ -178,10 +186,11 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
178
186
|
animatedProgress.setValue(0);
|
|
179
187
|
setDisplayProgress(0);
|
|
180
188
|
|
|
181
|
-
//
|
|
189
|
+
// Steady linear fill to 90% over 35 seconds — feels like real progress
|
|
182
190
|
animationRef.current = Animated.timing(animatedProgress, {
|
|
183
191
|
toValue: 90,
|
|
184
|
-
duration:
|
|
192
|
+
duration: 35000,
|
|
193
|
+
easing: Easing.linear,
|
|
185
194
|
useNativeDriver: false,
|
|
186
195
|
});
|
|
187
196
|
|
|
@@ -222,7 +231,9 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
222
231
|
|
|
223
232
|
try {
|
|
224
233
|
const result = await validateWithBackend(videoResult);
|
|
225
|
-
|
|
234
|
+
|
|
235
|
+
if (!isMountedRef.current) return;
|
|
236
|
+
|
|
226
237
|
if (animationRef.current) {
|
|
227
238
|
animationRef.current.stop();
|
|
228
239
|
}
|
|
@@ -231,12 +242,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
231
242
|
duration: 500,
|
|
232
243
|
useNativeDriver: false,
|
|
233
244
|
}).start(() => {
|
|
234
|
-
setDisplayProgress(100);
|
|
245
|
+
if (isMountedRef.current) setDisplayProgress(100);
|
|
235
246
|
});
|
|
236
|
-
|
|
247
|
+
|
|
237
248
|
// Small delay to show 100% before completing
|
|
238
249
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
239
|
-
|
|
250
|
+
|
|
251
|
+
if (!isMountedRef.current) return;
|
|
252
|
+
|
|
240
253
|
if (!result.isValid) {
|
|
241
254
|
if (animationRef.current) {
|
|
242
255
|
animationRef.current.stop();
|
|
@@ -254,6 +267,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
254
267
|
setIsValidating(false);
|
|
255
268
|
onComplete(result);
|
|
256
269
|
} catch (error: any) {
|
|
270
|
+
if (!isMountedRef.current) return;
|
|
257
271
|
// Stop animation on error
|
|
258
272
|
if (animationRef.current) {
|
|
259
273
|
animationRef.current.stop();
|
|
@@ -284,13 +298,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
284
298
|
let errorCode = BiometricErrorCode.UNKNOWN_ERROR;
|
|
285
299
|
let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
|
|
286
300
|
|
|
287
|
-
|
|
301
|
+
const msg = error?.message?.toLowerCase() || '';
|
|
302
|
+
if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
|
|
288
303
|
errorCode = BiometricErrorCode.NETWORK_ERROR;
|
|
289
304
|
errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
|
|
290
|
-
} else if (
|
|
305
|
+
} else if (msg.includes('timeout') || msg.includes('aborterror')) {
|
|
291
306
|
errorCode = BiometricErrorCode.VALIDATION_TIMEOUT;
|
|
292
307
|
errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
|
|
293
|
-
} else if (
|
|
308
|
+
} else if (msg.includes('liveness')) {
|
|
294
309
|
errorCode = BiometricErrorCode.LIVENESS_CHECK_FAILED;
|
|
295
310
|
errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
|
|
296
311
|
}
|
|
@@ -416,7 +431,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
416
431
|
language={language}
|
|
417
432
|
smartMode={true}
|
|
418
433
|
challenges={currentChallenges}
|
|
419
|
-
sessionId={sdk.getSessionId() || undefined}
|
|
434
|
+
sessionId={challengeSessionIdRef.current || sdk.getSessionId() || undefined}
|
|
420
435
|
onComplete={handleVideoComplete}
|
|
421
436
|
onCancel={handleVideoCancel}
|
|
422
437
|
onFetchChallenges={handleFetchChallenges}
|
|
@@ -36,33 +36,33 @@ export const ValidationProgress: React.FC<ValidationProgressProps> = ({
|
|
|
36
36
|
}, [language]);
|
|
37
37
|
|
|
38
38
|
useEffect(() => {
|
|
39
|
-
//
|
|
39
|
+
// Animate to whichever is higher: the fake "filling" target or real progress.
|
|
40
|
+
// The fake fill reaches 80% in 60s so the bar never looks stuck even if the
|
|
41
|
+
// backend is slow, but real progress always takes precedence.
|
|
40
42
|
if (!hasStartedAnimation.current) {
|
|
41
43
|
hasStartedAnimation.current = true;
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
+
|
|
45
|
+
// Slow fill to 80% over 60 seconds — purely cosmetic
|
|
44
46
|
animationRef.current = Animated.timing(animatedProgress, {
|
|
45
|
-
toValue:
|
|
46
|
-
duration:
|
|
47
|
+
toValue: 80,
|
|
48
|
+
duration: 60000,
|
|
47
49
|
useNativeDriver: false,
|
|
48
50
|
});
|
|
49
|
-
|
|
50
51
|
animationRef.current.start();
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
if (progress
|
|
53
|
+
|
|
54
|
+
// When real progress arrives and exceeds the current animated value, snap to it
|
|
55
|
+
if (progress > 0) {
|
|
55
56
|
if (animationRef.current) {
|
|
56
57
|
animationRef.current.stop();
|
|
57
58
|
}
|
|
58
59
|
Animated.timing(animatedProgress, {
|
|
59
|
-
toValue: progress,
|
|
60
|
-
duration: 500,
|
|
60
|
+
toValue: Math.max(progress, 80), // never go backwards
|
|
61
|
+
duration: progress >= 90 ? 500 : 300,
|
|
61
62
|
useNativeDriver: false,
|
|
62
63
|
}).start();
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// Update display value
|
|
66
66
|
const listener = animatedProgress.addListener(({ value }) => {
|
|
67
67
|
setDisplayProgress(Math.round(value));
|
|
68
68
|
});
|
|
@@ -127,6 +127,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
127
127
|
const completedChallengesRef = useRef<string[]>([]);
|
|
128
128
|
const [frames, setFrames] = useState<string[]>([]);
|
|
129
129
|
const framesRef = useRef<string[]>([]);
|
|
130
|
+
const [guidanceText, setGuidanceText] = useState<string | null>(null);
|
|
130
131
|
const [hasPermission, setHasPermission] = useState(false);
|
|
131
132
|
const cameraRef = useRef<Camera>(null);
|
|
132
133
|
const { hasPermission: cameraPermission, requestPermission } = useCameraPermission();
|
|
@@ -404,7 +405,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
404
405
|
frames: finalFrames,
|
|
405
406
|
duration: actualDuration,
|
|
406
407
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
407
|
-
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length /
|
|
408
|
+
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length / 20) * 100) : 85,
|
|
408
409
|
challengesCompleted: currentCompletedChallenges,
|
|
409
410
|
sessionId,
|
|
410
411
|
};
|
|
@@ -427,7 +428,53 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
427
428
|
framesRef.current = [];
|
|
428
429
|
let consecutiveErrors = 0;
|
|
429
430
|
const maxConsecutiveErrors = 10;
|
|
430
|
-
|
|
431
|
+
// Backend samples down to 15 anyway — 20 gives margin without OOM risk
|
|
432
|
+
const MAX_FRAMES = 20;
|
|
433
|
+
const FRAME_MAX_PX = 640;
|
|
434
|
+
const JPEG_QUALITY = 60;
|
|
435
|
+
// Quality gate: reject dark/corrupt frames (< 5KB) and abnormally large ones (> 500KB)
|
|
436
|
+
const MIN_FRAME_KB = 5;
|
|
437
|
+
const MAX_FRAME_KB = 500;
|
|
438
|
+
|
|
439
|
+
// Compress + read a photo to base64 — resize to 640px JPEG60
|
|
440
|
+
// to keep each frame at ~30-50KB instead of ~3MB raw.
|
|
441
|
+
const compressAndRead = async (photoPath: string): Promise<string | null> => {
|
|
442
|
+
const RNFS = require('react-native-fs');
|
|
443
|
+
const cleanPath = photoPath.startsWith('file://') ? photoPath.replace('file://', '') : photoPath;
|
|
444
|
+
let readPath = cleanPath;
|
|
445
|
+
|
|
446
|
+
// Try resizing with react-native-image-resizer (already a project dep)
|
|
447
|
+
try {
|
|
448
|
+
const ImageResizer = require('react-native-image-resizer').default;
|
|
449
|
+
const resized = await ImageResizer.createResizedImage(
|
|
450
|
+
photoPath,
|
|
451
|
+
FRAME_MAX_PX,
|
|
452
|
+
FRAME_MAX_PX,
|
|
453
|
+
'JPEG',
|
|
454
|
+
JPEG_QUALITY,
|
|
455
|
+
0,
|
|
456
|
+
undefined,
|
|
457
|
+
false,
|
|
458
|
+
{ mode: 'contain', onlyScaleDown: true },
|
|
459
|
+
);
|
|
460
|
+
readPath = resized.uri.startsWith('file://') ? resized.uri.replace('file://', '') : resized.uri;
|
|
461
|
+
} catch {
|
|
462
|
+
// Resizer not available or failed — fall back to raw photo
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Read with one retry (transient I/O on some Android OEMs)
|
|
466
|
+
try {
|
|
467
|
+
return await RNFS.readFile(readPath, 'base64');
|
|
468
|
+
} catch {
|
|
469
|
+
await new Promise(r => setTimeout(r, 50));
|
|
470
|
+
try {
|
|
471
|
+
return await RNFS.readFile(readPath, 'base64');
|
|
472
|
+
} catch (retryErr) {
|
|
473
|
+
logger.error('RNFS readFile failed after retry', { path: readPath, error: retryErr });
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
431
478
|
|
|
432
479
|
// Serial capture: each frame is captured only after the previous one
|
|
433
480
|
// finishes, preventing overlapping takePhoto() calls that cause the
|
|
@@ -444,25 +491,32 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
444
491
|
|
|
445
492
|
if (photo) {
|
|
446
493
|
consecutiveErrors = 0;
|
|
447
|
-
|
|
494
|
+
const base64Data = await compressAndRead(photo.path);
|
|
448
495
|
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
496
|
+
if (base64Data) {
|
|
497
|
+
const sizeKB = base64Data.length / 1024;
|
|
498
|
+
if (sizeKB >= MIN_FRAME_KB && sizeKB <= MAX_FRAME_KB) {
|
|
499
|
+
framesRef.current = [...framesRef.current, base64Data];
|
|
500
|
+
// Clear guidance when we get a good frame
|
|
501
|
+
if (guidanceText) setGuidanceText(null);
|
|
502
|
+
} else if (sizeKB < MIN_FRAME_KB) {
|
|
503
|
+
// Very small frame = dark/black image
|
|
504
|
+
setGuidanceText('Mejorá la iluminación');
|
|
505
|
+
logger.warn(`Frame rejected (too dark): ${sizeKB.toFixed(1)}KB`);
|
|
506
|
+
} else {
|
|
507
|
+
logger.warn(`Frame rejected by quality gate: ${sizeKB.toFixed(1)}KB`);
|
|
456
508
|
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
509
|
+
} else {
|
|
510
|
+
// RNFS failed — likely device I/O issue
|
|
511
|
+
setGuidanceText('Mantené el celular quieto');
|
|
460
512
|
}
|
|
461
513
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
514
|
+
// Clean up temp photo to free disk space on low-storage devices
|
|
515
|
+
try {
|
|
516
|
+
const RNFS = require('react-native-fs');
|
|
517
|
+
const cleanPath = photo.path.startsWith('file://') ? photo.path.replace('file://', '') : photo.path;
|
|
518
|
+
RNFS.unlink(cleanPath).catch(() => {});
|
|
519
|
+
} catch {}
|
|
466
520
|
}
|
|
467
521
|
} catch (error: any) {
|
|
468
522
|
consecutiveErrors++;
|
|
@@ -521,7 +575,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
521
575
|
frames: currentFrames,
|
|
522
576
|
duration: actualDuration,
|
|
523
577
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
524
|
-
qualityScore: Math.min(100, (currentFrames.length /
|
|
578
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
525
579
|
challengesCompleted: currentCompletedChallenges,
|
|
526
580
|
sessionId,
|
|
527
581
|
};
|
|
@@ -532,7 +586,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
532
586
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
533
587
|
duration: actualDuration,
|
|
534
588
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
535
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
589
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
536
590
|
challengesCompleted: currentCompletedChallenges,
|
|
537
591
|
sessionId,
|
|
538
592
|
};
|
|
@@ -569,7 +623,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
569
623
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
570
624
|
duration: actualDuration,
|
|
571
625
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
572
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
626
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
573
627
|
challengesCompleted: currentCompletedChallenges,
|
|
574
628
|
sessionId,
|
|
575
629
|
};
|
|
@@ -614,12 +668,13 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
614
668
|
} else {
|
|
615
669
|
const actualDuration = Date.now() - recordingStartTime.current;
|
|
616
670
|
const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
|
|
617
|
-
|
|
671
|
+
const currentFrames = framesRef.current.length > 0 ? framesRef.current : frames;
|
|
672
|
+
if (actualDuration >= minDurationMs && currentFrames.length > 0) {
|
|
618
673
|
const result: VideoRecordingResult = {
|
|
619
|
-
frames,
|
|
674
|
+
frames: currentFrames,
|
|
620
675
|
duration: actualDuration,
|
|
621
676
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
622
|
-
qualityScore: Math.min(100, (
|
|
677
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
623
678
|
challengesCompleted: currentCompletedChallenges,
|
|
624
679
|
sessionId,
|
|
625
680
|
};
|
|
@@ -1020,6 +1075,12 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
1020
1075
|
</>
|
|
1021
1076
|
)}
|
|
1022
1077
|
|
|
1078
|
+
{phase === 'recording' && guidanceText && (
|
|
1079
|
+
<View style={styles.guidanceContainer}>
|
|
1080
|
+
<Text style={styles.guidanceText}>{guidanceText}</Text>
|
|
1081
|
+
</View>
|
|
1082
|
+
)}
|
|
1083
|
+
|
|
1023
1084
|
{phase === 'recording' && (
|
|
1024
1085
|
<Text style={styles.bottomText}>
|
|
1025
1086
|
{strings.liveness.recordingInstructions || 'Keep your face visible and follow the instructions'}
|
|
@@ -1252,6 +1313,21 @@ const styles = StyleSheet.create({
|
|
|
1252
1313
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
1253
1314
|
alignItems: 'center',
|
|
1254
1315
|
},
|
|
1316
|
+
guidanceContainer: {
|
|
1317
|
+
backgroundColor: 'rgba(239, 68, 68, 0.85)',
|
|
1318
|
+
borderRadius: 8,
|
|
1319
|
+
paddingVertical: 8,
|
|
1320
|
+
paddingHorizontal: 16,
|
|
1321
|
+
marginHorizontal: 20,
|
|
1322
|
+
marginBottom: 8,
|
|
1323
|
+
alignItems: 'center' as const,
|
|
1324
|
+
},
|
|
1325
|
+
guidanceText: {
|
|
1326
|
+
color: '#FFFFFF',
|
|
1327
|
+
fontSize: 14,
|
|
1328
|
+
fontWeight: '600' as const,
|
|
1329
|
+
textAlign: 'center' as const,
|
|
1330
|
+
},
|
|
1255
1331
|
bottomText: {
|
|
1256
1332
|
color: '#FFFFFF',
|
|
1257
1333
|
fontSize: 16,
|
|
@@ -39,8 +39,9 @@ export const useBiometricSDK = (): UseBiometricSDKResult => {
|
|
|
39
39
|
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
isMounted.current = true;
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
let retryTimeout: NodeJS.Timeout | null = null;
|
|
43
|
+
|
|
44
|
+
const init = async (attempt = 1) => {
|
|
44
45
|
try {
|
|
45
46
|
await sdk.initialize();
|
|
46
47
|
if (isMounted.current) {
|
|
@@ -48,7 +49,13 @@ export const useBiometricSDK = (): UseBiometricSDKResult => {
|
|
|
48
49
|
setState(sdk.getState());
|
|
49
50
|
}
|
|
50
51
|
} catch (error) {
|
|
51
|
-
logger.error(
|
|
52
|
+
logger.error(`SDK initialization failed (attempt ${attempt}):`, error);
|
|
53
|
+
// Retry up to 3 times with exponential backoff (1s, 2s, 4s)
|
|
54
|
+
if (attempt < 4 && isMounted.current) {
|
|
55
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 4000);
|
|
56
|
+
logger.info(`Retrying SDK init in ${delay}ms`);
|
|
57
|
+
retryTimeout = setTimeout(() => init(attempt + 1), delay);
|
|
58
|
+
}
|
|
52
59
|
}
|
|
53
60
|
};
|
|
54
61
|
|
|
@@ -56,6 +63,7 @@ export const useBiometricSDK = (): UseBiometricSDKResult => {
|
|
|
56
63
|
|
|
57
64
|
return () => {
|
|
58
65
|
isMounted.current = false;
|
|
66
|
+
if (retryTimeout) clearTimeout(retryTimeout);
|
|
59
67
|
sdk.dispose();
|
|
60
68
|
};
|
|
61
69
|
}, [sdk]);
|