Improve camera mixed mode handling and clean up dead code.

This commit is contained in:
Greyson Parrelli
2026-03-31 11:17:33 -04:00
committed by Alex Hart
parent 3f067654d9
commit 36f7c60a99
37 changed files with 396 additions and 1081 deletions

View File

@@ -21,7 +21,7 @@ dependencies {
lintChecks(project(":lintchecks"))
// Signal Core
implementation(project(":core:util-jvm"))
implementation(project(":core:util"))
implementation(project(":core:ui"))
implementation(project(":lib:glide"))

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera
enum class CameraCaptureMode {
ImageAndVideoSimultaneous,
ImageAndVideoExclusive,
ImageOnly
}

View File

@@ -76,7 +76,7 @@ fun CameraScreen(
modifier: Modifier = Modifier,
roundCorners: Boolean = true,
contentAlignment: Alignment = Alignment.Center,
enableVideoCapture: Boolean = true,
captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
enableQrScanning: Boolean = false,
content: @Composable BoxScope.() -> Unit = {}
) {
@@ -106,7 +106,7 @@ fun CameraScreen(
cameraProvider = cameraProvider,
surfaceProvider = surfaceProvider,
context = context,
enableVideoCapture = enableVideoCapture,
captureMode = captureMode,
enableQrScanning = enableQrScanning
)
)

View File

@@ -14,7 +14,7 @@ sealed interface CameraScreenEvents {
val cameraProvider: ProcessCameraProvider,
val surfaceProvider: Preview.SurfaceProvider,
val context: Context,
val enableVideoCapture: Boolean = true,
val captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
val enableQrScanning: Boolean = false
) : CameraScreenEvents

View File

@@ -87,7 +87,7 @@ class CameraScreenViewModel : ViewModel() {
private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var recording: Recording? = null
private var isLimitedBinding: Boolean = false
private var captureMode: CameraCaptureMode = CameraCaptureMode.ImageOnly
private var brightnessBeforeFlash: Float = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
private var brightnessWindow: WeakReference<Window>? = null
private var orientationListener: OrientationEventListener? = null
@@ -109,6 +109,8 @@ class CameraScreenViewModel : ViewModel() {
}
fun onEvent(event: CameraScreenEvents) {
logEvent(event)
val currentState = _state.value
when (event) {
is CameraScreenEvents.BindCamera -> {
@@ -138,6 +140,19 @@ class CameraScreenViewModel : ViewModel() {
}
}
private fun logEvent(event: CameraScreenEvents) {
when (event) {
is CameraScreenEvents.BindCamera -> Log.d(TAG, "[Event] BindCamera(captureMode=${event.captureMode}, enableQrScanning=${event.enableQrScanning})")
is CameraScreenEvents.TapToFocus -> Log.d(TAG, "[Event] TapToFocus(view=${event.viewX},${event.viewY}, surface=${event.surfaceX},${event.surfaceY})")
is CameraScreenEvents.PinchZoom -> Log.d(TAG, "[Event] PinchZoom(factor=${event.zoomFactor})")
is CameraScreenEvents.LinearZoom -> Log.d(TAG, "[Event] LinearZoom(${event.linearZoom})")
is CameraScreenEvents.SwitchCamera -> Log.d(TAG, "[Event] SwitchCamera")
is CameraScreenEvents.SetFlashMode -> Log.d(TAG, "[Event] SetFlashMode(${event.flashMode})")
is CameraScreenEvents.NextFlashMode -> Log.d(TAG, "[Event] NextFlashMode")
is CameraScreenEvents.ClearCaptureError -> Log.d(TAG, "[Event] ClearCaptureError")
}
}
/**
* Capture a photo.
* If using front camera with flash enabled but no hardware flash available,
@@ -240,7 +255,7 @@ class CameraScreenViewModel : ViewModel() {
output: VideoOutput,
onVideoCaptured: (VideoCaptureResult) -> Unit
) {
val capture = if (isLimitedBinding) rebindForVideoCapture() ?: return else videoCapture ?: return
val capture = videoCapture ?: rebindForVideoCapture() ?: return
recordingStartZoomRatio = _state.value.zoomRatio
@@ -314,7 +329,7 @@ class CameraScreenViewModel : ViewModel() {
// Clear recording
recording = null
if (isLimitedBinding) {
if (captureMode == CameraCaptureMode.ImageAndVideoExclusive) {
rebindToLastSuccessfulAttempt()
}
}
@@ -452,7 +467,7 @@ class CameraScreenViewModel : ViewModel() {
lastSuccessfulAttempt = attempt
imageCapture = attempt.imageCapture
videoCapture = attempt.videoCapture
isLimitedBinding = event.enableVideoCapture && attempt.videoCapture == null
captureMode = event.captureMode
} catch (e: Exception) {
Log.e(TAG, "Use case binding failed (attempt ${index + 1} of ${bindingAttempts.size})", e)
continue
@@ -499,7 +514,7 @@ class CameraScreenViewModel : ViewModel() {
.setResolutionSelector(resolutionSelector)
.build()
val videoCapture: VideoCapture<Recorder>? = if (event.enableVideoCapture && !FORCE_LIMITED_BINDING) {
val videoCapture: VideoCapture<Recorder>? = if (event.captureMode == CameraCaptureMode.ImageAndVideoSimultaneous && !FORCE_LIMITED_BINDING) {
buildVideoCapture()
} else {
null

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.camera2.CameraAccessException
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraMetadata
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.camera.camera2.internal.compat.CameraManagerCompat
import org.signal.core.util.MemoryFileDescriptor
import org.signal.core.util.logging.Log
object CameraXUtil {
private val TAG = Log.tag(CameraXUtil::class.java)
private const val VIDEO_DEBUG_LABEL = "video-capture"
private const val VIDEO_SIZE = 10L * 1024 * 1024
@Throws(MemoryFileDescriptor.MemoryFileException::class)
fun createVideoFileDescriptor(context: Context): MemoryFileDescriptor {
return MemoryFileDescriptor.newMemoryFileDescriptor(context, VIDEO_DEBUG_LABEL, VIDEO_SIZE)
}
private val CAMERA_HARDWARE_LEVEL_ORDERING = intArrayOf(
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL
)
@RequiresApi(24)
private val CAMERA_HARDWARE_LEVEL_ORDERING_24 = intArrayOf(
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3
)
@RequiresApi(28)
private val CAMERA_HARDWARE_LEVEL_ORDERING_28 = intArrayOf(
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3
)
fun isMixedModeSupported(context: Context): Boolean {
return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
}
fun getLowestSupportedHardwareLevel(context: Context): Int {
@SuppressLint("RestrictedApi")
val cameraManager = CameraManagerCompat.from(context.applicationContext).unwrap()
try {
var supported = maxHardwareLevel()
for (cameraId in cameraManager.cameraIdList) {
var hwLevel: Int? = null
try {
hwLevel = cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)
} catch (_: NullPointerException) {
// redmi device crash, assume lowest
}
if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
}
supported = smallerHardwareLevel(supported, hwLevel)
}
return supported
} catch (e: CameraAccessException) {
Log.w(TAG, "Failed to enumerate cameras", e)
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
}
}
private fun maxHardwareLevel(): Int {
return if (Build.VERSION.SDK_INT >= 24) {
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3
} else {
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL
}
}
private fun smallerHardwareLevel(levelA: Int, levelB: Int): Int {
val hardwareInfoOrdering: IntArray = getHardwareInfoOrdering()
for (hwInfo in hardwareInfoOrdering) {
if (levelA == hwInfo || levelB == hwInfo) {
return hwInfo
}
}
return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
}
private fun getHardwareInfoOrdering(): IntArray {
return when {
Build.VERSION.SDK_INT >= 28 -> CAMERA_HARDWARE_LEVEL_ORDERING_28
Build.VERSION.SDK_INT >= 24 -> CAMERA_HARDWARE_LEVEL_ORDERING_24
else -> CAMERA_HARDWARE_LEVEL_ORDERING
}
}
}

View File

@@ -86,7 +86,7 @@ class CameraScreenViewModelTest {
// ===========================================================================
private fun bindCamera(
enableVideoCapture: Boolean = true,
captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
enableQrScanning: Boolean = false
) = viewModel.onEvent(
CameraScreenEvents.BindCamera(
@@ -94,7 +94,7 @@ class CameraScreenViewModelTest {
cameraProvider = mockCameraProvider,
surfaceProvider = mockSurfaceProvider,
context = RuntimeEnvironment.getApplication(),
enableVideoCapture = enableVideoCapture,
captureMode = captureMode,
enableQrScanning = enableQrScanning
)
)
@@ -145,7 +145,7 @@ class CameraScreenViewModelTest {
fun `binding with all use cases binds video and QR on the first attempt`() {
val attempts = captureBindingAttempts()
bindCamera(enableVideoCapture = true, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true)
assertThat(attempts.size).isEqualTo(1)
assertThat(attempts[0].hasVideoCapture()).isTrue()
@@ -156,7 +156,7 @@ class CameraScreenViewModelTest {
fun `binding with no optional use cases binds only preview and image capture`() {
val attempts = captureBindingAttempts(failCount = 0)
bindCamera(enableVideoCapture = false, enableQrScanning = false)
bindCamera(captureMode = CameraCaptureMode.ImageOnly, enableQrScanning = false)
assertThat(attempts.size).isEqualTo(1)
assertThat(attempts[0].hasVideoCapture()).isFalse()
@@ -171,7 +171,7 @@ class CameraScreenViewModelTest {
fun `when first attempt fails with video and QR, second attempt drops video but keeps QR`() {
val attempts = captureBindingAttempts(failCount = 1)
bindCamera(enableVideoCapture = true, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true)
assertThat(attempts.size).isEqualTo(2)
assertThat(attempts[0].hasVideoCapture()).isTrue()
@@ -184,7 +184,7 @@ class CameraScreenViewModelTest {
fun `when first two attempts fail with video and QR, third attempt drops both`() {
val attempts = captureBindingAttempts(failCount = 2)
bindCamera(enableVideoCapture = true, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true)
assertThat(attempts.size).isEqualTo(3)
assertThat(attempts[2].hasVideoCapture()).isFalse()
@@ -195,7 +195,7 @@ class CameraScreenViewModelTest {
fun `when all attempts fail, all three use case combinations are tried`() {
val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE)
bindCamera(enableVideoCapture = true, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true)
assertThat(attempts.size).isEqualTo(3)
}
@@ -204,7 +204,7 @@ class CameraScreenViewModelTest {
fun `with only video requested, fallback drops video and nothing else`() {
val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE)
bindCamera(enableVideoCapture = true, enableQrScanning = false)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false)
assertThat(attempts.size).isEqualTo(2)
assertThat(attempts[0].hasVideoCapture()).isTrue()
@@ -216,7 +216,7 @@ class CameraScreenViewModelTest {
fun `with only QR requested, fallback drops QR and nothing else`() {
val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE)
bindCamera(enableVideoCapture = false, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageOnly, enableQrScanning = true)
assertThat(attempts.size).isEqualTo(2)
assertThat(attempts[0].hasImageAnalysis()).isTrue()
@@ -228,7 +228,7 @@ class CameraScreenViewModelTest {
fun `each failed binding attempt calls unbindAll before retrying`() {
captureBindingAttempts(failCount = 2)
bindCamera(enableVideoCapture = true, enableQrScanning = true)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true)
// unbindAll called once before each of the 3 attempts
verify(exactly = 3) { mockCameraProvider.unbindAll() }
@@ -242,7 +242,7 @@ class CameraScreenViewModelTest {
fun `when video was dropped during initial binding, startRecording rebinds with video`() {
// Initial bind: first attempt (with video) fails, second (without) succeeds → limited mode
captureBindingAttempts(failCount = 1)
bindCamera(enableVideoCapture = true, enableQrScanning = false)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false)
val postInitAttempts = captureBindingAttempts()
@@ -259,7 +259,7 @@ class CameraScreenViewModelTest {
@Test
fun `in normal binding mode, startRecording does not rebind`() {
captureBindingAttempts()
bindCamera(enableVideoCapture = true, enableQrScanning = false)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false)
val postInitAttempts = captureBindingAttempts()
@@ -275,7 +275,7 @@ class CameraScreenViewModelTest {
@Test
fun `when the video rebind fails, restores the last successful use case set`() {
captureBindingAttempts(failCount = 1)
bindCamera(enableVideoCapture = true, enableQrScanning = false)
bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false)
// Both the failed video rebind and the restore attempt are captured here
val postInitAttempts = captureBindingAttempts(failCount = Int.MAX_VALUE)