From f4a87e9469fef30df56162fbfe22d4842a1ac785 Mon Sep 17 00:00:00 2001 From: v Date: Mon, 7 Sep 2026 03:48:40 +0200 Subject: [PATCH] fix(android): Destroy the session lifecycle owner on reconfigure --- .../__tests__/visioncamera.session.harness.ts | 44 +++++++++++++++++++ .../camera/hybrids/HybridCameraSession.kt | 23 +++++++--- .../nitro/camera/utils/CustomLifecycle.kt | 3 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.session.harness.ts b/apps/simple-camera/__tests__/visioncamera.session.harness.ts index 6154f5c9cf..0ac318401e 100644 --- a/apps/simple-camera/__tests__/visioncamera.session.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.session.harness.ts @@ -375,6 +375,50 @@ describe('VisionCamera - Session', () => { await session.stop() }) + it('starts again after all connections were removed', async () => { + const device = factory.getDefaultCamera('back') + assert.exists(device, 'no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const connection = { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' as const }], + constraints: [], + } + + let started = deferred() + const startSub = session.addOnStartedListener(() => { + started.resolve() + }) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + }) + + try { + await session.configure([connection]) + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + + started = deferred() + await session.configure([]) + await session.stop() + + await session.configure([connection]) + await session.start() + await withTimeout(started.promise, 10_000, 'session restart') + } finally { + await session.stop() + startSub.remove() + errorSub.remove() + } + }) + it('supports a multi-cam session when the platform allows it', async (context) => { if (!VisionCamera.supportsMultiCamSessions) { return context.skip('multi-cam session: not supported on this platform') diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt index ab21e76c67..66314d1e63 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt @@ -37,7 +37,8 @@ class HybridCameraSession( ActiveCameraSession.LifecycleListener { private val context: ReactApplicationContext get() = NitroModules.applicationContext ?: throw Error("No Context!") - private val lifecycleOwner = CustomLifecycle(context) + private var lifecycleOwner: CustomLifecycle? = null + private var isActive = false private val uiScope = CoroutineScope(Dispatchers.Main) override val isRunning: Boolean @@ -66,6 +67,9 @@ class HybridCameraSession( cameraProvider.unbindAll() activeSession?.close() activeSession = null + // Only `destroy()` releases the owner from CameraX and the `ReactApplicationContext` + lifecycleOwner?.destroy() + lifecycleOwner = null // 2. Build up Camera depending on connections count when (connections.size) { @@ -87,7 +91,9 @@ class HybridCameraSession( connection.onSessionConfigSelected(hybridConfig) } - val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraInfo.cameraSelector, config.sessionConfig) + val owner = CustomLifecycle(context, isActive) + lifecycleOwner = owner + val camera = cameraProvider.bindToLifecycle(owner, cameraInfo.cameraSelector, config.sessionConfig) // Notify outputs that their use-cases are now attached to the Camera config.preparedUseCases.forEach { it.notifyAttached() } activeSession = ActiveCameraSessionSingle(camera, this) @@ -100,6 +106,8 @@ class HybridCameraSession( // TODO: In Multi-Cam we cannot use the CameraX `SessionConfig` API, so we cannot use `ConstraintsResolver`! // This effectively means that no special features (FPS, HDR, Stabilization, ...) are supported in multi-cam... :( // Implement this once CameraX supports it - feature request: https://issuetracker.google.com/issues/470629644 + val owner = CustomLifecycle(context, isActive) + lifecycleOwner = owner val allPreparedUseCases = mutableListOf() val configs = connections.map { connection -> @@ -117,7 +125,7 @@ class HybridCameraSession( ConcurrentCamera.SingleCameraConfig( cameraInfo.cameraSelector, useCaseGroup.build(), - lifecycleOwner, + owner, ) } Log.i(TAG, "Binding configs: ${configs.joinToString { it.useCaseGroup.useCases.joinToString { it.name } }}") @@ -137,13 +145,15 @@ class HybridCameraSession( override fun start(): Promise { return Promise.async(uiScope) { - lifecycleOwner.setActive(true) + isActive = true + lifecycleOwner?.setActive(true) } } override fun stop(): Promise { return Promise.async(uiScope) { - lifecycleOwner.setActive(false) + isActive = false + lifecycleOwner?.setActive(false) } } @@ -151,7 +161,8 @@ class HybridCameraSession( super.dispose() Log.i(TAG, "Destroying CameraSession...") Promise.async(uiScope) { - lifecycleOwner.destroy() + lifecycleOwner?.destroy() + lifecycleOwner = null activeSession?.close() cameraProvider.unbindAll() } diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/utils/CustomLifecycle.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/utils/CustomLifecycle.kt index baf31f05b7..1988f24a4c 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/utils/CustomLifecycle.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/utils/CustomLifecycle.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.withContext */ class CustomLifecycle( private val context: ReactApplicationContext, + initialIsActive: Boolean, ) : LifecycleEventListener, LifecycleOwner { companion object { @@ -29,7 +30,7 @@ class CustomLifecycle( private val lifecycleRegistry = LifecycleRegistry(this) private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - @Volatile private var isActive: Boolean = false + @Volatile private var isActive: Boolean = initialIsActive @Volatile private var isDestroyed = false