diff --git a/.changeset/gorgeous-ligers-suffer.md b/.changeset/gorgeous-ligers-suffer.md new file mode 100644 index 000000000..1f5026430 --- /dev/null +++ b/.changeset/gorgeous-ligers-suffer.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix switchCamera not working if the camera id is physical id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fdb7bdd86..cf5d87a90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,6 +94,7 @@ mockito-core = { module = "org.mockito:mockito-core", version = "4.11.0" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.1.0" } #noinspection GradleDependency mockito-inline = { module = "org.mockito:mockito-inline", version = "4.11.0" } +byte-buddy = { module = "net.bytebuddy:byte-buddy", version = "1.14.3" } robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" } turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" } diff --git a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXCapturer.kt b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXCapturer.kt index 6168cdcdf..cfeed6590 100644 --- a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXCapturer.kt +++ b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXCapturer.kt @@ -32,13 +32,12 @@ import kotlinx.coroutines.flow.StateFlow @ExperimentalCamera2Interop internal class CameraXCapturer( - context: Context, + enumerator: CameraXEnumerator, private val lifecycleOwner: LifecycleOwner, cameraName: String?, eventsHandler: CameraVideoCapturer.CameraEventsHandler?, private val useCases: Array = emptyArray(), - var physicalCameraId: String? = null, -) : CameraCapturer(cameraName, eventsHandler, CameraXEnumerator(context, lifecycleOwner)) { +) : CameraCapturer(cameraName, eventsHandler, enumerator) { @FlowObservable @get:FlowObservable @@ -94,7 +93,6 @@ internal class CameraXCapturer( height, framerate, useCases, - physicalCameraId, ) } } diff --git a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXEnumerator.kt b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXEnumerator.kt index d35d9fc82..e4083020d 100644 --- a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXEnumerator.kt +++ b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXEnumerator.kt @@ -35,14 +35,40 @@ class CameraXEnumerator( context: Context, private val lifecycleOwner: LifecycleOwner, private val useCases: Array = emptyArray(), - var physicalCameraId: String? = null, ) : Camera2Enumerator(context) { + override fun getDeviceNames(): Array { + val cm = cameraManager!! + val availableCameraIds = ArrayList() + for (id in cm.cameraIdList) { + availableCameraIds.add(id) + if (VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val characteristics = cm.getCameraCharacteristics(id) + for (physicalId in characteristics.physicalCameraIds) { + availableCameraIds.add(physicalId) + } + } + } + return availableCameraIds.toTypedArray() + } + + override fun isBackFacing(deviceName: String?): Boolean { + val characteristics = cameraManager!!.getCameraCharacteristics(deviceName!!) + val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING) + return lensFacing == CameraCharacteristics.LENS_FACING_BACK + } + + override fun isFrontFacing(deviceName: String?): Boolean { + val characteristics = cameraManager!!.getCameraCharacteristics(deviceName!!) + val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING) + return lensFacing == CameraCharacteristics.LENS_FACING_FRONT + } + override fun createCapturer( deviceName: String?, eventsHandler: CameraVideoCapturer.CameraEventsHandler?, ): CameraVideoCapturer { - return CameraXCapturer(context, lifecycleOwner, deviceName, eventsHandler, useCases, physicalCameraId) + return CameraXCapturer(this, lifecycleOwner, deviceName, eventsHandler, useCases) } companion object { diff --git a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXHelper.kt b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXHelper.kt index 2ba116aef..42e42a46a 100644 --- a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXHelper.kt +++ b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXHelper.kt @@ -18,7 +18,6 @@ package livekit.org.webrtc import android.content.Context import android.hardware.camera2.CameraManager -import android.os.Build import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.camera.core.UseCase import androidx.lifecycle.Lifecycle @@ -63,21 +62,16 @@ class CameraXHelper { ): VideoCapturer { val enumerator = provideEnumerator(context) val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - val deviceId = options.deviceId - var targetDeviceName: String? = null - if (deviceId != null) { - targetDeviceName = findCameraById(cameraManager, deviceId) - } - if (targetDeviceName == null) { - // Fallback to enumerator.findCamera which can't find camera by physical id but it will choose the closest one. - targetDeviceName = enumerator.findCamera(deviceId, options.position) - } - val targetVideoCapturer = enumerator.createCapturer(targetDeviceName, eventsHandler) as CameraXCapturer + + val targetDevice = enumerator.findCamera(options.deviceId, options.position) + val targetDeviceId = targetDevice?.deviceId + + val targetVideoCapturer = enumerator.createCapturer(targetDeviceId, eventsHandler) as CameraXCapturer return CameraXCapturerWithSize( targetVideoCapturer, cameraManager, - targetDeviceName, + targetDeviceId, eventsHandler, ) } @@ -85,23 +79,6 @@ class CameraXHelper { override fun isSupported(context: Context): Boolean { return Camera2Enumerator.isSupported(context) && lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED) } - - private fun findCameraById(cameraManager: CameraManager, deviceId: String): String? { - for (id in cameraManager.cameraIdList) { - if (id == deviceId) return id // This means the provided id is logical id. - - val characteristics = cameraManager.getCameraCharacteristics(id) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val ids = characteristics.physicalCameraIds - if (ids.contains(deviceId)) { - // This means the provided id is physical id. - enumerator?.physicalCameraId = deviceId - return id // This is its logical id. - } - } - } - return null - } } private fun getSupportedFormats( diff --git a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXSession.kt b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXSession.kt index 654169944..ef547c17d 100644 --- a/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXSession.kt +++ b/livekit-android-camerax/src/main/java/livekit/org/webrtc/CameraXSession.kt @@ -18,6 +18,7 @@ package livekit.org.webrtc import android.content.Context import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager import android.hardware.camera2.CameraMetadata import android.hardware.camera2.CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF import android.hardware.camera2.CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON @@ -25,6 +26,7 @@ import android.hardware.camera2.CameraMetadata.LENS_OPTICAL_STABILIZATION_MODE_O import android.hardware.camera2.CameraMetadata.LENS_OPTICAL_STABILIZATION_MODE_ON import android.hardware.camera2.CaptureRequest import android.os.Build +import android.os.Build.VERSION import android.os.Handler import android.util.Range import android.util.Size @@ -62,7 +64,6 @@ internal constructor( private val height: Int, private val frameRate: Int, private val useCases: Array = emptyArray(), - var physicalCameraId: String? = null, ) : CameraSession { private var state = SessionState.RUNNING @@ -106,6 +107,13 @@ internal constructor( } } + private val cameraDevice: CameraDeviceId + get() { + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + return findCamera(cameraManager, cameraId) + ?: throw IllegalArgumentException("Camera ID $cameraId not found") + } + init { cameraThreadHandler.post { start() @@ -160,7 +168,7 @@ internal constructor( // Select camera by ID val cameraSelector = CameraSelector.Builder() - .addCameraFilter { cameraInfo -> cameraInfo.filter { Camera2CameraInfo.from(it).cameraId == cameraId } } + .addCameraFilter { cameraInfo -> cameraInfo.filter { Camera2CameraInfo.from(it).cameraId == cameraDevice.deviceId } } .build() try { @@ -209,7 +217,7 @@ internal constructor( private fun ExtendableBuilder.applyCameraSettings(): ExtendableBuilder { val cameraExtender = Camera2Interop.Extender(this) - physicalCameraId?.let { physicalId -> + cameraDevice.physicalId?.let { physicalId -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { cameraExtender.setPhysicalCameraId(physicalId) } @@ -275,7 +283,7 @@ internal constructor( } private fun obtainCameraConfiguration() { - val camera = cameraProvider.availableCameraInfos.map { Camera2CameraInfo.from(it) }.first { it.cameraId == cameraId } + val camera = cameraProvider.availableCameraInfos.map { Camera2CameraInfo.from(it) }.first { it.cameraId == cameraDevice.deviceId } cameraOrientation = camera.getCameraCharacteristic(CameraCharacteristics.SENSOR_ORIENTATION) ?: -1 isCameraFrontFacing = camera.getCameraCharacteristic(CameraCharacteristics.LENS_FACING) == CameraMetadata.LENS_FACING_FRONT @@ -326,6 +334,30 @@ internal constructor( return (cameraOrientation + rotation) % 360 } + private data class CameraDeviceId(val deviceId: String, val physicalId: String?) + + private fun findCamera( + cameraManager: CameraManager, + deviceId: String, + ): CameraDeviceId? { + for (id in cameraManager.cameraIdList) { + // First check if deviceId is a direct logical camera ID + if (id == deviceId) return CameraDeviceId(id, null) + + // Then check if deviceId is a physical camera ID in a logical camera + if (VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val characteristic = cameraManager.getCameraCharacteristics(id) + + for (physicalId in characteristic.physicalCameraIds) { + if (deviceId == physicalId) { + return CameraDeviceId(id, physicalId) + } + } + } + } + return null + } + companion object { private const val TAG = "CameraXSession" private val cameraXStartTimeMsHistogram = Histogram.createCounts("WebRTC.Android.CameraX.StartTimeMs", 1, 10000, 50) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt index 030776516..fcc3c54ff 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,9 +27,9 @@ import io.livekit.android.memory.CloseableManager import io.livekit.android.memory.SurfaceTextureHelperCloser import io.livekit.android.room.DefaultsManager import io.livekit.android.room.track.video.CameraCapturerUtils +import io.livekit.android.room.track.video.CameraCapturerUtils.CameraDeviceInfo import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera -import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition import io.livekit.android.room.track.video.CameraCapturerWithSize import io.livekit.android.room.track.video.CaptureDispatchObserver import io.livekit.android.room.track.video.ScaleCropVideoProcessor @@ -179,26 +179,28 @@ constructor( return } - var targetDeviceId: String? = null + var targetDevice: CameraDeviceInfo? = null val enumerator = createCameraEnumerator(context) if (deviceId != null || position != null) { - targetDeviceId = enumerator.findCamera(deviceId, position, fallback = false) + targetDevice = enumerator.findCamera(deviceId, position, fallback = false) } - if (targetDeviceId == null) { + if (targetDevice == null) { val deviceNames = enumerator.deviceNames if (deviceNames.size < 2) { LKLog.w { "No available cameras to switch to!" } return } val currentIndex = deviceNames.indexOf(options.deviceId) - targetDeviceId = deviceNames[(currentIndex + 1) % deviceNames.size] + val targetDeviceId = deviceNames[(currentIndex + 1) % deviceNames.size] + targetDevice = enumerator.findCamera(targetDeviceId, fallback = false) } + val targetDeviceId = targetDevice?.deviceId fun updateCameraOptions() { val newOptions = options.copy( deviceId = targetDeviceId, - position = enumerator.getCameraPosition(targetDeviceId), + position = targetDevice?.position, ) options = newOptions } @@ -243,7 +245,7 @@ constructor( LKLog.w { "switching camera failed: $errorDescription" } } } - if (targetDeviceId == null) { + if (targetDevice == null) { LKLog.w { "No target camera found!" } return } else { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt index 4fb600d6a..b64544d52 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/video/CameraCapturerUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,9 +72,7 @@ object CameraCapturerUtils { private fun getCameraProvider(context: Context): CameraProvider { return cameraProviders .sortedByDescending { it.cameraVersion } - .first { - it.isSupported(context) - } + .first { it.isSupported(context) } } /** @@ -98,15 +96,15 @@ object CameraCapturerUtils { provider: CameraProvider, options: LocalVideoTrackOptions, ): Pair? { - val cameraEventsDispatchHandler = CameraEventsDispatchHandler() val cameraEnumerator = provider.provideEnumerator(context) - val targetDeviceName = cameraEnumerator.findCamera(options.deviceId, options.position) ?: return null + val cameraEventsDispatchHandler = CameraEventsDispatchHandler() + val targetDevice = cameraEnumerator.findCamera(options.deviceId, options.position) ?: return null val targetVideoCapturer = provider.provideCapturer(context, options, cameraEventsDispatchHandler) // back fill any missing information val newOptions = options.copy( - deviceId = targetDeviceName, - position = cameraEnumerator.getCameraPosition(targetDeviceName), + deviceId = targetDevice.deviceId, + position = targetDevice.position, ) if (targetVideoCapturer !is VideoCapturerWithSize) { @@ -130,13 +128,13 @@ object CameraCapturerUtils { options: LocalVideoTrackOptions, eventsHandler: CameraEventsDispatchHandler, ): VideoCapturer { - val targetDeviceName = enumerator.findCamera(options.deviceId, options.position) + val targetDevice = enumerator.findCamera(options.deviceId, options.position) // Cache supported capture formats ahead of time to avoid future camera locks. - Camera1Helper.getSupportedFormats(Camera1Helper.getCameraId(targetDeviceName)) - val targetVideoCapturer = enumerator.createCapturer(targetDeviceName, eventsHandler) + Camera1Helper.getSupportedFormats(Camera1Helper.getCameraId(targetDevice?.deviceId)) + val targetVideoCapturer = enumerator.createCapturer(targetDevice?.deviceId, eventsHandler) return Camera1CapturerWithSize( targetVideoCapturer as Camera1Capturer, - targetDeviceName, + targetDevice?.deviceId, eventsHandler, ) } @@ -149,10 +147,9 @@ object CameraCapturerUtils { override val cameraVersion = 2 - override fun provideEnumerator(context: Context): CameraEnumerator = - enumerator ?: Camera2Enumerator(context).also { - enumerator = it - } + override fun provideEnumerator(context: Context): CameraEnumerator = enumerator ?: Camera2Enumerator(context).also { + enumerator = it + } override fun provideCapturer( context: Context, @@ -160,12 +157,12 @@ object CameraCapturerUtils { eventsHandler: CameraEventsDispatchHandler, ): VideoCapturer { val enumerator = provideEnumerator(context) - val targetDeviceName = enumerator.findCamera(options.deviceId, options.position) - val targetVideoCapturer = enumerator.createCapturer(targetDeviceName, eventsHandler) + val targetDevice = enumerator.findCamera(options.deviceId, options.position) + val targetVideoCapturer = enumerator.createCapturer(targetDevice?.deviceId, eventsHandler) return Camera2CapturerWithSize( targetVideoCapturer as Camera2Capturer, context.getSystemService(Context.CAMERA_SERVICE) as CameraManager, - targetDeviceName, + targetDevice?.deviceId, eventsHandler, ) } @@ -184,55 +181,53 @@ object CameraCapturerUtils { deviceId: String? = null, position: CameraPosition? = null, fallback: Boolean = true, - ): String? { - var targetDeviceName: String? = null + ): CameraDeviceInfo? { + var targetDevice: CameraDeviceInfo? = null // Prioritize search by deviceId first if (deviceId != null) { - targetDeviceName = findCamera { deviceName -> deviceName == deviceId } + targetDevice = findCamera { id, _ -> + id == deviceId + } } // Search by camera position - if (targetDeviceName == null && position != null) { - targetDeviceName = findCamera { deviceName -> - getCameraPosition(deviceName) == position + if (targetDevice == null && position != null) { + targetDevice = findCamera { _, pos -> + pos == position } } // Fall back by choosing first available camera. - if (targetDeviceName == null && fallback) { - targetDeviceName = findCamera { true } + if (targetDevice == null && fallback) { + targetDevice = findCamera { _, _ -> true } } - if (targetDeviceName == null) { - return null - } - - return targetDeviceName + return targetDevice } + data class CameraDeviceInfo(val deviceId: String, val position: CameraPosition?) + /** - * Finds the device id of a camera that matches the [predicate]. + * Returns information about a camera by searching for the specified device ID. + * + * @param predicate with deviceId and position, return true if camera is found + * @return [CameraDeviceInfo] with camera id and position if found, null otherwise */ - fun CameraEnumerator.findCamera(predicate: (deviceName: String) -> Boolean): String? { - for (deviceName in deviceNames) { - if (predicate(deviceName)) { - return deviceName + fun CameraEnumerator.findCamera( + predicate: (deviceId: String, position: CameraPosition?) -> Boolean, + ): CameraDeviceInfo? { + for (id in deviceNames) { + val position = if (isFrontFacing(id)) { + CameraPosition.FRONT + } else if (isBackFacing(id)) { + CameraPosition.BACK + } else { + null } - } - return null - } - /** - * Returns the camera position of a camera, or null if neither front or back facing (e.g. external camera). - */ - fun CameraEnumerator.getCameraPosition(deviceName: String?): CameraPosition? { - if (deviceName == null) { - return null - } - if (isBackFacing(deviceName)) { - return CameraPosition.BACK - } else if (isFrontFacing(deviceName)) { - return CameraPosition.FRONT + if (predicate(id, position)) { + return CameraDeviceInfo(id, position) + } } return null } diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 16ec5b411..ecdd6180f 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -91,6 +91,7 @@ dependencies { implementation libs.androidx.test.core implementation libs.coroutines.test implementation libs.dagger.lib + implementation libs.byte.buddy kapt libs.dagger.compiler testImplementation libs.junit