@hexar/biometric-identity-sdk-react-native 1.22.0 → 1.24.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":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAexE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAE1I,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,YAAY,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,EAAE,CAAC,SAAS,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACtD,iCAAiC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA2CD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,
|
|
1
|
+
{"version":3,"file":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAexE,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,CA2lCtD,CAAC;AA4PF,eAAe,aAAa,CAAC"}
|
|
@@ -102,12 +102,20 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
102
102
|
const [guidanceText, setGuidanceText] = (0, react_1.useState)(null);
|
|
103
103
|
const [hasPermission, setHasPermission] = (0, react_1.useState)(false);
|
|
104
104
|
const [isCameraReady, setIsCameraReady] = (0, react_1.useState)(false);
|
|
105
|
+
// Controlled via stopRecording — set to false before calling onComplete so
|
|
106
|
+
// VisionCamera tears down the native session and any in-flight takePhoto()
|
|
107
|
+
// calls fail gracefully instead of crashing with IllegalViewOperationException
|
|
108
|
+
// when the parent unmounts the <Camera> view underneath them.
|
|
109
|
+
const [isCameraActive, setIsCameraActive] = (0, react_1.useState)(true);
|
|
105
110
|
const cameraRef = (0, react_1.useRef)(null);
|
|
106
111
|
const { hasPermission: cameraPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
|
|
107
112
|
// Prefer wide-angle (main) front camera — some Android devices expose
|
|
108
113
|
// multiple front sensors and the default may lack autofocus.
|
|
114
|
+
// Only request wide-angle — ultra-wide opens a multi-physical-camera session
|
|
115
|
+
// that is slow to release, causing CameraViewModule.findCameraView to throw
|
|
116
|
+
// IllegalViewOperationException when the component unmounts after recording.
|
|
109
117
|
const device = (0, react_native_vision_camera_1.useCameraDevice)('front', {
|
|
110
|
-
physicalDevices: ['wide-angle-camera'
|
|
118
|
+
physicalDevices: ['wide-angle-camera'],
|
|
111
119
|
});
|
|
112
120
|
const [deviceReady, setDeviceReady] = (0, react_1.useState)(!!device);
|
|
113
121
|
const fadeAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
@@ -121,9 +129,19 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
121
129
|
const videoRecordingRef = (0, react_1.useRef)(null);
|
|
122
130
|
const isRecordingRef = (0, react_1.useRef)(false);
|
|
123
131
|
const recordingTimeoutRef = (0, react_1.useRef)(null);
|
|
132
|
+
// Tracks whether a takePhoto() call is currently dispatched to the native
|
|
133
|
+
// bridge. We must wait for it to resolve before unmounting the <Camera>
|
|
134
|
+
// view, otherwise CameraViewModule.findCameraView crashes on Android.
|
|
135
|
+
const captureInFlightRef = (0, react_1.useRef)(false);
|
|
136
|
+
const isMountedRef = (0, react_1.useRef)(true);
|
|
124
137
|
// Trigger autofocus on center — some Android devices (e.g. Ulefone Armor)
|
|
125
138
|
// don't auto-focus without an explicit programmatic trigger.
|
|
126
139
|
const triggerFocus = (0, react_1.useCallback)(() => {
|
|
140
|
+
// Guard: focus() dispatches to native findCameraView which crashes
|
|
141
|
+
// if the view is gone. The JS try/catch does NOT help — the exception
|
|
142
|
+
// happens on the Android UI thread Looper, not in the JS promise chain.
|
|
143
|
+
if (!isMountedRef.current)
|
|
144
|
+
return;
|
|
127
145
|
try {
|
|
128
146
|
const { width: w, height: h } = react_native_1.Dimensions.get('window');
|
|
129
147
|
cameraRef.current?.focus({ x: w / 2, y: h / 2 });
|
|
@@ -132,6 +150,30 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
132
150
|
// focus() not supported or camera not ready — safe to ignore
|
|
133
151
|
}
|
|
134
152
|
}, []);
|
|
153
|
+
// ── Unmount cleanup ──────────────────────────────────────────────────
|
|
154
|
+
// Prevents scenario 2: unexpected unmount (back button, navigation,
|
|
155
|
+
// phone call) while timers are still scheduled. Without this, pending
|
|
156
|
+
// setTimeout callbacks fire after unmount, dispatch takePhoto() to the
|
|
157
|
+
// native bridge, and crash with IllegalViewOperationException.
|
|
158
|
+
(0, react_1.useEffect)(() => {
|
|
159
|
+
isMountedRef.current = true;
|
|
160
|
+
return () => {
|
|
161
|
+
isMountedRef.current = false;
|
|
162
|
+
isRecordingRef.current = false;
|
|
163
|
+
if (frameCaptureInterval.current) {
|
|
164
|
+
clearTimeout(frameCaptureInterval.current);
|
|
165
|
+
frameCaptureInterval.current = null;
|
|
166
|
+
}
|
|
167
|
+
if (recordingTimeoutRef.current) {
|
|
168
|
+
clearTimeout(recordingTimeoutRef.current);
|
|
169
|
+
recordingTimeoutRef.current = null;
|
|
170
|
+
}
|
|
171
|
+
if (frameInterval.current) {
|
|
172
|
+
clearTimeout(frameInterval.current);
|
|
173
|
+
frameInterval.current = null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
135
177
|
const minDurationMs = 8000;
|
|
136
178
|
const totalDuration = duration || Math.max(minDurationMs, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
|
|
137
179
|
(0, react_1.useEffect)(() => {
|
|
@@ -307,6 +349,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
307
349
|
setChallengeProgress(0);
|
|
308
350
|
setOverallProgress(0);
|
|
309
351
|
recordingStartTime.current = 0;
|
|
352
|
+
setIsCameraActive(true);
|
|
310
353
|
setPhase('countdown');
|
|
311
354
|
setCountdown(3);
|
|
312
355
|
}, []);
|
|
@@ -314,6 +357,30 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
314
357
|
setPhase('loading');
|
|
315
358
|
react_native_1.Alert.alert('Recording Error', 'Failed to record video. Please try again.', [{ text: 'OK', onPress: onCancel }]);
|
|
316
359
|
}, [onCancel]);
|
|
360
|
+
// Wait for any in-flight takePhoto() to finish, then deactivate the camera
|
|
361
|
+
// and deliver the result. This prevents the IllegalViewOperationException
|
|
362
|
+
// crash: the native view stays alive while the native bridge processes the
|
|
363
|
+
// last queued takePhoto(), and only THEN do we trigger the parent unmount
|
|
364
|
+
// via onComplete.
|
|
365
|
+
const safeComplete = (0, react_1.useCallback)((result) => {
|
|
366
|
+
const drain = () => {
|
|
367
|
+
if (captureInFlightRef.current) {
|
|
368
|
+
// A takePhoto() is still on the native bridge — check again shortly
|
|
369
|
+
setTimeout(drain, 30);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// No in-flight capture — safe to tear down
|
|
373
|
+
setIsCameraActive(false);
|
|
374
|
+
// Small delay to let VisionCamera process the isActive=false prop change
|
|
375
|
+
// and release the native camera session before the parent unmounts the view.
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
if (isMountedRef.current) {
|
|
378
|
+
onComplete(result);
|
|
379
|
+
}
|
|
380
|
+
}, 100);
|
|
381
|
+
};
|
|
382
|
+
drain();
|
|
383
|
+
}, [onComplete]);
|
|
317
384
|
// Safety timeout: if the camera session never fires onInitialized within
|
|
318
385
|
// 10 seconds, treat it as a hardware failure and send the user back.
|
|
319
386
|
(0, react_1.useEffect)(() => {
|
|
@@ -380,12 +447,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
380
447
|
};
|
|
381
448
|
biometric_identity_sdk_core_1.logger.info('Video recording completed:', result.frames.length, 'frames,', (actualDuration / 1000).toFixed(1) + 's', 'challenges:', currentCompletedChallenges.length);
|
|
382
449
|
isRecordingRef.current = false;
|
|
383
|
-
|
|
384
|
-
// multi-physical-camera sessions opened via physicalDevices) has time
|
|
385
|
-
// to fully close before the parent unmounts the <Camera> view.
|
|
386
|
-
// Without this delay, CameraViewModule.findCameraView throws
|
|
387
|
-
// IllegalViewOperationException on Android 13 (SDK 33).
|
|
388
|
-
setTimeout(() => onComplete(result), 250);
|
|
450
|
+
safeComplete(result);
|
|
389
451
|
}
|
|
390
452
|
catch (error) {
|
|
391
453
|
biometric_identity_sdk_core_1.logger.error('Error processing video:', error);
|
|
@@ -393,7 +455,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
393
455
|
setOverallProgress(0);
|
|
394
456
|
handleRecordingError(error);
|
|
395
457
|
}
|
|
396
|
-
}, [frames, completedChallenges, challenges, sessionId,
|
|
458
|
+
}, [frames, completedChallenges, challenges, sessionId, safeComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
|
|
397
459
|
const startFrameCapture = (0, react_1.useCallback)(() => {
|
|
398
460
|
if (cameraRef.current && device) {
|
|
399
461
|
biometric_identity_sdk_core_1.logger.info('Starting serial frame capture');
|
|
@@ -441,9 +503,13 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
441
503
|
// finishes, preventing overlapping takePhoto() calls that cause the
|
|
442
504
|
// camera to throw "busy" errors and kill the capture loop.
|
|
443
505
|
const captureNextFrame = async () => {
|
|
444
|
-
if
|
|
506
|
+
// Bail if recording stopped, component unmounted, or we have enough frames.
|
|
507
|
+
// Checking isMountedRef prevents dispatching takePhoto() to a native view
|
|
508
|
+
// that is about to be removed from NativeViewHierarchyManager.
|
|
509
|
+
if (!isRecordingRef.current || !isMountedRef.current || framesRef.current.length >= MAX_FRAMES) {
|
|
445
510
|
return;
|
|
446
511
|
}
|
|
512
|
+
captureInFlightRef.current = true;
|
|
447
513
|
try {
|
|
448
514
|
const photo = await cameraRef.current?.takePhoto({
|
|
449
515
|
flash: 'off',
|
|
@@ -486,12 +552,14 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
486
552
|
biometric_identity_sdk_core_1.logger.warn('Frame capture error', consecutiveErrors, '/', maxConsecutiveErrors, error?.message);
|
|
487
553
|
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
488
554
|
biometric_identity_sdk_core_1.logger.error('Too many consecutive capture errors, stopping');
|
|
555
|
+
captureInFlightRef.current = false;
|
|
489
556
|
return;
|
|
490
557
|
}
|
|
491
558
|
}
|
|
559
|
+
captureInFlightRef.current = false;
|
|
492
560
|
// Schedule next capture only after this one completes (serial chain).
|
|
493
561
|
// Small delay lets the camera hardware reset between captures.
|
|
494
|
-
if (isRecordingRef.current && framesRef.current.length < MAX_FRAMES) {
|
|
562
|
+
if (isRecordingRef.current && isMountedRef.current && framesRef.current.length < MAX_FRAMES) {
|
|
495
563
|
frameCaptureInterval.current = setTimeout(captureNextFrame, 66);
|
|
496
564
|
}
|
|
497
565
|
};
|
|
@@ -536,7 +604,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
536
604
|
sessionId,
|
|
537
605
|
};
|
|
538
606
|
setPhase('processing');
|
|
539
|
-
|
|
607
|
+
safeComplete(result);
|
|
540
608
|
}
|
|
541
609
|
else {
|
|
542
610
|
biometric_identity_sdk_core_1.logger.info('Stopping recording with frames (fallback):', currentFrames.length);
|
|
@@ -549,7 +617,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
549
617
|
sessionId,
|
|
550
618
|
};
|
|
551
619
|
setPhase('processing');
|
|
552
|
-
|
|
620
|
+
safeComplete(result);
|
|
553
621
|
}
|
|
554
622
|
}
|
|
555
623
|
}
|
|
@@ -580,11 +648,9 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
580
648
|
challengesCompleted: currentCompletedChallenges,
|
|
581
649
|
sessionId,
|
|
582
650
|
};
|
|
583
|
-
|
|
584
|
-
onComplete(result);
|
|
585
|
-
}, 250);
|
|
651
|
+
safeComplete(result);
|
|
586
652
|
}
|
|
587
|
-
}, [frames, completedChallenges, challenges, sessionId, onComplete, minDurationMs, resetAndRetry, strings]);
|
|
653
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete, safeComplete, minDurationMs, resetAndRetry, strings]);
|
|
588
654
|
const runChallenge = (0, react_1.useCallback)((index) => {
|
|
589
655
|
const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
|
|
590
656
|
biometric_identity_sdk_core_1.logger.info('VideoRecorder: runChallenge called', {
|
|
@@ -626,7 +692,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
626
692
|
sessionId,
|
|
627
693
|
};
|
|
628
694
|
setPhase('processing');
|
|
629
|
-
|
|
695
|
+
safeComplete(result);
|
|
630
696
|
}
|
|
631
697
|
}
|
|
632
698
|
return;
|
|
@@ -678,7 +744,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
678
744
|
runChallenge(index + 1);
|
|
679
745
|
});
|
|
680
746
|
}, challenge.duration_ms);
|
|
681
|
-
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
|
|
747
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording, safeComplete]);
|
|
682
748
|
const startRecording = (0, react_1.useCallback)(async () => {
|
|
683
749
|
setPhase('recording');
|
|
684
750
|
recordingStartTime.current = Date.now();
|
|
@@ -807,7 +873,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
|
|
|
807
873
|
}
|
|
808
874
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
809
875
|
react_1.default.createElement(react_native_1.View, { style: styles.cameraContainer },
|
|
810
|
-
react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive:
|
|
876
|
+
react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: isCameraActive, video: true, photo: true, audio: false, onInitialized: () => setIsCameraReady(true), onError: (error) => {
|
|
811
877
|
biometric_identity_sdk_core_1.logger.error('Camera hardware error:', error);
|
|
812
878
|
handleRecordingError(error);
|
|
813
879
|
} }),
|
package/package.json
CHANGED
|
@@ -131,12 +131,20 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
131
131
|
const [guidanceText, setGuidanceText] = useState<string | null>(null);
|
|
132
132
|
const [hasPermission, setHasPermission] = useState(false);
|
|
133
133
|
const [isCameraReady, setIsCameraReady] = useState(false);
|
|
134
|
+
// Controlled via stopRecording — set to false before calling onComplete so
|
|
135
|
+
// VisionCamera tears down the native session and any in-flight takePhoto()
|
|
136
|
+
// calls fail gracefully instead of crashing with IllegalViewOperationException
|
|
137
|
+
// when the parent unmounts the <Camera> view underneath them.
|
|
138
|
+
const [isCameraActive, setIsCameraActive] = useState(true);
|
|
134
139
|
const cameraRef = useRef<Camera>(null);
|
|
135
140
|
const { hasPermission: cameraPermission, requestPermission } = useCameraPermission();
|
|
136
141
|
// Prefer wide-angle (main) front camera — some Android devices expose
|
|
137
142
|
// multiple front sensors and the default may lack autofocus.
|
|
143
|
+
// Only request wide-angle — ultra-wide opens a multi-physical-camera session
|
|
144
|
+
// that is slow to release, causing CameraViewModule.findCameraView to throw
|
|
145
|
+
// IllegalViewOperationException when the component unmounts after recording.
|
|
138
146
|
const device = useCameraDevice('front', {
|
|
139
|
-
physicalDevices: ['wide-angle-camera'
|
|
147
|
+
physicalDevices: ['wide-angle-camera'],
|
|
140
148
|
});
|
|
141
149
|
const [deviceReady, setDeviceReady] = useState(!!device);
|
|
142
150
|
|
|
@@ -152,9 +160,18 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
152
160
|
const videoRecordingRef = useRef<any>(null);
|
|
153
161
|
const isRecordingRef = useRef<boolean>(false);
|
|
154
162
|
const recordingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
163
|
+
// Tracks whether a takePhoto() call is currently dispatched to the native
|
|
164
|
+
// bridge. We must wait for it to resolve before unmounting the <Camera>
|
|
165
|
+
// view, otherwise CameraViewModule.findCameraView crashes on Android.
|
|
166
|
+
const captureInFlightRef = useRef<boolean>(false);
|
|
167
|
+
const isMountedRef = useRef<boolean>(true);
|
|
155
168
|
// Trigger autofocus on center — some Android devices (e.g. Ulefone Armor)
|
|
156
169
|
// don't auto-focus without an explicit programmatic trigger.
|
|
157
170
|
const triggerFocus = useCallback(() => {
|
|
171
|
+
// Guard: focus() dispatches to native findCameraView which crashes
|
|
172
|
+
// if the view is gone. The JS try/catch does NOT help — the exception
|
|
173
|
+
// happens on the Android UI thread Looper, not in the JS promise chain.
|
|
174
|
+
if (!isMountedRef.current) return;
|
|
158
175
|
try {
|
|
159
176
|
const { width: w, height: h } = Dimensions.get('window');
|
|
160
177
|
cameraRef.current?.focus({ x: w / 2, y: h / 2 });
|
|
@@ -163,6 +180,31 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
163
180
|
}
|
|
164
181
|
}, []);
|
|
165
182
|
|
|
183
|
+
// ── Unmount cleanup ──────────────────────────────────────────────────
|
|
184
|
+
// Prevents scenario 2: unexpected unmount (back button, navigation,
|
|
185
|
+
// phone call) while timers are still scheduled. Without this, pending
|
|
186
|
+
// setTimeout callbacks fire after unmount, dispatch takePhoto() to the
|
|
187
|
+
// native bridge, and crash with IllegalViewOperationException.
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
isMountedRef.current = true;
|
|
190
|
+
return () => {
|
|
191
|
+
isMountedRef.current = false;
|
|
192
|
+
isRecordingRef.current = false;
|
|
193
|
+
if (frameCaptureInterval.current) {
|
|
194
|
+
clearTimeout(frameCaptureInterval.current);
|
|
195
|
+
frameCaptureInterval.current = null;
|
|
196
|
+
}
|
|
197
|
+
if (recordingTimeoutRef.current) {
|
|
198
|
+
clearTimeout(recordingTimeoutRef.current);
|
|
199
|
+
recordingTimeoutRef.current = null;
|
|
200
|
+
}
|
|
201
|
+
if (frameInterval.current) {
|
|
202
|
+
clearTimeout(frameInterval.current);
|
|
203
|
+
frameInterval.current = null;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
166
208
|
const minDurationMs = 8000;
|
|
167
209
|
const totalDuration = duration || Math.max(
|
|
168
210
|
minDurationMs,
|
|
@@ -356,6 +398,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
356
398
|
setChallengeProgress(0);
|
|
357
399
|
setOverallProgress(0);
|
|
358
400
|
recordingStartTime.current = 0;
|
|
401
|
+
setIsCameraActive(true);
|
|
359
402
|
setPhase('countdown');
|
|
360
403
|
setCountdown(3);
|
|
361
404
|
}, []);
|
|
@@ -369,6 +412,31 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
369
412
|
);
|
|
370
413
|
}, [onCancel]);
|
|
371
414
|
|
|
415
|
+
// Wait for any in-flight takePhoto() to finish, then deactivate the camera
|
|
416
|
+
// and deliver the result. This prevents the IllegalViewOperationException
|
|
417
|
+
// crash: the native view stays alive while the native bridge processes the
|
|
418
|
+
// last queued takePhoto(), and only THEN do we trigger the parent unmount
|
|
419
|
+
// via onComplete.
|
|
420
|
+
const safeComplete = useCallback((result: VideoRecordingResult) => {
|
|
421
|
+
const drain = () => {
|
|
422
|
+
if (captureInFlightRef.current) {
|
|
423
|
+
// A takePhoto() is still on the native bridge — check again shortly
|
|
424
|
+
setTimeout(drain, 30);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// No in-flight capture — safe to tear down
|
|
428
|
+
setIsCameraActive(false);
|
|
429
|
+
// Small delay to let VisionCamera process the isActive=false prop change
|
|
430
|
+
// and release the native camera session before the parent unmounts the view.
|
|
431
|
+
setTimeout(() => {
|
|
432
|
+
if (isMountedRef.current) {
|
|
433
|
+
onComplete(result);
|
|
434
|
+
}
|
|
435
|
+
}, 100);
|
|
436
|
+
};
|
|
437
|
+
drain();
|
|
438
|
+
}, [onComplete]);
|
|
439
|
+
|
|
372
440
|
// Safety timeout: if the camera session never fires onInitialized within
|
|
373
441
|
// 10 seconds, treat it as a hardware failure and send the user back.
|
|
374
442
|
useEffect(() => {
|
|
@@ -450,19 +518,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
450
518
|
logger.info('Video recording completed:', result.frames.length, 'frames,', (actualDuration / 1000).toFixed(1) + 's', 'challenges:', currentCompletedChallenges.length);
|
|
451
519
|
|
|
452
520
|
isRecordingRef.current = false;
|
|
453
|
-
|
|
454
|
-
// multi-physical-camera sessions opened via physicalDevices) has time
|
|
455
|
-
// to fully close before the parent unmounts the <Camera> view.
|
|
456
|
-
// Without this delay, CameraViewModule.findCameraView throws
|
|
457
|
-
// IllegalViewOperationException on Android 13 (SDK 33).
|
|
458
|
-
setTimeout(() => onComplete(result), 250);
|
|
521
|
+
safeComplete(result);
|
|
459
522
|
} catch (error) {
|
|
460
523
|
logger.error('Error processing video:', error);
|
|
461
524
|
setPhase('recording');
|
|
462
525
|
setOverallProgress(0);
|
|
463
526
|
handleRecordingError(error);
|
|
464
527
|
}
|
|
465
|
-
}, [frames, completedChallenges, challenges, sessionId,
|
|
528
|
+
}, [frames, completedChallenges, challenges, sessionId, safeComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
|
|
466
529
|
|
|
467
530
|
const startFrameCapture = useCallback(() => {
|
|
468
531
|
if (cameraRef.current && device) {
|
|
@@ -522,10 +585,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
522
585
|
// finishes, preventing overlapping takePhoto() calls that cause the
|
|
523
586
|
// camera to throw "busy" errors and kill the capture loop.
|
|
524
587
|
const captureNextFrame = async () => {
|
|
525
|
-
if
|
|
588
|
+
// Bail if recording stopped, component unmounted, or we have enough frames.
|
|
589
|
+
// Checking isMountedRef prevents dispatching takePhoto() to a native view
|
|
590
|
+
// that is about to be removed from NativeViewHierarchyManager.
|
|
591
|
+
if (!isRecordingRef.current || !isMountedRef.current || framesRef.current.length >= MAX_FRAMES) {
|
|
526
592
|
return;
|
|
527
593
|
}
|
|
528
594
|
|
|
595
|
+
captureInFlightRef.current = true;
|
|
529
596
|
try {
|
|
530
597
|
const photo = await cameraRef.current?.takePhoto({
|
|
531
598
|
flash: 'off',
|
|
@@ -565,13 +632,15 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
565
632
|
logger.warn('Frame capture error', consecutiveErrors, '/', maxConsecutiveErrors, error?.message);
|
|
566
633
|
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
567
634
|
logger.error('Too many consecutive capture errors, stopping');
|
|
635
|
+
captureInFlightRef.current = false;
|
|
568
636
|
return;
|
|
569
637
|
}
|
|
570
638
|
}
|
|
639
|
+
captureInFlightRef.current = false;
|
|
571
640
|
|
|
572
641
|
// Schedule next capture only after this one completes (serial chain).
|
|
573
642
|
// Small delay lets the camera hardware reset between captures.
|
|
574
|
-
if (isRecordingRef.current && framesRef.current.length < MAX_FRAMES) {
|
|
643
|
+
if (isRecordingRef.current && isMountedRef.current && framesRef.current.length < MAX_FRAMES) {
|
|
575
644
|
frameCaptureInterval.current = setTimeout(captureNextFrame, 66);
|
|
576
645
|
}
|
|
577
646
|
};
|
|
@@ -622,7 +691,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
622
691
|
sessionId,
|
|
623
692
|
};
|
|
624
693
|
setPhase('processing');
|
|
625
|
-
|
|
694
|
+
safeComplete(result);
|
|
626
695
|
} else {
|
|
627
696
|
logger.info('Stopping recording with frames (fallback):', currentFrames.length);
|
|
628
697
|
const result: VideoRecordingResult = {
|
|
@@ -634,7 +703,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
634
703
|
sessionId,
|
|
635
704
|
};
|
|
636
705
|
setPhase('processing');
|
|
637
|
-
|
|
706
|
+
safeComplete(result);
|
|
638
707
|
}
|
|
639
708
|
}
|
|
640
709
|
} else {
|
|
@@ -672,11 +741,9 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
672
741
|
sessionId,
|
|
673
742
|
};
|
|
674
743
|
|
|
675
|
-
|
|
676
|
-
onComplete(result);
|
|
677
|
-
}, 250);
|
|
744
|
+
safeComplete(result);
|
|
678
745
|
}
|
|
679
|
-
}, [frames, completedChallenges, challenges, sessionId, onComplete, minDurationMs, resetAndRetry, strings]);
|
|
746
|
+
}, [frames, completedChallenges, challenges, sessionId, onComplete, safeComplete, minDurationMs, resetAndRetry, strings]);
|
|
680
747
|
|
|
681
748
|
const runChallenge = useCallback((index: number) => {
|
|
682
749
|
const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
|
|
@@ -723,7 +790,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
723
790
|
sessionId,
|
|
724
791
|
};
|
|
725
792
|
setPhase('processing');
|
|
726
|
-
|
|
793
|
+
safeComplete(result);
|
|
727
794
|
}
|
|
728
795
|
}
|
|
729
796
|
return;
|
|
@@ -784,7 +851,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
784
851
|
runChallenge(index + 1);
|
|
785
852
|
});
|
|
786
853
|
}, challenge.duration_ms);
|
|
787
|
-
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
|
|
854
|
+
}, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording, safeComplete]);
|
|
788
855
|
|
|
789
856
|
const startRecording = useCallback(async () => {
|
|
790
857
|
setPhase('recording');
|
|
@@ -978,7 +1045,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
|
|
|
978
1045
|
ref={cameraRef}
|
|
979
1046
|
style={StyleSheet.absoluteFill}
|
|
980
1047
|
device={device}
|
|
981
|
-
isActive={
|
|
1048
|
+
isActive={isCameraActive}
|
|
982
1049
|
video={true}
|
|
983
1050
|
photo={true}
|
|
984
1051
|
audio={false}
|