@hexar/biometric-identity-sdk-react-native 1.13.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/CameraCapture.js +1 -1
- 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 +88 -23
- package/dist/hooks/useBiometricSDK.d.ts.map +1 -1
- package/dist/hooks/useBiometricSDK.js +11 -2
- package/package.json +2 -2
- package/src/components/CameraCapture.tsx +1 -1
- package/src/components/ProfilePictureCapture.tsx +46 -33
- package/src/components/ValidationProgress.tsx +12 -12
- package/src/components/VideoRecorder.tsx +100 -25
- package/src/hooks/useBiometricSDK.ts +11 -3
|
@@ -47,7 +47,6 @@ const { width, height } = react_native_1.Dimensions.get('window');
|
|
|
47
47
|
const CameraCapture = ({ mode, theme, language, onCapture, onCancel, }) => {
|
|
48
48
|
const [isCapturing, setIsCapturing] = (0, react_1.useState)(false);
|
|
49
49
|
const [hasPermission, setHasPermission] = (0, react_1.useState)(false);
|
|
50
|
-
const [deviceReady, setDeviceReady] = (0, react_1.useState)(false);
|
|
51
50
|
const cameraRef = (0, react_1.useRef)(null);
|
|
52
51
|
const { hasPermission: cameraPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
|
|
53
52
|
if (language) {
|
|
@@ -56,6 +55,7 @@ const CameraCapture = ({ mode, theme, language, onCapture, onCancel, }) => {
|
|
|
56
55
|
const strings = (0, biometric_identity_sdk_core_1.getStrings)();
|
|
57
56
|
// Get camera device (back camera for document capture)
|
|
58
57
|
const device = (0, react_native_vision_camera_1.useCameraDevice)('back');
|
|
58
|
+
const [deviceReady, setDeviceReady] = (0, react_1.useState)(!!device);
|
|
59
59
|
(0, react_1.useEffect)(() => {
|
|
60
60
|
checkPermissions();
|
|
61
61
|
}, []);
|
|
@@ -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,11 +99,12 @@ 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
|
-
const [deviceReady, setDeviceReady] = (0, react_1.useState)(false);
|
|
104
104
|
const cameraRef = (0, react_1.useRef)(null);
|
|
105
105
|
const { hasPermission: cameraPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
|
|
106
106
|
const device = (0, react_native_vision_camera_1.useCameraDevice)('front');
|
|
107
|
+
const [deviceReady, setDeviceReady] = (0, react_1.useState)(!!device);
|
|
107
108
|
const fadeAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
108
109
|
const scaleAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
109
110
|
const pulseAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
@@ -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",
|
|
@@ -37,7 +37,6 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
37
37
|
}) => {
|
|
38
38
|
const [isCapturing, setIsCapturing] = useState(false);
|
|
39
39
|
const [hasPermission, setHasPermission] = useState(false);
|
|
40
|
-
const [deviceReady, setDeviceReady] = useState(false);
|
|
41
40
|
const cameraRef = useRef<Camera>(null);
|
|
42
41
|
const { hasPermission: cameraPermission, requestPermission } = useCameraPermission();
|
|
43
42
|
|
|
@@ -48,6 +47,7 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
|
|
|
48
47
|
|
|
49
48
|
// Get camera device (back camera for document capture)
|
|
50
49
|
const device = useCameraDevice('back');
|
|
50
|
+
const [deviceReady, setDeviceReady] = useState(!!device);
|
|
51
51
|
|
|
52
52
|
useEffect(() => {
|
|
53
53
|
checkPermissions();
|
|
@@ -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,12 +127,12 @@ 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
|
-
const [deviceReady, setDeviceReady] = useState(false);
|
|
132
|
-
|
|
133
132
|
const cameraRef = useRef<Camera>(null);
|
|
134
133
|
const { hasPermission: cameraPermission, requestPermission } = useCameraPermission();
|
|
135
134
|
const device = useCameraDevice('front');
|
|
135
|
+
const [deviceReady, setDeviceReady] = useState(!!device);
|
|
136
136
|
|
|
137
137
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
138
138
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
|
@@ -405,7 +405,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
405
405
|
frames: finalFrames,
|
|
406
406
|
duration: actualDuration,
|
|
407
407
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
408
|
-
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length /
|
|
408
|
+
qualityScore: finalFrames.length > 0 ? Math.min(100, (finalFrames.length / 20) * 100) : 85,
|
|
409
409
|
challengesCompleted: currentCompletedChallenges,
|
|
410
410
|
sessionId,
|
|
411
411
|
};
|
|
@@ -428,7 +428,53 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
428
428
|
framesRef.current = [];
|
|
429
429
|
let consecutiveErrors = 0;
|
|
430
430
|
const maxConsecutiveErrors = 10;
|
|
431
|
-
|
|
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
|
+
};
|
|
432
478
|
|
|
433
479
|
// Serial capture: each frame is captured only after the previous one
|
|
434
480
|
// finishes, preventing overlapping takePhoto() calls that cause the
|
|
@@ -445,25 +491,32 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
445
491
|
|
|
446
492
|
if (photo) {
|
|
447
493
|
consecutiveErrors = 0;
|
|
448
|
-
|
|
494
|
+
const base64Data = await compressAndRead(photo.path);
|
|
449
495
|
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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`);
|
|
457
508
|
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
509
|
+
} else {
|
|
510
|
+
// RNFS failed — likely device I/O issue
|
|
511
|
+
setGuidanceText('Mantené el celular quieto');
|
|
461
512
|
}
|
|
462
513
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 {}
|
|
467
520
|
}
|
|
468
521
|
} catch (error: any) {
|
|
469
522
|
consecutiveErrors++;
|
|
@@ -522,7 +575,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
522
575
|
frames: currentFrames,
|
|
523
576
|
duration: actualDuration,
|
|
524
577
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
525
|
-
qualityScore: Math.min(100, (currentFrames.length /
|
|
578
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
526
579
|
challengesCompleted: currentCompletedChallenges,
|
|
527
580
|
sessionId,
|
|
528
581
|
};
|
|
@@ -533,7 +586,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
533
586
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
534
587
|
duration: actualDuration,
|
|
535
588
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
536
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
589
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
537
590
|
challengesCompleted: currentCompletedChallenges,
|
|
538
591
|
sessionId,
|
|
539
592
|
};
|
|
@@ -570,7 +623,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
570
623
|
frames: currentFrames.length > 0 ? currentFrames : [],
|
|
571
624
|
duration: actualDuration,
|
|
572
625
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
573
|
-
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length /
|
|
626
|
+
qualityScore: currentFrames.length > 0 ? Math.min(100, (currentFrames.length / 20) * 100) : 0,
|
|
574
627
|
challengesCompleted: currentCompletedChallenges,
|
|
575
628
|
sessionId,
|
|
576
629
|
};
|
|
@@ -615,12 +668,13 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
615
668
|
} else {
|
|
616
669
|
const actualDuration = Date.now() - recordingStartTime.current;
|
|
617
670
|
const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
|
|
618
|
-
|
|
671
|
+
const currentFrames = framesRef.current.length > 0 ? framesRef.current : frames;
|
|
672
|
+
if (actualDuration >= minDurationMs && currentFrames.length > 0) {
|
|
619
673
|
const result: VideoRecordingResult = {
|
|
620
|
-
frames,
|
|
674
|
+
frames: currentFrames,
|
|
621
675
|
duration: actualDuration,
|
|
622
676
|
instructionsFollowed: currentCompletedChallenges.length === challenges.length,
|
|
623
|
-
qualityScore: Math.min(100, (
|
|
677
|
+
qualityScore: Math.min(100, (currentFrames.length / 20) * 100),
|
|
624
678
|
challengesCompleted: currentCompletedChallenges,
|
|
625
679
|
sessionId,
|
|
626
680
|
};
|
|
@@ -1021,6 +1075,12 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
1021
1075
|
</>
|
|
1022
1076
|
)}
|
|
1023
1077
|
|
|
1078
|
+
{phase === 'recording' && guidanceText && (
|
|
1079
|
+
<View style={styles.guidanceContainer}>
|
|
1080
|
+
<Text style={styles.guidanceText}>{guidanceText}</Text>
|
|
1081
|
+
</View>
|
|
1082
|
+
)}
|
|
1083
|
+
|
|
1024
1084
|
{phase === 'recording' && (
|
|
1025
1085
|
<Text style={styles.bottomText}>
|
|
1026
1086
|
{strings.liveness.recordingInstructions || 'Keep your face visible and follow the instructions'}
|
|
@@ -1253,6 +1313,21 @@ const styles = StyleSheet.create({
|
|
|
1253
1313
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
1254
1314
|
alignItems: 'center',
|
|
1255
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
|
+
},
|
|
1256
1331
|
bottomText: {
|
|
1257
1332
|
color: '#FFFFFF',
|
|
1258
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]);
|