@hexar/biometric-identity-sdk-react-native 1.14.0 → 1.15.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 -29
- 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 +46 -33
- 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;AAYxE,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,
|
|
1
|
+
{"version":3,"file":"ProfilePictureCapture.d.ts","sourceRoot":"","sources":["../../src/components/ProfilePictureCapture.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAYxE,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,CAsZtE,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,10 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
165
172
|
hasStartedAnimation.current = true;
|
|
166
173
|
animatedProgress.setValue(0);
|
|
167
174
|
setDisplayProgress(0);
|
|
168
|
-
//
|
|
175
|
+
// Slow cosmetic fill to 80% over 45 seconds — real progress snaps ahead when it arrives
|
|
169
176
|
animationRef.current = react_native_1.Animated.timing(animatedProgress, {
|
|
170
|
-
toValue:
|
|
171
|
-
duration:
|
|
177
|
+
toValue: 80,
|
|
178
|
+
duration: 45000,
|
|
172
179
|
useNativeDriver: false,
|
|
173
180
|
});
|
|
174
181
|
animationRef.current.start();
|
|
@@ -204,6 +211,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
204
211
|
setIsValidating(true);
|
|
205
212
|
try {
|
|
206
213
|
const result = await validateWithBackend(videoResult);
|
|
214
|
+
if (!isMountedRef.current)
|
|
215
|
+
return;
|
|
207
216
|
if (animationRef.current) {
|
|
208
217
|
animationRef.current.stop();
|
|
209
218
|
}
|
|
@@ -212,10 +221,13 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
212
221
|
duration: 500,
|
|
213
222
|
useNativeDriver: false,
|
|
214
223
|
}).start(() => {
|
|
215
|
-
|
|
224
|
+
if (isMountedRef.current)
|
|
225
|
+
setDisplayProgress(100);
|
|
216
226
|
});
|
|
217
227
|
// Small delay to show 100% before completing
|
|
218
228
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
229
|
+
if (!isMountedRef.current)
|
|
230
|
+
return;
|
|
219
231
|
if (!result.isValid) {
|
|
220
232
|
if (animationRef.current) {
|
|
221
233
|
animationRef.current.stop();
|
|
@@ -233,6 +245,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
233
245
|
onComplete(result);
|
|
234
246
|
}
|
|
235
247
|
catch (error) {
|
|
248
|
+
if (!isMountedRef.current)
|
|
249
|
+
return;
|
|
236
250
|
// Stop animation on error
|
|
237
251
|
if (animationRef.current) {
|
|
238
252
|
animationRef.current.stop();
|
|
@@ -262,15 +276,16 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
262
276
|
}
|
|
263
277
|
let errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR;
|
|
264
278
|
let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
|
|
265
|
-
|
|
279
|
+
const msg = error?.message?.toLowerCase() || '';
|
|
280
|
+
if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
|
|
266
281
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.NETWORK_ERROR;
|
|
267
282
|
errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
|
|
268
283
|
}
|
|
269
|
-
else if (
|
|
284
|
+
else if (msg.includes('timeout') || msg.includes('aborterror')) {
|
|
270
285
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.VALIDATION_TIMEOUT;
|
|
271
286
|
errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
|
|
272
287
|
}
|
|
273
|
-
else if (
|
|
288
|
+
else if (msg.includes('liveness')) {
|
|
274
289
|
errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.LIVENESS_CHECK_FAILED;
|
|
275
290
|
errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
|
|
276
291
|
}
|
|
@@ -332,7 +347,7 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
|
|
|
332
347
|
if (showFacePositioningGuide) {
|
|
333
348
|
return (react_1.default.createElement(FacePositioningGuide_1.FacePositioningGuide, { theme: theme, language: language, onContinue: () => setShowFacePositioningGuide(false), onCancel: onCancel }));
|
|
334
349
|
}
|
|
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 }));
|
|
350
|
+
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
351
|
};
|
|
337
352
|
exports.ProfilePictureCapture = ProfilePictureCapture;
|
|
338
353
|
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.15.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",
|
|
@@ -53,6 +53,13 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
53
53
|
const [displayProgress, setDisplayProgress] = useState(0);
|
|
54
54
|
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
|
|
55
55
|
const hasStartedAnimation = useRef(false);
|
|
56
|
+
const isMountedRef = useRef(true);
|
|
57
|
+
const challengeSessionIdRef = useRef<string | null>(null);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
isMountedRef.current = true;
|
|
61
|
+
return () => { isMountedRef.current = false; };
|
|
62
|
+
}, []);
|
|
56
63
|
|
|
57
64
|
const handleFetchChallenges = useCallback(async () => {
|
|
58
65
|
return await fetchChallenges('active');
|
|
@@ -83,8 +90,12 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
83
90
|
setIsLoadingChallenges(true);
|
|
84
91
|
try {
|
|
85
92
|
const challenges = await fetchChallenges('active');
|
|
93
|
+
// Capture session ID right after challenge fetch — this is the session
|
|
94
|
+
// the backend expects when we submit the video for validation.
|
|
95
|
+
challengeSessionIdRef.current = sdk.getSessionId();
|
|
86
96
|
logger.info('ProfilePictureCapture: Challenges loaded', {
|
|
87
97
|
challengeCount: challenges.length,
|
|
98
|
+
sessionId: challengeSessionIdRef.current,
|
|
88
99
|
challenges: challenges.map(c => c.action)
|
|
89
100
|
});
|
|
90
101
|
setCurrentChallenges(challenges);
|
|
@@ -106,32 +117,28 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
106
117
|
|
|
107
118
|
const validateWithBackend = useCallback(async (videoResult: VideoRecordingResult): Promise<ProfilePictureValidationResult> => {
|
|
108
119
|
try {
|
|
109
|
-
if (!isInitialized) {
|
|
110
|
-
logger.error('SDK not
|
|
111
|
-
throw
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
throw new Error('NETWORK_ERROR');
|
|
120
|
+
if (!isInitialized || !isUsingBackend) {
|
|
121
|
+
logger.error('SDK not ready', { isInitialized, isUsingBackend });
|
|
122
|
+
throw {
|
|
123
|
+
name: 'BiometricError',
|
|
124
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
125
|
+
code: BiometricErrorCode.UNKNOWN_ERROR,
|
|
126
|
+
};
|
|
117
127
|
}
|
|
118
128
|
|
|
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
|
-
}
|
|
129
|
+
// Use the session ID from the original challenge fetch (stored in ref),
|
|
130
|
+
// falling back to the video result or SDK state. NEVER generate a new
|
|
131
|
+
// challenge here — that creates a session mismatch where the backend
|
|
132
|
+
// expects challenges from the old session but receives a new one.
|
|
133
|
+
const sessionId = videoResult.sessionId || challengeSessionIdRef.current || sdk.getSessionId();
|
|
131
134
|
|
|
132
135
|
if (!sessionId) {
|
|
133
|
-
logger.error('
|
|
134
|
-
throw
|
|
136
|
+
logger.error('No session ID available — challenge was not properly loaded');
|
|
137
|
+
throw {
|
|
138
|
+
name: 'BiometricError',
|
|
139
|
+
message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
|
|
140
|
+
code: BiometricErrorCode.UNKNOWN_ERROR,
|
|
141
|
+
};
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
if (!videoResult.frames || videoResult.frames.length === 0) {
|
|
@@ -178,10 +185,10 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
178
185
|
animatedProgress.setValue(0);
|
|
179
186
|
setDisplayProgress(0);
|
|
180
187
|
|
|
181
|
-
//
|
|
188
|
+
// Slow cosmetic fill to 80% over 45 seconds — real progress snaps ahead when it arrives
|
|
182
189
|
animationRef.current = Animated.timing(animatedProgress, {
|
|
183
|
-
toValue:
|
|
184
|
-
duration:
|
|
190
|
+
toValue: 80,
|
|
191
|
+
duration: 45000,
|
|
185
192
|
useNativeDriver: false,
|
|
186
193
|
});
|
|
187
194
|
|
|
@@ -222,7 +229,9 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
222
229
|
|
|
223
230
|
try {
|
|
224
231
|
const result = await validateWithBackend(videoResult);
|
|
225
|
-
|
|
232
|
+
|
|
233
|
+
if (!isMountedRef.current) return;
|
|
234
|
+
|
|
226
235
|
if (animationRef.current) {
|
|
227
236
|
animationRef.current.stop();
|
|
228
237
|
}
|
|
@@ -231,12 +240,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
231
240
|
duration: 500,
|
|
232
241
|
useNativeDriver: false,
|
|
233
242
|
}).start(() => {
|
|
234
|
-
setDisplayProgress(100);
|
|
243
|
+
if (isMountedRef.current) setDisplayProgress(100);
|
|
235
244
|
});
|
|
236
|
-
|
|
245
|
+
|
|
237
246
|
// Small delay to show 100% before completing
|
|
238
247
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
239
|
-
|
|
248
|
+
|
|
249
|
+
if (!isMountedRef.current) return;
|
|
250
|
+
|
|
240
251
|
if (!result.isValid) {
|
|
241
252
|
if (animationRef.current) {
|
|
242
253
|
animationRef.current.stop();
|
|
@@ -254,6 +265,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
254
265
|
setIsValidating(false);
|
|
255
266
|
onComplete(result);
|
|
256
267
|
} catch (error: any) {
|
|
268
|
+
if (!isMountedRef.current) return;
|
|
257
269
|
// Stop animation on error
|
|
258
270
|
if (animationRef.current) {
|
|
259
271
|
animationRef.current.stop();
|
|
@@ -284,13 +296,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
284
296
|
let errorCode = BiometricErrorCode.UNKNOWN_ERROR;
|
|
285
297
|
let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
|
|
286
298
|
|
|
287
|
-
|
|
299
|
+
const msg = error?.message?.toLowerCase() || '';
|
|
300
|
+
if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
|
|
288
301
|
errorCode = BiometricErrorCode.NETWORK_ERROR;
|
|
289
302
|
errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
|
|
290
|
-
} else if (
|
|
303
|
+
} else if (msg.includes('timeout') || msg.includes('aborterror')) {
|
|
291
304
|
errorCode = BiometricErrorCode.VALIDATION_TIMEOUT;
|
|
292
305
|
errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
|
|
293
|
-
} else if (
|
|
306
|
+
} else if (msg.includes('liveness')) {
|
|
294
307
|
errorCode = BiometricErrorCode.LIVENESS_CHECK_FAILED;
|
|
295
308
|
errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
|
|
296
309
|
}
|
|
@@ -416,7 +429,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
|
|
|
416
429
|
language={language}
|
|
417
430
|
smartMode={true}
|
|
418
431
|
challenges={currentChallenges}
|
|
419
|
-
sessionId={sdk.getSessionId() || undefined}
|
|
432
|
+
sessionId={challengeSessionIdRef.current || sdk.getSessionId() || undefined}
|
|
420
433
|
onComplete={handleVideoComplete}
|
|
421
434
|
onCancel={handleVideoCancel}
|
|
422
435
|
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]);
|