mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 00:17:41 +01:00
Improve camera mixed mode handling and clean up dead code.
This commit is contained in:
committed by
Alex Hart
parent
3f067654d9
commit
36f7c60a99
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
113
feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt
Normal file
113
feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user