@hexar/biometric-identity-sdk-react-native 1.9.0 → 1.11.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":"CameraCapture.d.ts","sourceRoot":"","sources":["../../src/components/CameraCapture.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAsC,MAAM,OAAO,CAAC;AAY3D,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAIrH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAgQtD,CAAC;AA0JF,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"CameraCapture.d.ts","sourceRoot":"","sources":["../../src/components/CameraCapture.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAsC,MAAM,OAAO,CAAC;AAY3D,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAIrH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA8OtD,CAAC;AA0JF,eAAe,aAAa,CAAC"}
@@ -129,36 +129,19 @@ const CameraCapture = ({ mode, theme, language, onCapture, onCancel, }) => {
129
129
  // Convert to base64
130
130
  try {
131
131
  const RNFS = require('react-native-fs');
132
- const base64 = await RNFS.readFile(processedPath.replace('file://', ''), 'base64');
133
- const sizeKB = Math.round(base64.length / 1024);
134
- biometric_identity_sdk_core_1.logger.info('Image converted to base64:', { sizeKB, path: processedPath });
135
- onCapture(base64);
136
- }
137
- catch (fsError) {
138
- // Fallback: try fetch method
132
+ // Try with original path first, then strip file:// prefix
133
+ let base64;
139
134
  try {
140
- const fileUri = react_native_1.Platform.OS === 'android' ? `file://${processedPath}` : processedPath;
141
- const response = await fetch(fileUri);
142
- const blob = await response.blob();
143
- const reader = new FileReader();
144
- reader.onloadend = () => {
145
- const base64data = reader.result;
146
- const base64 = base64data.includes(',')
147
- ? base64data.split(',')[1]
148
- : base64data;
149
- onCapture(base64);
150
- setIsCapturing(false);
151
- };
152
- reader.onerror = () => {
153
- throw new Error('Failed to read photo file');
154
- };
155
- reader.readAsDataURL(blob);
156
- return;
135
+ base64 = await RNFS.readFile(processedPath, 'base64');
157
136
  }
158
- catch (fetchError) {
159
- biometric_identity_sdk_core_1.logger.warn('Could not convert to base64, using file path:', processedPath);
160
- onCapture(processedPath);
137
+ catch {
138
+ base64 = await RNFS.readFile(processedPath.replace('file://', ''), 'base64');
161
139
  }
140
+ onCapture(base64);
141
+ }
142
+ catch (fsError) {
143
+ biometric_identity_sdk_core_1.logger.warn('Could not convert to base64, using file path:', processedPath);
144
+ onCapture(processedPath);
162
145
  }
163
146
  }
164
147
  catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"ProfilePictureCapture.d.ts","sourceRoot":"","sources":["../../src/components/ProfilePictureCapture.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAWxE,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,CAkWtE,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,CAyYtE,CAAC;AA4EF,eAAe,qBAAqB,CAAC"}
@@ -46,6 +46,7 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
46
46
  const [isValidating, setIsValidating] = (0, react_1.useState)(false);
47
47
  const [currentChallenges, setCurrentChallenges] = (0, react_1.useState)([]);
48
48
  const [isLoadingChallenges, setIsLoadingChallenges] = (0, react_1.useState)(false);
49
+ const [loadingTimedOut, setLoadingTimedOut] = (0, react_1.useState)(false);
49
50
  const animatedProgress = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
50
51
  const [displayProgress, setDisplayProgress] = (0, react_1.useState)(0);
51
52
  const animationRef = (0, react_1.useRef)(null);
@@ -58,6 +59,19 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
58
59
  (0, biometric_identity_sdk_core_1.setLanguage)(language);
59
60
  }
60
61
  }, [language]);
