@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.
@@ -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,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;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 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,10 @@ 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
+ // 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: 90,
171
- duration: 25000, // 25 seconds
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
- setDisplayProgress(100);
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
- if (error?.message === 'NETWORK_ERROR' || (error?.message && error.message.toLowerCase().includes('network'))) {
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 (error?.message && error.message.toLowerCase().includes('timeout')) {
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 (error?.message && error.message.toLowerCase().includes('liveness')) {
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
- // 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,CAy6BtD,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,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 / 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.13.0",
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.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",
@@ -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 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');
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
- 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
- }
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('Session ID not available after attempting to generate');
134
- throw new Error('VALIDATION_ERROR');
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
- // Start animation to 90% over 25 seconds
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: 90,
184
- duration: 25000, // 25 seconds
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
- if (error?.message === 'NETWORK_ERROR' || (error?.message && error.message.toLowerCase().includes('network'))) {
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 (error?.message && error.message.toLowerCase().includes('timeout')) {
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 (error?.message && error.message.toLowerCase().includes('liveness')) {
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
- // 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,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 / 30) * 100) : 85,
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
- 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
+ };
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
- let base64Data: string | null = null;
494
+ const base64Data = await compressAndRead(photo.path);
449
495
 
450
- try {
451
- const RNFS = require('react-native-fs');
452
- // Try with original path first, then strip file:// prefix
453
- try {
454
- base64Data = await RNFS.readFile(photo.path, 'base64');
455
- } catch {
456
- 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`);
457
508
  }
458
- } catch (fsError) {
459
- logger.warn('Could not convert photo to base64, skipping frame');
460
- base64Data = null;
509
+ } else {
510
+ // RNFS failed likely device I/O issue
511
+ setGuidanceText('Mantené el celular quieto');
461
512
  }
462
513
 
463
- if (base64Data) {
464
- framesRef.current = [...framesRef.current, base64Data];
465
- setFrames(framesRef.current);
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 / 30) * 100),
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 / 30) * 100) : 0,
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 / 30) * 100) : 0,
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
- if (actualDuration >= minDurationMs && frames.length > 0) {
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, (frames.length / 30) * 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
- 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]);