@hexar/biometric-identity-sdk-react-native 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,CAyYtE,CAAC;AA4EF,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"ProfilePictureCapture.d.ts","sourceRoot":"","sources":["../../src/components/ProfilePictureCapture.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAaxE,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAmC,cAAc,EAAsB,MAAM,oCAAoC,CAAC;AAGzJ,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,CAAC,MAAM,EAAE,8BAA8B,KAAK,IAAI,CAAC;IAC7D,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAuZtE,CAAC;AA4EF,eAAe,qBAAqB,CAAC"}
@@ -51,6 +51,12 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
51
51
  const [displayProgress, setDisplayProgress] = (0, react_1.useState)(0);
52
52
  const animationRef = (0, react_1.useRef)(null);
53
53
  const hasStartedAnimation = (0, react_1.useRef)(false);
54
+ const isMountedRef = (0, react_1.useRef)(true);
55
+ const challengeSessionIdRef = (0, react_1.useRef)(null);
56
+ (0, react_1.useEffect)(() => {
57
+ isMountedRef.current = true;
58
+ return () => { isMountedRef.current = false; };
59
+ }, []);
54
60
  const handleFetchChallenges = (0, react_1.useCallback)(async () => {
55
61
  return await fetchChallenges('active');
56
62
  }, [fetchChallenges]);
@@ -78,8 +84,12 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
78
84
  setIsLoadingChallenges(true);
