@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,CAwhCtD,CAAC;AA4PF,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;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', 'ultra-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
- // Delay onComplete so Vision Camera's native session (especially
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, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
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 (!isRecordingRef.current || framesRef.current.length >= MAX_FRAMES) {
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
- setTimeout(() => onComplete(result), 250);
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
- setTimeout(() => onComplete(result), 250);
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
- setTimeout(() => {
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
- setTimeout(() => onComplete(result), 250);
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: true, video: true, photo: true, audio: false, onInitialized: () => setIsCameraReady(true), onError: (error) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexar/biometric-identity-sdk-react-native",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "description": "React Native wrapper for Biometric Identity SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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', 'ultra-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
- // Delay onComplete so Vision Camera's native session (especially
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, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
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 (!isRecordingRef.current || framesRef.current.length >= MAX_FRAMES) {
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
- setTimeout(() => onComplete(result), 250);
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
- setTimeout(() => onComplete(result), 250);
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
- setTimeout(() => {
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
- setTimeout(() => onComplete(result), 250);
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={true}
1048
+ isActive={isCameraActive}
982
1049
  video={true}
983
1050
  photo={true}
984
1051
  audio={false}