62
+ // 15-second timeout on initial loading (challenge fetch / SDK init)
63
+ (0, react_1.useEffect)(() => {
64
+ const isLoading = !isInitialized || (isUsingBackend && isLoadingChallenges);
65
+ if (isLoading) {
66
+ const timer = setTimeout(() => {
67
+ setLoadingTimedOut(true);
68
+ }, 15000);
69
+ return () => clearTimeout(timer);
70
+ }
71
+ else {
72
+ setLoadingTimedOut(false);
73
+ }
74
+ }, [isInitialized, isUsingBackend, isLoadingChallenges]);
61
75
  (0, react_1.useEffect)(() => {
62
76
  if (isInitialized && isUsingBackend) {
63
77
  const loadChallenges = async () => {
@@ -151,10 +165,10 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
151
165
  hasStartedAnimation.current = true;
152
166
  animatedProgress.setValue(0);
153
167
  setDisplayProgress(0);
154
- // Start animation to 90% over 60 seconds
168
+ // Start animation to 90% over 25 seconds
155
169
  animationRef.current = react_native_1.Animated.timing(animatedProgress, {
156
170
  toValue: 90,
157
- duration: 60000, // 60 seconds
171
+ duration: 25000, // 25 seconds
158
172
  useNativeDriver: false,
159
173
  });
160
174
  animationRef.current.start();
@@ -301,6 +315,15 @@ const ProfilePictureCapture = ({ onComplete, onError, onCancel, theme, language,
301
315
  }
302
316
  // Wait for initialization and challenge loading before showing guide or VideoRecorder
303
317
  if (!isInitialized || (isUsingBackend && isLoadingChallenges)) {
318
+ if (loadingTimedOut) {
319
+ return (react_1.default.createElement(react_native_1.SafeAreaView, { style: [styles.container, { backgroundColor: theme?.backgroundColor || '#FFFFFF' }] },
320
+ react_1.default.createElement(react_native_1.View, { style: styles.loadingContainer },
321
+ react_1.default.createElement(react_native_1.Text, { style: { fontSize: 48, marginBottom: 16 } }, "\u23F3"),
322
+ react_1.default.createElement(react_native_1.Text, { style: [styles.loadingText, { color: theme?.textColor || '#1e1b4b', fontSize: 20, fontWeight: 'bold' }] }, strings.errors.systemBusy),
323
+ react_1.default.createElement(react_native_1.Text, { style: { fontSize: 15, color: theme?.secondaryTextColor || '#64748b', textAlign: 'center', marginTop: 12, paddingHorizontal: 24, lineHeight: 22 } }, strings.errors.systemBusyMessage),
324
+ onCancel && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: { backgroundColor: theme?.primaryColor || '#4f46e5', paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12, marginTop: 32 }, onPress: onCancel },
325
+ react_1.default.createElement(react_native_1.Text, { style: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' } }, strings.common?.back || 'Go back'))))));
326
+ }
304
327
  return (react_1.default.createElement(react_native_1.SafeAreaView, { style: [styles.container, { backgroundColor: theme?.backgroundColor || '#FFFFFF' }] },
305
328
  react_1.default.createElement(react_native_1.View, { style: styles.loadingContainer },
306
329
  react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: theme?.primaryColor || '#4f46e5' }),
@@ -55,10 +55,10 @@ const ValidationProgress = ({ progress, theme, language = 'en', }) => {
55
55
  // Start animation from 0 to 90% over 60 seconds (1 minute) - only once
56
56
  if (!hasStartedAnimation.current) {
57
57
  hasStartedAnimation.current = true;
58
- // Start animation to 90% over 70 seconds, regardless of backend progress
58
+ // Start animation to 90% over 35 seconds, regardless of backend progress
59
59
  animationRef.current = react_native_1.Animated.timing(animatedProgress, {
60
60
  toValue: 90,
61
- duration: 70000, // 70 seconds
61
+ duration: 35000, // 35 seconds
62
62
  useNativeDriver: false,
63
63
  });
64
64
  animationRef.current.start();
@@ -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;AAaxE,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,CAm6BtD,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;AAaxE,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,CAg5BtD,CAAC;AA6OF,eAAe,aAAa,CAAC"}
@@ -347,7 +347,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
347
347
  framesRef.current = [];
348
348
  let consecutiveErrors = 0;
349
349
  const maxConsecutiveErrors = 10;
350
- const MAX_FRAMES = 150;
350
+ const MAX_FRAMES = 30;
351
351
  // Serial capture: each frame is captured only after the previous one
352
352
  // finishes, preventing overlapping takePhoto() calls that cause the
353
353
  // camera to throw "busy" errors and kill the capture loop.
@@ -364,41 +364,21 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
364
364
  let base64Data = null;
365
365
  try {
366
366
  const RNFS = require('react-native-fs');
367
- base64Data = await RNFS.readFile(photo.path, 'base64');
368
- }
369
- catch (fsError) {
367
+ // Try with original path first, then strip file:// prefix
370
368
  try {
371
- const fileUri = react_native_1.Platform.OS === 'android' ? `file://${photo.path}` : photo.path;
372
- const response = await fetch(fileUri);
373
- const blob = await response.blob();
374
- base64Data = await new Promise((resolve, reject) => {
375
- const reader = new FileReader();
376
- reader.onloadend = () => {
377
- const result = reader.result;
378
- const base64 = result.includes(',') ? result.split(',')[1] : result;
379
- resolve(base64);
380
- };
381
- reader.onerror = reject;
382
- reader.readAsDataURL(blob);
383
- });
369
+ base64Data = await RNFS.readFile(photo.path, 'base64');
384
370
  }
385
- catch (fetchError) {
386
- biometric_identity_sdk_core_1.logger.error('Failed to read photo file as base64:', fetchError);
387
- base64Data = null;
371
+ catch {
372
+ base64Data = await RNFS.readFile(photo.path.replace('file://', ''), 'base64');
388
373
  }
389
374
  }
390
- if (base64Data) {
391
- setFrames(prev => {
392
- const newFrames = prev.length < MAX_FRAMES ? [...prev, base64Data] : prev;
393
- framesRef.current = newFrames;
394
- if (newFrames.length % 10 === 0) {
395
- biometric_identity_sdk_core_1.logger.info('Captured frames:', newFrames.length);
396
- }
397
- return newFrames;
398
- });
399
- }
400
- else {
375
+ catch (fsError) {
401
376
  biometric_identity_sdk_core_1.logger.warn('Could not convert photo to base64, skipping frame');
377
+ base64Data = null;
378
+ }
379
+ if (base64Data) {
380
+ framesRef.current = [...framesRef.current, base64Data];
381
+ setFrames(framesRef.current);
402
382
  }
403
383
  }
404
384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexar/biometric-identity-sdk-react-native",
3
- "version": "1.9.0",
3
+ "version": "1.11.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.4.0",
14
+ "@hexar/biometric-identity-sdk-core": ">=1.7.0",
15
15
  "react": ">=18.0.0",
16
16
  "react-native": ">=0.70.0",
17
17
  "react-native-permissions": ">=4.0.0",
@@ -133,35 +133,17 @@ export const CameraCapture: React.FC<CameraCaptureProps> = ({
133
133
  // Convert to base64
134
134
  try {
135
135
  const RNFS = require('react-native-fs');
136
- const base64 = await RNFS.readFile(processedPath.replace('file://', ''), 'base64');
137
- const sizeKB = Math.round(base64.length / 1024);
138
- logger.info('Image converted to base64:', { sizeKB, path: processedPath });
139
- onCapture(base64);
140
- } catch (fsError) {
141
- // Fallback: try fetch method
136
+ // Try with original path first, then strip file:// prefix
137
+ let base64: string;
142
138
  try {
143
- const fileUri = Platform.OS === 'android' ? `file://${processedPath}` : processedPath;
144
- const response = await fetch(fileUri);
145
- const blob = await response.blob();
146
-
147
- const reader = new FileReader();
148
- reader.onloadend = () => {
149
- const base64data = reader.result as string;
150
- const base64 = base64data.includes(',')
151
- ? base64data.split(',')[1]
152
- : base64data;
153
- onCapture(base64);
154
- setIsCapturing(false);
155
- };
156
- reader.onerror = () => {
157
- throw new Error('Failed to read photo file');
158
- };
159
- reader.readAsDataURL(blob);
160
- return;
161
- } catch (fetchError) {
162
- logger.warn('Could not convert to base64, using file path:', processedPath);
163
- onCapture(processedPath);
139
+ base64 = await RNFS.readFile(processedPath, 'base64');
140
+ } catch {
141
+ base64 = await RNFS.readFile(processedPath.replace('file://', ''), 'base64');
164
142
  }
143
+ onCapture(base64);
144
+ } catch (fsError) {
145
+ logger.warn('Could not convert to base64, using file path:', processedPath);
146
+ onCapture(processedPath);
165
147
  }
166
148
  } catch (error) {
167
149
  logger.error('Error processing image:', error);
@@ -6,6 +6,7 @@ import {
6
6
  ActivityIndicator,
7
7
  SafeAreaView,
8
8
  Animated,
9
+ TouchableOpacity,
9
10
  } from 'react-native';
10
11
  import { VideoRecorder, VideoRecordingResult } from './VideoRecorder';
11
12
  import { FacePositioningGuide } from './FacePositioningGuide';
@@ -47,6 +48,7 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
47
48
  const [isValidating, setIsValidating] = useState(false);
48
49
  const [currentChallenges, setCurrentChallenges] = useState<any[]>([]);
49
50
  const [isLoadingChallenges, setIsLoadingChallenges] = useState(false);
51
+ const [loadingTimedOut, setLoadingTimedOut] = useState(false);
50
52
  const animatedProgress = useRef(new Animated.Value(0)).current;
51
53
  const [displayProgress, setDisplayProgress] = useState(0);
52
54
  const animationRef = useRef<Animated.CompositeAnimation | null>(null);
@@ -62,6 +64,19 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
62
64
  }
63
65
  }, [language]);
64
66
 
67
+ // 15-second timeout on initial loading (challenge fetch / SDK init)
68
+ useEffect(() => {
69
+ const isLoading = !isInitialized || (isUsingBackend && isLoadingChallenges);
70
+ if (isLoading) {
71
+ const timer = setTimeout(() => {
72
+ setLoadingTimedOut(true);
73
+ }, 15000);
74
+ return () => clearTimeout(timer);
75
+ } else {
76
+ setLoadingTimedOut(false);
77
+ }
78
+ }, [isInitialized, isUsingBackend, isLoadingChallenges]);
79
+
65
80
  useEffect(() => {
66
81
  if (isInitialized && isUsingBackend) {
67
82
  const loadChallenges = async () => {
@@ -163,10 +178,10 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
163
178
  animatedProgress.setValue(0);
164
179
  setDisplayProgress(0);
165
180
 
166
- // Start animation to 90% over 60 seconds
181
+ // Start animation to 90% over 25 seconds
167
182
  animationRef.current = Animated.timing(animatedProgress, {
168
183
  toValue: 90,
169
- duration: 60000, // 60 seconds
184
+ duration: 25000, // 25 seconds
170
185
  useNativeDriver: false,
171
186
  });
172
187
 
@@ -347,6 +362,31 @@ export const ProfilePictureCapture: React.FC<ProfilePictureCaptureProps> = ({
347
362
 
348
363
  // Wait for initialization and challenge loading before showing guide or VideoRecorder
349
364
  if (!isInitialized || (isUsingBackend && isLoadingChallenges)) {
365
+ if (loadingTimedOut) {
366
+ return (
367
+ <SafeAreaView style={[styles.container, { backgroundColor: theme?.backgroundColor || '#FFFFFF' }]}>
368
+ <View style={styles.loadingContainer}>
369
+ <Text style={{ fontSize: 48, marginBottom: 16 }}>⏳</Text>
370
+ <Text style={[styles.loadingText, { color: theme?.textColor || '#1e1b4b', fontSize: 20, fontWeight: 'bold' }]}>
371
+ {strings.errors.systemBusy}
372
+ </Text>
373
+ <Text style={{ fontSize: 15, color: theme?.secondaryTextColor || '#64748b', textAlign: 'center', marginTop: 12, paddingHorizontal: 24, lineHeight: 22 }}>
374
+ {strings.errors.systemBusyMessage}
375
+ </Text>
376
+ {onCancel && (
377
+ <TouchableOpacity
378
+ style={{ backgroundColor: theme?.primaryColor || '#4f46e5', paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12, marginTop: 32 }}
379
+ onPress={onCancel}
380
+ >
381
+ <Text style={{ color: '#FFFFFF', fontSize: 16, fontWeight: '600' }}>
382
+ {strings.common?.back || 'Go back'}
383
+ </Text>
384
+ </TouchableOpacity>
385
+ )}
386
+ </View>
387
+ </SafeAreaView>
388
+ );
389
+ }
350
390
  return (
351
391
  <SafeAreaView style={[styles.container, { backgroundColor: theme?.backgroundColor || '#FFFFFF' }]}>
352
392
  <View style={styles.loadingContainer}>
@@ -40,10 +40,10 @@ export const ValidationProgress: React.FC<ValidationProgressProps> = ({
40
40
  if (!hasStartedAnimation.current) {
41
41
  hasStartedAnimation.current = true;
42
42
 
43
- // Start animation to 90% over 70 seconds, regardless of backend progress
43
+ // Start animation to 90% over 35 seconds, regardless of backend progress
44
44
  animationRef.current = Animated.timing(animatedProgress, {
45
45
  toValue: 90,
46
- duration: 70000, // 70 seconds
46
+ duration: 35000, // 35 seconds
47
47
  useNativeDriver: false,
48
48
  });
49
49
 
@@ -415,7 +415,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
415
415
  framesRef.current = [];
416
416
  let consecutiveErrors = 0;
417
417
  const maxConsecutiveErrors = 10;
418
- const MAX_FRAMES = 150;
418
+ const MAX_FRAMES = 30;
419
419
 
420
420
  // Serial capture: each frame is captured only after the previous one
421
421
  // finishes, preventing overlapping takePhoto() calls that cause the
@@ -436,39 +436,20 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
436
436
 
437
437
  try {
438
438
  const RNFS = require('react-native-fs');
439
- base64Data = await RNFS.readFile(photo.path, 'base64');
440
- } catch (fsError) {
439
+ // Try with original path first, then strip file:// prefix
441
440
  try {
442
- const fileUri = Platform.OS === 'android' ? `file://${photo.path}` : photo.path;
443
- const response = await fetch(fileUri);
444
- const blob = await response.blob();
445
- base64Data = await new Promise<string>((resolve, reject) => {
446
- const reader = new FileReader();
447
- reader.onloadend = () => {
448
- const result = reader.result as string;
449
- const base64 = result.includes(',') ? result.split(',')[1] : result;
450
- resolve(base64);
451
- };
452
- reader.onerror = reject;
453
- reader.readAsDataURL(blob);
454
- });
455
- } catch (fetchError) {
456
- logger.error('Failed to read photo file as base64:', fetchError);
457
- base64Data = null;
441
+ base64Data = await RNFS.readFile(photo.path, 'base64');
442
+ } catch {
443
+ base64Data = await RNFS.readFile(photo.path.replace('file://', ''), 'base64');
458
444
  }
445
+ } catch (fsError) {
446
+ logger.warn('Could not convert photo to base64, skipping frame');
447
+ base64Data = null;
459
448
  }
460
449
 
461
450
  if (base64Data) {
462
- setFrames(prev => {
463
- const newFrames = prev.length < MAX_FRAMES ? [...prev, base64Data!] : prev;
464
- framesRef.current = newFrames;
465
- if (newFrames.length % 10 === 0) {
466
- logger.info('Captured frames:', newFrames.length);
467
- }
468
- return newFrames;
469
- });
470
- } else {
471
- logger.warn('Could not convert photo to base64, skipping frame');
451
+ framesRef.current = [...framesRef.current, base64Data];
452
+ setFrames(framesRef.current);
472
453
  }
473
454
  }
474
455
  } catch (error: any) {