79
85
  try {
80
86
  const challenges = await fetchChallenges('active');
87
+ // Capture session ID right after challenge fetch — this is the session
88
+ // the backend expects when we submit the video for validation.
89
+ challengeSessionIdRef.current = sdk.getSessionId();
81
90
  biometric_identity_sdk_core_1.logger.info('ProfilePictureCapture: Challenges loaded', {
82
91
  challengeCount: challenges.length,
92
+ sessionId: challengeSessionIdRef.current,
83
93
  challenges: challenges.map(c => c.action)
84
94
  });
85
95
  setCurrentChallenges(challenges);
@@ -101,29 +111,26 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
101
111
  const strings = (0, biometric_identity_sdk_core_1.getStrings)();
102
112
  const validateWithBackend = (0, react_1.useCallback)(async (videoResult) => {
103
113
  try {
104
- if (!isInitialized) {
105
- biometric_identity_sdk_core_1.logger.error('SDK not initialized');
106
- throw new Error('NETWORK_ERROR');
107
- }
108
- if (!isUsingBackend) {
109
- biometric_identity_sdk_core_1.logger.error('Backend not available - SDK not configured with backend');
110
- throw new Error('NETWORK_ERROR');
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('Session ID not available after attempting to generate');
126
- throw new Error('VALIDATION_ERROR');
128
+ biometric_identity_sdk_core_1.logger.error('No session ID available challenge was not properly loaded');
129
+ throw {
130
+ name: 'BiometricError',
131
+ message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
132
+ code: biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR,
133
+ };
127
134
  }
128
135
  if (!videoResult.frames || videoResult.frames.length === 0) {
129
136
  throw new Error('No video frames available for validation');
@@ -165,10 +172,11 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
165
172
  hasStartedAnimation.current = true;
166
173
  animatedProgress.setValue(0);
167
174
  setDisplayProgress(0);
168
- // Start animation to 90% over 25 seconds
175
+ // Steady linear fill to 90% over 35 seconds — feels like real progress
169
176
  animationRef.current = react_native_1.Animated.timing(animatedProgress, {
170
177
  toValue: 90,
171
- duration: 25000, // 25 seconds
178
+ duration: 35000,
179
+ easing: react_native_1.Easing.linear,
172
180
  useNativeDriver: false,
173
181
  });
174
182
  animationRef.current.start();
@@ -204,6 +212,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
204
212
  setIsValidating(true);
205
213
  try {
206
214
  const result = await validateWithBackend(videoResult);
215
+ if (!isMountedRef.current)
216
+ return;
207
217
  if (animationRef.current) {
208
218
  animationRef.current.stop();
209
219
  }
@@ -212,10 +222,13 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
212
222
  duration: 500,
213
223
  useNativeDriver: false,
214
224
  }).start(() => {
215
- setDisplayProgress(100);
225
+ if (isMountedRef.current)
226
+ setDisplayProgress(100);
216
227
  });
217
228
  // Small delay to show 100% before completing
218
229
  await new Promise(resolve => setTimeout(resolve, 500));
230
+ if (!isMountedRef.current)
231
+ return;
219
232
  if (!result.isValid) {
220
233
  if (animationRef.current) {
221
234
  animationRef.current.stop();
@@ -233,6 +246,8 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
233
246
  onComplete(result);
234
247
  }
235
248
  catch (error) {
249
+ if (!isMountedRef.current)
250
+ return;
236
251
  // Stop animation on error
237
252
  if (animationRef.current) {
238
253
  animationRef.current.stop();
@@ -262,15 +277,16 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
262
277
  }
263
278
  let errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.UNKNOWN_ERROR;
264
279
  let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
265
- if (error?.message === 'NETWORK_ERROR' || (error?.message && error.message.toLowerCase().includes('network'))) {
280
+ const msg = error?.message?.toLowerCase() || '';
281
+ if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
266
282
  errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.NETWORK_ERROR;
267
283
  errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
268
284
  }
269
- else if (error?.message && error.message.toLowerCase().includes('timeout')) {
285
+ else if (msg.includes('timeout') || msg.includes('aborterror')) {
270
286
  errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.VALIDATION_TIMEOUT;
271
287
  errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
272
288
  }
273
- else if (error?.message && error.message.toLowerCase().includes('liveness')) {
289
+ else if (msg.includes('liveness')) {
274
290
  errorCode = biometric_identity_sdk_core_1.BiometricErrorCode.LIVENESS_CHECK_FAILED;
275
291
  errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
276
292
  }
@@ -332,7 +348,7 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
332
348
  if (showFacePositioningGuide) {
333
349
  return (react_1.default.createElement(FacePositioningGuide_1.FacePositioningGuide, { theme: theme, language: language, onContinue: () => setShowFacePositioningGuide(false), onCancel: onCancel }));
334
350
  }
335
- return (react_1.default.createElement(VideoRecorder_1.VideoRecorder, { theme: theme, language: language, smartMode: true, challenges: currentChallenges, sessionId: sdk.getSessionId() || undefined, onComplete: handleVideoComplete, onCancel: handleVideoCancel, onFetchChallenges: handleFetchChallenges }));
351
+ return (react_1.default.createElement(VideoRecorder_1.VideoRecorder, { theme: theme, language: language, smartMode: true, challenges: currentChallenges, sessionId: challengeSessionIdRef.current || sdk.getSessionId() || undefined, onComplete: handleVideoComplete, onCancel: handleVideoCancel, onFetchChallenges: handleFetchChallenges }));
336
352
  };
337
353
  exports.ProfilePictureCapture = ProfilePictureCapture;
338
354
  const styles = react_native_1.StyleSheet.create({
@@ -52,29 +52,30 @@ const ValidationProgress = ({ progress, theme, language = 'en', }) => {
52
52
  }
53
53
  }, [language]);
54
54
  (0, react_1.useEffect)(() => {
55
- // Start animation from 0 to 90% over 60 seconds (1 minute) - only once
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
- // Start animation to 90% over 35 seconds, regardless of backend progress
60
+ // Slow fill to 80% over 60 seconds purely cosmetic
59
61
  animationRef.current = react_native_1.Animated.timing(animatedProgress, {
60
- toValue: 90,
61
- duration: 35000, // 35 seconds
62
+ toValue: 80,
63
+ duration: 60000,
62
64
  useNativeDriver: false,
63
65
  });
64
66
  animationRef.current.start();
65
67
  }
66
- // Only update to actual progress if it's 90% or more (validation complete)
67
- if (progress >= 90) {
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,CAw6BtD,CAAC;AA6OF,eAAe,aAAa,CAAC"}
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 / 30) * 100) : 85,
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
- const MAX_FRAMES = 30;
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
- let base64Data = null;
376
- try {
377
- const RNFS = require('react-native-fs');
378
- // Try with original path first, then strip file:// prefix
379
- try {
380
- base64Data = await RNFS.readFile(photo.path, 'base64');
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
- catch {
383
- base64Data = await RNFS.readFile(photo.path.replace('file://', ''), 'base64');
426
+ else {
427
+ biometric_identity_sdk_core_1.logger.warn(`Frame rejected by quality gate: ${sizeKB.toFixed(1)}KB`);
384
428
  }
385
429
  }
386
- catch (fsError) {
387
- biometric_identity_sdk_core_1.logger.warn('Could not convert photo to base64, skipping frame');
388
- base64Data = null;
430
+ else {
431
+ // RNFS failed likely device I/O issue
432
+ setGuidanceText('Mantené el celular quieto');
389
433
  }
390
- if (base64Data) {
391
- framesRef.current = [...framesRef.current, base64Data];
392
- setFrames(framesRef.current);
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 / 30) * 100),
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 / 30) * 100) : 0,
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 / 30) * 100) : 0,
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
- if (actualDuration >= minDurationMs && frames.length > 0) {
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, (frames.length / 30) * 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,qBA2QlC,CAAC;AAEF,eAAe,eAAe,CAAC"}
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
- const init = async () => {
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('SDK initialization failed:', 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.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "React Native wrapper for Biometric Identity SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "clean": "rm -rf dist"
12
12
  },
13
13
  "peerDependencies": {
14
- "@hexar/biometric-identity-sdk-core": ">=1.7.0",
14
+ "@hexar/biometric-identity-sdk-core": ">=1.8.0",
15
15
  "react": ">=18.0.0",
16
16
  "react-native": ">=0.70.0",
17
17
  "react-native-fs": ">=2.20.0",
@@ -6,6 +6,7 @@ import {
6
6
  ActivityIndicator,
7
7
  SafeAreaView,
8
8
  Animated,
9
+ Easing,
9
10
  TouchableOpacity,
10
11
  } from 'react-native';
11
12
  import { VideoRecorder, VideoRecordingResult } from './VideoRecorder';
@@ -53,6 +54,13 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
53
54
  const [displayProgress, setDisplayProgress] = useState(0);
54
55
  const animationRef = useRef<Animated.CompositeAnimation | null>(null);
55
56
  const hasStartedAnimation = useRef(false);
57
+ const isMountedRef = useRef(true);
58
+ const challengeSessionIdRef = useRef<string | null>(null);
59
+
60
+ useEffect(() => {
61
+ isMountedRef.current = true;
62
+ return () => { isMountedRef.current = false; };
63
+ }, []);
56
64
 
57
65
  const handleFetchChallenges = useCallback(async () => {
58
66
  return await fetchChallenges('active');
@@ -83,8 +91,12 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
83
91
  setIsLoadingChallenges(true);
84
92
  try {
85
93
  const challenges = await fetchChallenges('active');
94
+ // Capture session ID right after challenge fetch — this is the session
95
+ // the backend expects when we submit the video for validation.
96
+ challengeSessionIdRef.current = sdk.getSessionId();
86
97
  logger.info('ProfilePictureCapture: Challenges loaded', {
87
98
  challengeCount: challenges.length,
99
+ sessionId: challengeSessionIdRef.current,
88
100
  challenges: challenges.map(c => c.action)
89
101
  });
90
102
  setCurrentChallenges(challenges);
@@ -106,32 +118,28 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
106
118
 
107
119
  const validateWithBackend = useCallback(async (videoResult: VideoRecordingResult): Promise<ProfilePictureValidationResult> => {
108
120
  try {
109
- if (!isInitialized) {
110
- logger.error('SDK not initialized');
111
- throw new Error('NETWORK_ERROR');
112
- }
113
-
114
- if (!isUsingBackend) {
115
- logger.error('Backend not available - SDK not configured with backend');
116
- throw new Error('NETWORK_ERROR');
121
+ if (!isInitialized || !isUsingBackend) {
122
+ logger.error('SDK not ready', { isInitialized, isUsingBackend });
123
+ throw {
124
+ name: 'BiometricError',
125
+ message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
126
+ code: BiometricErrorCode.UNKNOWN_ERROR,
127
+ };
117
128
  }
118
129
 
119
- let sessionId = videoResult.sessionId || sdk.getSessionId();
120
-
121
- if (!sessionId) {
122
- logger.info('No session ID available, generating challenge to create one');
123
- try {
124
- const challengeResponse = await sdk.generateLivenessChallenge('active');
125
- sessionId = challengeResponse.session_id;
126
- logger.info('Session ID generated', { sessionId });
127
- } catch (challengeError) {
128
- logger.error('Failed to generate challenge for session ID', challengeError);
129
- }
130
- }
130
+ // Use the session ID from the original challenge fetch (stored in ref),
131
+ // falling back to the video result or SDK state. NEVER generate a new
132
+ // challenge here — that creates a session mismatch where the backend
133
+ // expects challenges from the old session but receives a new one.
134
+ const sessionId = videoResult.sessionId || challengeSessionIdRef.current || sdk.getSessionId();
131
135
 
132
136
  if (!sessionId) {
133
- logger.error('Session ID not available after attempting to generate');
134
- throw new Error('VALIDATION_ERROR');
137
+ logger.error('No session ID available challenge was not properly loaded');
138
+ throw {
139
+ name: 'BiometricError',
140
+ message: strings.errors.unknownError || 'An unexpected error occurred. Please try again.',
141
+ code: BiometricErrorCode.UNKNOWN_ERROR,
142
+ };
135
143
  }
136
144
 
137
145
  if (!videoResult.frames || videoResult.frames.length === 0) {
@@ -178,10 +186,11 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
178
186
  animatedProgress.setValue(0);
179
187
  setDisplayProgress(0);
180
188
 
181
- // Start animation to 90% over 25 seconds
189
+ // Steady linear fill to 90% over 35 seconds — feels like real progress
182
190
  animationRef.current = Animated.timing(animatedProgress, {
183
191
  toValue: 90,
184
- duration: 25000, // 25 seconds
192
+ duration: 35000,
193
+ easing: Easing.linear,
185
194
  useNativeDriver: false,
186
195
  });
187
196
 
@@ -222,7 +231,9 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
222
231
 
223
232
  try {
224
233
  const result = await validateWithBackend(videoResult);
225
-
234
+
235
+ if (!isMountedRef.current) return;
236
+
226
237
  if (animationRef.current) {
227
238
  animationRef.current.stop();
228
239
  }
@@ -231,12 +242,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
231
242
  duration: 500,
232
243
  useNativeDriver: false,
233
244
  }).start(() => {
234
- setDisplayProgress(100);
245
+ if (isMountedRef.current) setDisplayProgress(100);
235
246
  });
236
-
247
+
237
248
  // Small delay to show 100% before completing
238
249
  await new Promise(resolve => setTimeout(resolve, 500));
239
-
250
+
251
+ if (!isMountedRef.current) return;
252
+
240
253
  if (!result.isValid) {
241
254
  if (animationRef.current) {
242
255
  animationRef.current.stop();
@@ -254,6 +267,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
254
267
  setIsValidating(false);
255
268
  onComplete(result);
256
269
  } catch (error: any) {
270
+ if (!isMountedRef.current) return;
257
271
  // Stop animation on error
258
272
  if (animationRef.current) {
259
273
  animationRef.current.stop();
@@ -284,13 +298,14 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
284
298
  let errorCode = BiometricErrorCode.UNKNOWN_ERROR;
285
299
  let errorMessage = strings.errors.unknownError || 'An unexpected error occurred.';
286
300
 
287
- if (error?.message === 'NETWORK_ERROR' || (error?.message && error.message.toLowerCase().includes('network'))) {
301
+ const msg = error?.message?.toLowerCase() || '';
302
+ if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
288
303
  errorCode = BiometricErrorCode.NETWORK_ERROR;
289
304
  errorMessage = strings.errors.networkError || 'Network error. Please check your connection.';
290
- } else if (error?.message && error.message.toLowerCase().includes('timeout')) {
305
+ } else if (msg.includes('timeout') || msg.includes('aborterror')) {
291
306
  errorCode = BiometricErrorCode.VALIDATION_TIMEOUT;
292
307
  errorMessage = strings.errors.timeout || 'Timeout. Please try again.';
293
- } else if (error?.message && error.message.toLowerCase().includes('liveness')) {
308
+ } else if (msg.includes('liveness')) {
294
309
  errorCode = BiometricErrorCode.LIVENESS_CHECK_FAILED;
295
310
  errorMessage = strings.errors.livenessCheckFailed || 'Liveness check failed. Please try again.';
296
311
  }
@@ -416,7 +431,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
416
431
  language={language}
417
432
  smartMode={true}
418
433
  challenges={currentChallenges}
419
- sessionId={sdk.getSessionId() || undefined}
434
+ sessionId={challengeSessionIdRef.current || sdk.getSessionId() || undefined}
420
435
  onComplete={handleVideoComplete}
421
436
  onCancel={handleVideoCancel}
422
437
  onFetchChallenges={handleFetchChallenges}
@@ -36,33 +36,33 @@ export const ValidationProgress: React.FC<ValidationProgressProps> = ({
36
36
  }, [language]);
37
37
 
38
38
  useEffect(() => {
39
- // Start animation from 0 to 90% over 60 seconds (1 minute) - only once
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
- // Start animation to 90% over 35 seconds, regardless of backend progress
44
+
45
+ // Slow fill to 80% over 60 seconds purely cosmetic
44
46
  animationRef.current = Animated.timing(animatedProgress, {
45
- toValue: 90,
46
- duration: 35000, // 35 seconds
47
+ toValue: 80,
48
+ duration: 60000,
47
49
  useNativeDriver: false,
48
50
  });
49
-
50
51
  animationRef.current.start();
51
52
  }
52
-
53
- // Only update to actual progress if it's 90% or more (validation complete)
54
- if (progress >= 90) {
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 / 30) * 100) : 85,
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
- const MAX_FRAMES = 30;
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
- let base64Data: string | null = null;
494
+ const base64Data = await compressAndRead(photo.path);
448
495
 
449
- try {
450
- const RNFS = require('react-native-fs');
451
- // Try with original path first, then strip file:// prefix
452
- try {
453
- base64Data = await RNFS.readFile(photo.path, 'base64');
454
- } catch {
455
- base64Data = await RNFS.readFile(photo.path.replace('file://', ''), 'base64');
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
- } catch (fsError) {
458
- logger.warn('Could not convert photo to base64, skipping frame');
459
- base64Data = null;
509
+ } else {
510
+ // RNFS failed likely device I/O issue
511
+ setGuidanceText('Mantené el celular quieto');
460
512
  }
461
513
 
462
- if (base64Data) {
463
- framesRef.current = [...framesRef.current, base64Data];
464
- setFrames(framesRef.current);
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 / 30) * 100),
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 / 30) * 100) : 0,
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 / 30) * 100) : 0,
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
- if (actualDuration >= minDurationMs && frames.length > 0) {
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, (frames.length / 30) * 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
- const init = async () => {
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('SDK initialization failed:', 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]);