Kasem Mohamed
Committed by GitHub

Refactor and enable switching between cameras using physical id. (#676)

* Refactor and enable switching between cameras using physical id.

* Override cameraName

* Rename unused arguments in predicate

* Update documentation

* Add changeset

* Move camera lookup to CameraXSession

* Override isBackFacing and isFrontFacing

* update byte buddy to support java 21

* Refactor to fix unit tests

* Run spotlessApply

* Run spotlessApply on all plugins

* cleanup

---------

Co-authored-by: davidliu <davidliu@deviange.net>
---
"client-sdk-android": patch
---
Fix switchCamera not working if the camera id is physical id
... ...
... ... @@ -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" }
... ...
... ... @@ -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<out UseCase> = 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,
)
}
}
... ...
... ... @@ -35,14 +35,40 @@ class CameraXEnumerator(
context: Context,
private val lifecycleOwner: LifecycleOwner,
private val useCases: Array<out UseCase> = emptyArray(),
var physicalCameraId: String? = null,
) : Camera2Enumerator(context) {
override fun getDeviceNames(): Array<out String?> {
val cm = cameraManager!!
val availableCameraIds = ArrayList<String>()
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 {
... ...
... ... @@ -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(
... ...
... ... @@ -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<out UseCase> = 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 <T> ExtendableBuilder<T>.applyCameraSettings(): ExtendableBuilder<T> {
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)
... ...
/*
* 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 {
... ...
/*
* 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<VideoCapturer, LocalVideoTrackOptions>? {
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
}
... ...
... ... @@ -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
... ...