From 8381d54a51b7bde55a7f2a6566cd6dbabbd6e134 Mon Sep 17 00:00:00 2001 From: v Date: Mon, 7 Sep 2026 02:49:22 +0200 Subject: [PATCH] fix(android): Release PreviewView when the React view is dropped Nitro does not call `dispose()` on unmount, so the `PreviewView` and its `SurfaceView` stayed reachable after every unmount. `onDropView()` now removes the stream-state observer, detaches the surface provider and drops the view reference. Fixes #4180 --- ...visioncamera.nativepreviewview.harness.tsx | 98 +++++++++++++++++++ .../nitro/camera/views/HybridPreviewView.kt | 38 ++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx b/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx index b107015992..1fb97cf961 100644 --- a/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx +++ b/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx @@ -722,6 +722,104 @@ describe('VisionCamera - NativePreviewView', () => { } }) + it('re-attaches a preview output to a new NativePreviewView that replaces the old one while running', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const firstRef = deferred() + const secondRef = deferred() + const firstLayout = deferred() + const secondLayout = deferred() + const firstPreviewStarted = deferred() + const secondPreviewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + firstRef.reject(error) + secondRef.reject(error) + firstLayout.reject(error) + secondLayout.reject(error) + firstPreviewStarted.reject(error) + secondPreviewStarted.reject(error) + }) + + try { + const { rerender } = await render( + { + firstRef.resolve(preview) + })} + onLayout={(event) => { + firstLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(firstPreviewStarted.resolve)} + />, + ) + + const firstPreview = await withTimeout( + firstRef.promise, + 10_000, + 'first NativePreviewView hybridRef', + ) + const firstPreviewLayout = await withTimeout( + firstLayout.promise, + 10_000, + 'first NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + firstPreviewStarted.promise, + 15_000, + 'first NativePreviewView onPreviewStarted', + ) + expectPreviewGeometry(firstPreview, firstPreviewLayout) + + await rerender( + { + secondRef.resolve(preview) + })} + onLayout={(event) => { + secondLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(secondPreviewStarted.resolve)} + />, + ) + + const secondPreview = await withTimeout( + secondRef.promise, + 10_000, + 'second NativePreviewView hybridRef', + ) + const secondPreviewLayout = await withTimeout( + secondLayout.promise, + 10_000, + 'second NativePreviewView onLayout', + ) + await withTimeout( + secondPreviewStarted.promise, + 15_000, + 'second NativePreviewView onPreviewStarted', + ) + expectPreviewGeometry(secondPreview, secondPreviewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + it('starts a replacement preview and stops the removed preview while running', async () => { const session = await VisionCamera.createCameraSession(false) const firstPreviewOutput = VisionCamera.createPreviewOutput() diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/views/HybridPreviewView.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/views/HybridPreviewView.kt index dcbc15ded7..f8f148670f 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/views/HybridPreviewView.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/views/HybridPreviewView.kt @@ -37,7 +37,10 @@ class HybridPreviewView( ) : HybridPreviewViewSpec(), View.OnTouchListener, Observer { - private val previewView = PreviewView(context) + // released on drop; JS may hold this object long after unmount + private var previewViewOrNull: PreviewView? = PreviewView(context) + private val previewView: PreviewView + get() = previewViewOrNull ?: throw Error("PreviewView has already been dropped from the React tree!") private val uiScope = CoroutineScope(Dispatchers.Main.immediate) override val view: View get() = previewView @@ -67,6 +70,7 @@ class HybridPreviewView( field = newImplementationMode uiScope.launch { // Update ImplementationMode and reconnect output + val previewView = previewViewOrNull ?: return@launch previewView.implementationMode = newImplementationMode.toImplementationMode() previewOutput?.let { previewOutput -> connectPreviewOutput(previewOutput) } } @@ -80,6 +84,7 @@ class HybridPreviewView( field = newResizeMode uiScope.launch { // Update scaleType + val previewView = previewViewOrNull ?: return@launch previewView.scaleType = newResizeMode.toScaleType() } } @@ -100,9 +105,30 @@ class HybridPreviewView( pixelRatio = context.resources.displayMetrics.density } + override fun onDropView() { + super.onDropView() + uiScope.launch { + releasePreviewView() + } + } + override fun dispose() { super.dispose() + uiScope.launch { + releasePreviewView() + } + } + + /** + * Detaches the `PreviewView` from its Preview Output and releases it. + * Safe to call from both `onDropView()` and `dispose()`. + */ + @UiThread + private fun releasePreviewView() { + val previewView = previewViewOrNull ?: return previewView.previewStreamState.removeObserver(this) + previewOutput?.let { previewOutput -> disconnectPreviewOutput(previewOutput) } + previewViewOrNull = null } override fun createMeteringPoint( @@ -168,7 +194,9 @@ class HybridPreviewView( private fun connectPreviewOutput(previewOutput: HybridCameraPreviewOutputSpec) { // 1. Downcast val previewOutput = previewOutput as? NativePreviewOutput ?: throw Error("PreviewOutput is not of type `NativePreviewOutput`!") - // 2. Set surfaceProvider on UI Thread + // 2. Skip if the PreviewView was already dropped + val previewView = previewViewOrNull ?: return + // 3. Set surfaceProvider on UI Thread previewOutput.setSurfaceProvider(previewView.surfaceProvider) } @@ -176,7 +204,9 @@ class HybridPreviewView( private fun disconnectPreviewOutput(previewOutput: HybridCameraPreviewOutputSpec) { // 1. Downcast val previewOutput = previewOutput as? NativePreviewOutput ?: throw Error("PreviewOutput is not of type `NativePreviewOutput`!") - // 2. Remove surfaceProvider on UI Thread + // 2. Skip if the PreviewView was already dropped + val previewView = previewViewOrNull ?: return + // 3. Remove surfaceProvider on UI Thread previewOutput.removeSurfaceProvider(previewView.surfaceProvider) } @@ -201,7 +231,7 @@ class HybridPreviewView( // since it's accessible from the UI-Thread only. private fun updateCameraSpaceToViewPixelsMatrix() { val matrix = - previewView.sensorToViewTransform + previewViewOrNull?.sensorToViewTransform ?: return this.cameraSpaceToViewPixelsMatrix = matrix }