From 14865b7b5ece9df1035a6baeb550c7cd0b5f1a27 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 11 Nov 2026 17:46:23 -0800 Subject: [PATCH 1/2] Sync camera preview rotation with windowRotation and decouple compact viewfinder --- .../concurrent/ConcurrentCameraSession.kt | 3 +- .../core/camera/CameraSession.kt | 84 ++++-- .../core/camera/CameraSessionSettings.kt | 7 +- .../jetpackcamera/core/camera/CameraSystem.kt | 8 + .../core/camera/CameraXCameraSystem.kt | 127 ++++---- .../core/camera/CameraSessionTest.kt | 147 ++++++++++ .../core/camera/testing/FakeCameraSystem.kt | 6 + .../feature/preview/PreviewScreen.kt | 1 + .../components/capture/CameraSessionEffect.kt | 36 ++- .../capture/CaptureScreenComponents.kt | 87 ++++-- .../capture/CompactPortraitLayout.kt | 270 ++++++++++++++++-- .../components/capture/RotationModifiers.kt | 8 + .../components/capture/CaptureScaffoldTest.kt | 46 +++ .../components/capture/LayoutRotationTest.kt | 236 ++++++++++++--- .../components/capture/PreviewDisplayTest.kt | 69 ++++- .../controller/impl/CameraControllerImpl.kt | 6 + .../ui/controller/CameraController.kt | 8 + .../testing/FakeCameraController.kt | 8 +- .../testing/FakeCameraControllerTest.kt | 10 + 19 files changed, 986 insertions(+), 181 deletions(-) diff --git a/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt b/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt index 084cbe89d7..03e4a65046 100644 --- a/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt +++ b/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt @@ -147,7 +147,8 @@ internal suspend fun CameraSessionContext.runConcurrentCameraSession( captureMode = sessionSettings.captureMode, videoCaptureUseCase = videoCapture, previewMirrorMode = sessionSettings.mirrorMode - .toCXConcurrentPreviewMirrorMode() + .toCXConcurrentPreviewMirrorMode(), + windowRotation = sessionSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) } diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index cd1fb74050..99f905d70d 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -302,7 +302,8 @@ internal suspend fun runSingleCameraSession( imageFormat = sessionSettings.imageFormat, captureMode = sessionSettings.captureMode, effect = effect, - captureResults = captureResults + captureResults = captureResults, + windowRotation = sessionSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) @@ -740,11 +741,9 @@ fun CameraSessionContext.applyDeviceRotation( useCaseGroup.useCases.forEach { when (it) { is Preview -> { - // Preview's target rotation should not be updated with device rotation. - // Instead, preview rotation should match the display rotation. - // When Preview is created, it is initialized with the display rotation. - // This will need to be updated separately if the display rotation is not - // locked. Currently the app is locked to portrait orientation. + // Preview's target rotation, ViewPort, and StreamSharing OutConfigs track + // PerpetualSessionSettings.windowRotation via session rebind rather than physical + // device gravity rotation. } is ImageCapture -> { @@ -770,15 +769,17 @@ fun createUseCaseGroup( captureMode: CaptureMode, effect: CameraEffect? = null, captureResults: MutableStateFlow? = null, - previewMirrorMode: Int = CX_SINGLE_CAMERA_PREVIEW_MIRROR_MODE + previewMirrorMode: Int = CX_SINGLE_CAMERA_PREVIEW_MIRROR_MODE, + windowRotation: DeviceRotation = sessionSettings?.windowRotation ?: DeviceRotation.Natural ): UseCaseGroup { val previewUseCase = createPreviewUseCase( - cameraInfo, - aspectRatio, - stabilizationMode, - previewMirrorMode, - captureResults + cameraInfo = cameraInfo, + aspectRatio = aspectRatio, + stabilizationMode = stabilizationMode, + previewMirrorMode = previewMirrorMode, + captureResults = captureResults, + windowRotation = windowRotation ) // only create image use case in image or standard @@ -799,14 +800,15 @@ fun createUseCaseGroup( return UseCaseGroup.Builder().apply { Log.d( TAG, - "Setting initial device rotation to ${initialTransientSettings.deviceRotation}" + "Setting initial device rotation to ${initialTransientSettings.deviceRotation}, " + + "window rotation to $windowRotation" ) setViewPort( - ViewPort.Builder( - Rational(aspectRatio.numerator, aspectRatio.denominator), - // Initialize rotation to Preview's rotation, which comes from Display rotation - previewUseCase.targetRotation - ).build() + createSensorAlignedViewPort( + cameraInfo = cameraInfo, + aspectRatio = aspectRatio, + targetRotation = previewUseCase.targetRotation + ) ) addUseCase(previewUseCase) @@ -818,6 +820,47 @@ fun createUseCaseGroup( }.build() } +/** + * Builds a [ViewPort] aligned with [targetRotation] whose aspect-ratio [Rational] matches the + * camera sensor's upright orientation at [targetRotation]. + */ +internal fun createSensorAlignedViewPort( + cameraInfo: CameraInfo, + aspectRatio: AspectRatio, + targetRotation: Int +): ViewPort = ViewPort.Builder( + resolveViewPortAspectRatio( + cameraInfo = cameraInfo, + aspectRatio = aspectRatio, + targetRotation = targetRotation + ), + targetRotation +).build() + +/** + * Applies [windowRotation] as the target rotation of this [Preview.Builder]. + */ +internal fun Preview.Builder.applyWindowRotation(windowRotation: DeviceRotation): Preview.Builder = + setTargetRotation(windowRotation.toUiSurfaceRotation()) + +/** + * Resolves the [ViewPort] aspect-ratio [Rational] in upright target-rotation coordinates using + * [CameraInfo.getSensorRotationDegrees] so landscape sessions on landscape sensors frame `4:3` / + * `16:9` rather than cropping a portrait strip. + */ +internal fun resolveViewPortAspectRatio( + cameraInfo: CameraInfo, + aspectRatio: AspectRatio, + targetRotation: Int +): Rational { + val sensorRotationDegrees = cameraInfo.getSensorRotationDegrees(targetRotation) + return if (sensorRotationDegrees % 180 == 0) { + Rational(aspectRatio.denominator, aspectRatio.numerator) + } else { + Rational(aspectRatio.numerator, aspectRatio.denominator) + } +} + /** * Maps a given resolution [Size] to its corresponding [VideoQuality] based on the short edge. * @@ -946,8 +989,11 @@ private fun createPreviewUseCase( aspectRatio: AspectRatio, stabilizationMode: StabilizationMode, previewMirrorMode: Int, - captureResults: MutableStateFlow? = null + captureResults: MutableStateFlow? = null, + windowRotation: DeviceRotation = DeviceRotation.Natural ): Preview = Preview.Builder().apply { + applyWindowRotation(windowRotation) + updateCameraStateWithCaptureResults( targetCameraInfo = cameraInfo, captureResults = captureResults diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt index e43ed50d7c..069215802d 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt @@ -40,6 +40,7 @@ import com.google.jetpackcamera.model.VideoQuality sealed interface PerpetualSessionSettings { val aspectRatio: AspectRatio val captureMode: CaptureMode + val windowRotation: DeviceRotation data class SingleCamera( override val aspectRatio: AspectRatio, @@ -52,7 +53,8 @@ sealed interface PerpetualSessionSettings { val mirrorMode: MirrorMode, val imageFormat: ImageOutputFormat, val lowLightBoostPriority: LowLightBoostPriority, - val activeCaptureSubMode: CaptureSubModeFeatureKey? = null + val activeCaptureSubMode: CaptureSubModeFeatureKey? = null, + override val windowRotation: DeviceRotation = DeviceRotation.Natural ) : PerpetualSessionSettings /** @@ -62,7 +64,8 @@ sealed interface PerpetualSessionSettings { val subModeKey: CaptureSubModeFeatureKey, override val aspectRatio: AspectRatio, override val captureMode: CaptureMode, - val mirrorMode: MirrorMode = MirrorMode.OFF + val mirrorMode: MirrorMode = MirrorMode.OFF, + override val windowRotation: DeviceRotation = DeviceRotation.Natural ) : PerpetualSessionSettings } diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt index 8322c2ce1b..22b7baa4bd 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt @@ -232,6 +232,14 @@ interface CameraSystem { */ fun setDeviceRotation(deviceRotation: DeviceRotation) + /** + * Sets the host OS display/window rotation used to orient the live preview stream, + * [androidx.camera.core.ViewPort], and stream-sharing OpenGL output buffers. + * + * @param windowRotation The host display/window [DeviceRotation] to set. + */ + fun setWindowRotation(windowRotation: DeviceRotation) {} + /** * Sets the image output format. * diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index 3ce2d87beb..f6b25c3cd4 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -102,6 +102,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map @@ -150,6 +151,7 @@ class CameraXCameraSystem( private val videoCaptureControlEvents = Channel() private val currentSettings = MutableStateFlow(null) + private val currentWindowRotation = MutableStateFlow(DeviceRotation.Natural) private var externalCaptureMode: ExternalCaptureMode = ExternalCaptureMode.Standard // Per-lens default sub-modes that failed to bind during the current initialization cycle. @@ -598,73 +600,76 @@ class CameraXCameraSystem( clearCameraError() val transientSettings = MutableStateFlow(null) - currentSettings - .filterNotNull() - .map { currentCameraSettings -> - transientSettings.value = TransientSessionSettings( - isAudioEnabled = currentCameraSettings.audioEnabled, - deviceRotation = currentCameraSettings.deviceRotation, - flashMode = currentCameraSettings.flashMode, - primaryLensFacing = currentCameraSettings.cameraLensFacing, - zoomRatios = currentCameraSettings.defaultZoomRatios, - testPattern = currentCameraSettings.debugSettings.testPattern - ) + combine( + currentSettings.filterNotNull(), + currentWindowRotation + ) { currentCameraSettings, windowRotation -> + transientSettings.value = TransientSessionSettings( + isAudioEnabled = currentCameraSettings.audioEnabled, + deviceRotation = currentCameraSettings.deviceRotation, + flashMode = currentCameraSettings.flashMode, + primaryLensFacing = currentCameraSettings.cameraLensFacing, + zoomRatios = currentCameraSettings.defaultZoomRatios, + testPattern = currentCameraSettings.debugSettings.testPattern + ) - val activeSubMode = - registeredSubModes[currentCameraSettings.activeCaptureSubModeId] - val activeSubModeBinding = activeSubMode?.provider?.sessionBinding - if (activeSubMode != null && - activeSubModeBinding is CameraSessionBinding.Custom - ) { - PerpetualSessionSettings.CustomSession( - subModeKey = activeSubMode.key, - aspectRatio = currentCameraSettings.aspectRatio, - captureMode = currentCameraSettings.captureMode, - mirrorMode = currentCameraSettings.mirrorMode + val activeSubMode = + registeredSubModes[currentCameraSettings.activeCaptureSubModeId] + val activeSubModeBinding = activeSubMode?.provider?.sessionBinding + if (activeSubMode != null && + activeSubModeBinding is CameraSessionBinding.Custom + ) { + PerpetualSessionSettings.CustomSession( + subModeKey = activeSubMode.key, + aspectRatio = currentCameraSettings.aspectRatio, + captureMode = currentCameraSettings.captureMode, + mirrorMode = currentCameraSettings.mirrorMode, + windowRotation = windowRotation + ) + } else { + val cameraConstraints = + systemConstraints.forCurrentLens(currentCameraSettings) + if (cameraConstraints == null) { + Log.e( + TAG, + "Could not retrieve constraints for " + + "${currentCameraSettings.cameraLensFacing}" ) - } else { - val cameraConstraints = - systemConstraints.forCurrentLens(currentCameraSettings) - if (cameraConstraints == null) { - Log.e( - TAG, - "Could not retrieve constraints for " + - "${currentCameraSettings.cameraLensFacing}" + currentCameraState.update { old -> + old.copy( + isCameraRunning = false, + cameraError = CameraError.CameraRemoved ) - currentCameraState.update { old -> - old.copy( - isCameraRunning = false, - cameraError = CameraError.CameraRemoved - ) - } - return@map null } + return@combine null + } - val resolvedStabilizationMode = resolveStabilizationMode( - requestedStabilizationMode = currentCameraSettings.stabilizationMode, - targetFrameRate = currentCameraSettings.targetFrameRate, - cameraConstraints = cameraConstraints - ) - - val activeCameraEffect = cameraEffectProviders.keys.firstOrNull { - it.id == currentCameraSettings.selectedCameraEffect - } + val resolvedStabilizationMode = resolveStabilizationMode( + requestedStabilizationMode = currentCameraSettings.stabilizationMode, + targetFrameRate = currentCameraSettings.targetFrameRate, + cameraConstraints = cameraConstraints + ) - PerpetualSessionSettings.SingleCamera( - aspectRatio = currentCameraSettings.aspectRatio, - captureMode = currentCameraSettings.captureMode, - activeCameraEffect = activeCameraEffect, - targetFrameRate = currentCameraSettings.targetFrameRate, - stabilizationMode = resolvedStabilizationMode, - dynamicRange = currentCameraSettings.dynamicRange, - videoQuality = currentCameraSettings.videoQuality, - mirrorMode = currentCameraSettings.mirrorMode, - imageFormat = currentCameraSettings.imageFormat, - lowLightBoostPriority = currentCameraSettings.lowLightBoostPriority, - activeCaptureSubMode = activeSubMode?.key - ) + val activeCameraEffect = cameraEffectProviders.keys.firstOrNull { + it.id == currentCameraSettings.selectedCameraEffect } + + PerpetualSessionSettings.SingleCamera( + aspectRatio = currentCameraSettings.aspectRatio, + captureMode = currentCameraSettings.captureMode, + activeCameraEffect = activeCameraEffect, + targetFrameRate = currentCameraSettings.targetFrameRate, + stabilizationMode = resolvedStabilizationMode, + dynamicRange = currentCameraSettings.dynamicRange, + videoQuality = currentCameraSettings.videoQuality, + mirrorMode = currentCameraSettings.mirrorMode, + imageFormat = currentCameraSettings.imageFormat, + lowLightBoostPriority = currentCameraSettings.lowLightBoostPriority, + activeCaptureSubMode = activeSubMode?.key, + windowRotation = windowRotation + ) } + } .distinctUntilChanged() .collectLatest { sessionSettings -> if (sessionSettings == null) return@collectLatest @@ -1394,6 +1399,10 @@ class CameraXCameraSystem( } } + override fun setWindowRotation(windowRotation: DeviceRotation) { + currentWindowRotation.value = windowRotation + } + override suspend fun setImageFormat(imageFormat: ImageOutputFormat) { currentSettings.update { old -> old?.withUserChange { copy(imageFormat = imageFormat) } diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt index 486230ad3c..74151daa48 100644 --- a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt +++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt @@ -13,12 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(InternalCameraApi::class) + package com.google.jetpackcamera.core.camera import android.graphics.Rect +import android.util.Rational import android.util.Size +import android.view.Surface +import androidx.camera.core.CameraInfo +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.core.UseCaseGroup import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.core.camera.submode.CaptureSubModeFeatureKey +import com.google.jetpackcamera.core.camera.testing.FakeCameraSystem +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.CaptureSubModeId +import com.google.jetpackcamera.model.DeviceRotation +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LowLightBoostPriority +import com.google.jetpackcamera.model.MirrorMode +import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality +import java.lang.reflect.Proxy import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -99,4 +120,130 @@ class CameraSessionTest { assertThat(getWidthFromCropRect(null, rotationDegrees = 90)).isEqualTo(0) assertThat(getHeightFromCropRect(null, rotationDegrees = 90)).isEqualTo(0) } + + @Test + fun previewAndViewPort_syncWithWindowRotation_independentlyOfDeviceRotation() { + val phoneSensorInfo = fakeBackCameraInfo(sensorOrientationDegrees = 90) + val tabletLandscapeSensorInfo = fakeBackCameraInfo(sensorOrientationDegrees = 0) + + val expectedPhoneViewPorts = mapOf( + DeviceRotation.Natural to (Surface.ROTATION_0 to Rational(3, 4)), + DeviceRotation.Rotated270 to (Surface.ROTATION_90 to Rational(4, 3)), + DeviceRotation.Rotated180 to (Surface.ROTATION_180 to Rational(3, 4)), + DeviceRotation.Rotated90 to (Surface.ROTATION_270 to Rational(4, 3)) + ) + val expectedTabletViewPorts = mapOf( + DeviceRotation.Natural to (Surface.ROTATION_0 to Rational(4, 3)), + DeviceRotation.Rotated270 to (Surface.ROTATION_90 to Rational(3, 4)), + DeviceRotation.Rotated180 to (Surface.ROTATION_180 to Rational(4, 3)), + DeviceRotation.Rotated90 to (Surface.ROTATION_270 to Rational(3, 4)) + ) + + // Covers foldables, landscape-native tablets, and portrait W <= H split-screen panes on + // Rotated90 / Rotated270 displays across independent physical deviceRotations. + for (windowRot in DeviceRotation.entries) { + for (deviceRot in DeviceRotation.entries) { + val (expectedSurfaceRot, expectedPhoneRatio) = + expectedPhoneViewPorts.getValue(windowRot) + + val phonePreview = Preview.Builder().applyWindowRotation(windowRot).build() + val phoneImage = ImageCapture.Builder() + .setTargetRotation(deviceRot.toUiSurfaceRotation()) + .build() + val phoneViewPort = createSensorAlignedViewPort( + cameraInfo = phoneSensorInfo, + aspectRatio = AspectRatio.THREE_FOUR, + targetRotation = phonePreview.targetRotation + ) + val phoneGroup = UseCaseGroup.Builder() + .setViewPort(phoneViewPort) + .addUseCase(phonePreview) + .addUseCase(phoneImage) + .build() + + assertThat(phonePreview.targetRotation).isEqualTo(expectedSurfaceRot) + assertThat(phoneGroup.viewPort!!.rotation).isEqualTo(expectedSurfaceRot) + assertThat(phoneGroup.viewPort!!.aspectRatio).isEqualTo(expectedPhoneRatio) + assertThat(phoneImage.targetRotation).isEqualTo(deviceRot.toUiSurfaceRotation()) + + val (_, expectedTabletRatio) = expectedTabletViewPorts.getValue(windowRot) + val tabletPreview = Preview.Builder().applyWindowRotation(windowRot).build() + val tabletViewPort = createSensorAlignedViewPort( + cameraInfo = tabletLandscapeSensorInfo, + aspectRatio = AspectRatio.THREE_FOUR, + targetRotation = tabletPreview.targetRotation + ) + assertThat(tabletPreview.targetRotation).isEqualTo(expectedSurfaceRot) + assertThat(tabletViewPort.rotation).isEqualTo(expectedSurfaceRot) + assertThat(tabletViewPort.aspectRatio).isEqualTo(expectedTabletRatio) + } + } + } + + @Test + fun perpetualSessionSettingsAndFakeCameraSystem_trackWindowRotationAcrossSessionTypes() { + val baseSingle = PerpetualSessionSettings.SingleCamera( + aspectRatio = AspectRatio.THREE_FOUR, + captureMode = CaptureMode.STANDARD, + activeCameraEffect = null, + targetFrameRate = 30, + stabilizationMode = StabilizationMode.OFF, + dynamicRange = DynamicRange.SDR, + videoQuality = VideoQuality.FHD, + mirrorMode = MirrorMode.OFF, + imageFormat = ImageOutputFormat.JPEG, + lowLightBoostPriority = LowLightBoostPriority.PRIORITIZE_AE_MODE + ) + assertThat(baseSingle.windowRotation).isEqualTo(DeviceRotation.Natural) + assertThat(baseSingle.copy(windowRotation = DeviceRotation.Rotated90)) + .isNotEqualTo(baseSingle) + + val testKey = object : CaptureSubModeFeatureKey { + override val id: CaptureSubModeId = CaptureSubModeId("test_concurrent") + } + val baseCustom = PerpetualSessionSettings.CustomSession( + subModeKey = testKey, + aspectRatio = AspectRatio.NINE_SIXTEEN, + captureMode = CaptureMode.STANDARD + ) + assertThat(baseCustom.windowRotation).isEqualTo(DeviceRotation.Natural) + assertThat(baseCustom.copy(windowRotation = DeviceRotation.Rotated270)) + .isNotEqualTo(baseCustom) + + val fakeSystem = FakeCameraSystem() + assertThat(fakeSystem.windowRotation).isEqualTo(DeviceRotation.Natural) + fakeSystem.setWindowRotation(DeviceRotation.Rotated180) + assertThat(fakeSystem.windowRotation).isEqualTo(DeviceRotation.Rotated180) + } + + private fun fakeBackCameraInfo(sensorOrientationDegrees: Int): CameraInfo = + Proxy.newProxyInstance( + CameraInfo::class.java.classLoader, + arrayOf(CameraInfo::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString" -> "FakeCameraInfo($sensorOrientationDegrees)" + "getLensFacing" -> CameraSelector.LENS_FACING_BACK + "getSensorRotationDegrees" -> { + val relativeSurfaceRotation = + (args?.firstOrNull() as? Int) ?: Surface.ROTATION_0 + val surfaceCounterClockwiseDegrees = when (relativeSurfaceRotation) { + Surface.ROTATION_0 -> 0 + Surface.ROTATION_90 -> 90 + Surface.ROTATION_180 -> 180 + Surface.ROTATION_270 -> 270 + else -> 0 + } + (sensorOrientationDegrees - surfaceCounterClockwiseDegrees + 360) % 360 + } + else -> when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Float::class.javaPrimitiveType -> 0f + else -> null + } + } + } as CameraInfo } diff --git a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt index b26d3c6a09..1994dbf4b7 100644 --- a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt +++ b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt @@ -77,6 +77,8 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti private var screenFlashEvents = Channel(capacity = UNLIMITED) private val zoomChanges = MutableStateFlow(null) private val currentSettings = MutableStateFlow(defaultCameraSettings) + var windowRotation: DeviceRotation = DeviceRotation.Natural + private set var externalCaptureMode: ExternalCaptureMode = ExternalCaptureMode.Standard private set @@ -277,6 +279,10 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti } } + override fun setWindowRotation(windowRotation: DeviceRotation) { + this.windowRotation = windowRotation + } + override suspend fun setImageFormat(imageFormat: ImageOutputFormat) { currentSettings.update { old -> old.copy(imageFormat = imageFormat) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 5a44ccda7f..4e5548481f 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -106,6 +106,7 @@ fun PreviewScreen( cameraController = viewModel.cameraController, enabled = true, isRecording = isRecordingInProgress.value, + windowRotation = windowRotation, onDeviceRotationChanged = { deviceRotation = it }, onSessionStarted = viewModel::startLocationUpdates, onSessionStopped = viewModel::stopLocationUpdates diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt index e2c5a63f40..2fb718e56e 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt @@ -47,18 +47,22 @@ import com.google.jetpackcamera.ui.controller.CameraController * lifecycle stops, [enabled] becomes `false`, or the effect leaves the composition. * 2. Collects [debouncedOrientationFlow] and forwards device rotation changes to * [CameraController.setDisplayRotation] and [onDeviceRotationChanged] while [isRecording] is - * `false`. - * 3. While [isRecording] is `true`, freezes display rotation updates and locks the host - * [Activity]'s `requestedOrientation` to [ActivityInfo.SCREEN_ORIENTATION_LOCKED], restoring its - * previous `requestedOrientation` and flushing any pending sensor rotation once recording ends. + * `false`, and forwards [windowRotation] changes to [CameraController.setWindowRotation] while + * [isRecording] is `false`. + * 3. While [isRecording] is `true`, freezes both device and window rotation updates and locks the + * host [Activity]'s `requestedOrientation` to [ActivityInfo.SCREEN_ORIENTATION_LOCKED], + * restoring its previous `requestedOrientation` and flushing any pending device or window + * rotation once recording ends. * * [enabled] has no default value so that host screens make an explicit readiness or runtime * permission decision before starting the camera session. * - * @param cameraController the [CameraController] to start, stop, and update with display rotation. + * @param cameraController the [CameraController] to start, stop, and update with rotation changes. * @param enabled whether the camera session is allowed to run (for example, `true` once required * camera permissions have been granted). * @param isRecording whether an active or starting video recording session is in progress. + * @param windowRotation active OS display/window [DeviceRotation] used for preview target rotation + * and viewport framing. * @param onDeviceRotationChanged optional callback notified with the active (unfrozen) physical * [DeviceRotation]. * @param onSessionStarted optional callback invoked right after [CameraController.startCamera]. @@ -69,6 +73,7 @@ fun CameraSessionEffect( cameraController: CameraController, enabled: Boolean, isRecording: Boolean = false, + windowRotation: DeviceRotation = rememberWindowRotation(), onDeviceRotationChanged: (DeviceRotation) -> Unit = {}, onSessionStarted: () -> Unit = {}, onSessionStopped: () -> Unit = {} @@ -98,6 +103,12 @@ fun CameraSessionEffect( var lastDeliveredRotation by remember(cameraController) { mutableStateOf(null) } + var pendingWindowRotation by remember(cameraController) { + mutableStateOf(null) + } + var lastDeliveredWindowRotation by remember(cameraController) { + mutableStateOf(null) + } LaunchedEffect(cameraController, context, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -112,6 +123,16 @@ fun CameraSessionEffect( } } + LaunchedEffect(cameraController, lifecycleOwner, windowRotation) { + pendingWindowRotation = windowRotation + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + if (!currentIsRecording && windowRotation != lastDeliveredWindowRotation) { + lastDeliveredWindowRotation = windowRotation + cameraController.setWindowRotation(windowRotation) + } + } + } + LaunchedEffect(isRecording, cameraController) { if (!isRecording) { val rotation = pendingRotation @@ -120,6 +141,11 @@ fun CameraSessionEffect( cameraController.setDisplayRotation(rotation) currentOnDeviceRotationChanged(rotation) } + val winRot = pendingWindowRotation + if (winRot != null && winRot != lastDeliveredWindowRotation) { + lastDeliveredWindowRotation = winRot + cameraController.setWindowRotation(winRot) + } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 4e01397bae..5a3ef99069 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -149,6 +149,7 @@ import androidx.compose.ui.unit.sp import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality @@ -921,6 +922,7 @@ fun PreviewDisplay( ) val layoutSolution = LocalCameraLayoutSolution.current + val compactCounterRotation = LocalCompactCounterRotation.current var imageVisible by remember { mutableStateOf(true) } val targetBoundsState = LocalOverlapTargetBounds.current @@ -972,35 +974,68 @@ fun PreviewDisplay( .layout { measurable, constraints -> val maxWidth = constraints.maxWidth.toDp() val maxHeight = constraints.maxHeight.toDp() - val solvedBand = if (maxHeight > maxWidth) { - layoutSolution?.viewfinderBandFor(aspectRatio.toFloat()) - } else { - null - } - val topOffsetPx = (solvedBand?.top ?: 0.dp).roundToPx() - .coerceIn(0, constraints.maxHeight) - val size = if (solvedBand != null) { - DpSize(maxWidth, solvedBand.height) + val isPortraitBounds = maxHeight >= maxWidth + val solvedBand = layoutSolution?.viewfinderBandFor(aspectRatio.toFloat()) + if (solvedBand != null && isPortraitBounds) { + val topOffsetPx = solvedBand.top.roundToPx() + .coerceIn(0, constraints.maxHeight) + val widthPx = maxWidth.roundToPx().coerceIn(0, constraints.maxWidth) + val heightPx = solvedBand.height.roundToPx() + .coerceIn(0, (constraints.maxHeight - topOffsetPx).coerceAtLeast(0)) + val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx)) + val totalHeight = + (topOffsetPx + placeable.height).coerceAtMost(constraints.maxHeight) + layout( + width = placeable.width, + height = totalHeight, + alignmentLines = mapOf(ViewfinderTopLine to topOffsetPx) + ) { + placeable.place(0, topOffsetPx) + } + } else if (solvedBand != null) { + val portraitTopOffsetPx = solvedBand.top.roundToPx() + .coerceIn(0, constraints.maxWidth) + val widthPx = solvedBand.height.roundToPx() + .coerceIn( + 0, + (constraints.maxWidth - portraitTopOffsetPx).coerceAtLeast(0) + ) + val heightPx = maxHeight.roundToPx().coerceIn(0, constraints.maxHeight) + val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx)) + val leftOffsetPx = if ( + compactCounterRotation == DeviceRotation.Rotated90 + ) { + (constraints.maxWidth - portraitTopOffsetPx - placeable.width) + .coerceAtLeast(0) + } else { + portraitTopOffsetPx + } + val verticalOffsetPx = (constraints.maxHeight - placeable.height) / 2 + layout( + width = constraints.maxWidth, + height = placeable.height, + alignmentLines = mapOf(ViewfinderTopLine to portraitTopOffsetPx) + ) { + placeable.place(leftOffsetPx, verticalOffsetPx) + } } else { - calculateViewfinderSize( + val size = calculateViewfinderSize( maxWidth = maxWidth, maxHeight = maxHeight, aspectRatio = aspectRatio, useLandscapeAspectRatio = useLandscapeAspectRatio ) - } - val widthPx = size.width.roundToPx().coerceIn(0, constraints.maxWidth) - val heightPx = size.height.roundToPx() - .coerceIn(0, (constraints.maxHeight - topOffsetPx).coerceAtLeast(0)) - val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx)) - val totalHeight = - (topOffsetPx + placeable.height).coerceAtMost(constraints.maxHeight) - layout( - width = placeable.width, - height = totalHeight, - alignmentLines = mapOf(ViewfinderTopLine to topOffsetPx) - ) { - placeable.place(0, topOffsetPx) + val widthPx = size.width.roundToPx().coerceIn(0, constraints.maxWidth) + val heightPx = size.height.roundToPx() + .coerceIn(0, constraints.maxHeight) + val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx)) + layout( + width = placeable.width, + height = placeable.height, + alignmentLines = mapOf(ViewfinderTopLine to 0) + ) { + placeable.place(0, 0) + } } } .testTag(PREVIEW_DISPLAY) @@ -1021,11 +1056,9 @@ fun PreviewDisplay( } ) { if (surfaceRequest != null) { - val isCounterRotatedLandscape = LocalWindowRotation.current.isLandscape val implementationMode = when { - Build.VERSION.SDK_INT <= 24 || isCounterRotatedLandscape -> - ImplementationMode.EMBEDDED - else -> ImplementationMode.EXTERNAL + Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL + else -> ImplementationMode.EMBEDDED } DetectWindowColorModeChanges( diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt index 41f24f2008..b285dcadaf 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt @@ -60,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -92,7 +93,9 @@ import androidx.compose.ui.unit.min import androidx.compose.ui.unit.takeOrElse import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutDefaults +import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolution import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolver +import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSpec import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds import com.google.jetpackcamera.ui.components.capture.layout.CameraWindow import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution @@ -292,9 +295,18 @@ internal fun CompactPortraitLayout( SeamlessRotationAnimationEffect() + val isSheetVisible = state.isQuickSettingsOpen || + bottomSheetState.isVisible || + bottomSheetState.targetValue == SheetValue.Expanded + var isErrorDialogPlaced by remember { mutableStateOf(false) } + val isErrorModalActive = isErrorDialogShowing || isErrorDialogPlaced + val isModalOpen = isSheetVisible || isErrorModalActive + val externalVfMetrics = remember { ExternalViewfinderMetrics() } + BoxWithConstraints( modifier = modifier .fillMaxSize() + .background(Color.Black) .testTag(COMPACT_CONTROLS_TAG) ) { val windowWidth = maxWidth @@ -325,12 +337,94 @@ internal fun CompactPortraitLayout( deviceRotation = effectiveDeviceRotation, windowRotation = containerFrameRotation ) + val unrotatedGravityRotationState = rememberGravityRotationState( + deviceRotation = effectiveDeviceRotation, + windowRotation = windowRotation + ) + + val portraitWidth = if (shouldCounterRotate) windowHeight else windowWidth + val portraitHeight = if (shouldCounterRotate) windowWidth else windowHeight + val portraitAspectRatio = portraitHeight.value / portraitWidth.value + val isHandheldPortrait = + portraitAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + val minTouchTarget = LocalMinimumInteractiveComponentSize.current + .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } + val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + includeStatusBarsInTopBar = includeStatusBarsInTopBar, + minTouchTarget = minTouchTarget, + windowRotation = effectiveWindowRotation + ) + val navInset = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(effectiveWindowRotation) + .asPaddingValues() + .calculateBottomPadding() + val rawGestureInset = WindowInsets.systemGestures + .toPortraitWindowInsets(effectiveWindowRotation) + .asPaddingValues() + .calculateBottomPadding() + val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset) + val spec = remember(minTouchTarget) { + CameraLayoutDefaults.spec(minInteractiveTouchTarget = minTouchTarget) + } + val portraitSolution = if (isHandheldPortrait) { + val cameraWindow = remember( + portraitWidth, + portraitHeight, + topBarHeight, + navInset, + effectiveGestureInset + ) { + CameraWindow( + width = portraitWidth, + height = portraitHeight, + topInset = topBarHeight, + navInset = navInset, + gestureInset = effectiveGestureInset + ) + } + remember(spec, cameraWindow) { CameraLayoutSolver.solve(spec, cameraWindow) } + } else { + null + } + + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalActiveCaptureLayout provides "CompactPortraitLayout", + LocalDeviceRotation provides effectiveDeviceRotation, + LocalWindowRotation provides if (shouldCounterRotate) { + windowRotation + } else { + effectiveWindowRotation + }, + LocalCompactCounterRotation provides effectiveWindowRotation, + LocalGravityRotationState provides if (shouldCounterRotate) { + unrotatedGravityRotationState + } else { + gravityRotationState + }, + LocalCameraLayoutSolution provides portraitSolution, + LocalLayoutDirection provides localeDirection + ) { + OuterViewfinderHost( + viewfinder = viewfinder, + solution = portraitSolution, + spec = spec, + topBarHeight = topBarHeight, + shouldCounterRotate = shouldCounterRotate, + counterRotation = effectiveWindowRotation, + metrics = externalVfMetrics, + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + ) + } CompositionLocalProvider( LocalOverlapTargetBounds provides overlapTargetBounds, LocalActiveCaptureLayout provides "CompactPortraitLayout", LocalDeviceRotation provides effectiveDeviceRotation, LocalWindowRotation provides effectiveWindowRotation, + LocalCompactCounterRotation provides effectiveWindowRotation, LocalGravityRotationState provides gravityRotationState ) { Box( @@ -341,6 +435,7 @@ internal fun CompactPortraitLayout( BottomSheetScaffold( modifier = Modifier.fillMaxSize(), scaffoldState = scaffoldState, + containerColor = Color.Transparent, sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, sheetDragHandle = { BottomSheetDefaults.DragHandle( @@ -371,20 +466,14 @@ internal fun CompactPortraitLayout( snackbarHost = {} ) { BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) + modifier = Modifier.fillMaxSize() ) { - val isSheetVisible = state.isQuickSettingsOpen || - bottomSheetState.isVisible || - bottomSheetState.targetValue == SheetValue.Expanded - var isErrorDialogPlaced by remember { mutableStateOf(false) } - val isErrorModalActive = isErrorDialogShowing || isErrorDialogPlaced - val isModalOpen = isSheetVisible || isErrorModalActive - - val windowAspectRatio = maxHeight.value / maxWidth.value - val isHandheldPortrait = windowAspectRatio >= - CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + val portraitViewfinderProxy: @Composable (Modifier) -> Unit = { proxyMod -> + ExternalViewfinderProxy( + metrics = externalVfMetrics, + modifier = proxyMod + ) + } if (isHandheldPortrait) { AdaptivePortraitCaptureLayout( windowWidth = maxWidth, @@ -392,8 +481,9 @@ internal fun CompactPortraitLayout( windowRotation = effectiveWindowRotation, includeStatusBarsInTopBar = includeStatusBarsInTopBar, controlsVisible = controlsVisible, + precomputedSolution = portraitSolution, modifier = Modifier.suppressBackgroundForModal(isModalOpen), - viewfinder = viewfinder, + viewfinder = portraitViewfinderProxy, topStartContent = topStartContent, viewfinderTopEndContent = viewfinderTopEndContent, onRequiresImmersiveChange = onRequiresImmersiveChange, @@ -432,13 +522,6 @@ internal fun CompactPortraitLayout( } } else { Column(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) { - val minTouchTarget = LocalMinimumInteractiveComponentSize.current - .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } - val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( - includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget, - windowRotation = effectiveWindowRotation - ) CompactTopBarBox( topBarHeight = topBarHeight, topBarTopPadding = topBarTopPadding, @@ -447,7 +530,7 @@ internal fun CompactPortraitLayout( topBarTrailingContent = topBarTrailingContent ) Box { - viewfinder(Modifier) + portraitViewfinderProxy(Modifier) OverlapAwareStyleProvider(Modifier.align(Alignment.TopStart)) { topStartContent(Modifier) } @@ -539,6 +622,144 @@ internal fun CompactPortraitLayout( } } +private class ExternalViewfinderMetrics { + var portraitWidthPx by mutableIntStateOf(0) + var portraitHeightPx by mutableIntStateOf(0) + var reportedTopPx by mutableIntStateOf(AlignmentLine.Unspecified) +} + +@Composable +private fun OuterViewfinderHost( + viewfinder: @Composable (Modifier) -> Unit, + solution: CameraLayoutSolution?, + spec: CameraLayoutSpec, + topBarHeight: Dp, + shouldCounterRotate: Boolean, + counterRotation: DeviceRotation, + metrics: ExternalViewfinderMetrics, + modifier: Modifier = Modifier +) { + Layout( + modifier = modifier, + content = { viewfinder(Modifier) } + ) { measurables, constraints -> + val measurable = measurables.firstOrNull() + if (measurable == null) { + layout(constraints.maxWidth, constraints.maxHeight) {} + } else if (!shouldCounterRotate) { + if (solution != null) { + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val vf = measurable.measure(looseConstraints) + val reportedTop = vf[ViewfinderTopLine] + val viewfinderY = if (vf.height in 1 until constraints.maxHeight) { + val ratio = vf.width.toFloat() / vf.height.toFloat() + solution.viewfinderBandFor(ratio, spec.viewfinders)?.top?.roundToPx() + } else { + null + } ?: 0 + val vfX = (constraints.maxWidth - vf.width) / 2 + if (metrics.portraitWidthPx != vf.width) metrics.portraitWidthPx = vf.width + if (metrics.portraitHeightPx != vf.height) metrics.portraitHeightPx = vf.height + if (metrics.reportedTopPx != reportedTop) metrics.reportedTopPx = reportedTop + layout(constraints.maxWidth, constraints.maxHeight) { + vf.placeRelative(vfX, viewfinderY) + } + } else { + val topBarPx = topBarHeight.roundToPx().coerceIn(0, constraints.maxHeight) + val availHeight = (constraints.maxHeight - topBarPx).coerceAtLeast(0) + val vf = measurable.measure( + Constraints( + minWidth = 0, + maxWidth = constraints.maxWidth, + minHeight = 0, + maxHeight = availHeight + ) + ) + if (metrics.portraitWidthPx != vf.width) metrics.portraitWidthPx = vf.width + if (metrics.portraitHeightPx != vf.height) metrics.portraitHeightPx = vf.height + if (metrics.reportedTopPx != AlignmentLine.Unspecified) { + metrics.reportedTopPx = AlignmentLine.Unspecified + } + layout(constraints.maxWidth, constraints.maxHeight) { + vf.placeRelative(0, topBarPx) + } + } + } else if (solution != null) { + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val vf = measurable.measure(looseConstraints) + val reportedTop = vf[ViewfinderTopLine] + val xOffset = if ( + reportedTop == AlignmentLine.Unspecified && + vf.width in 1 until constraints.maxWidth && + vf.height > 0 + ) { + val portraitRatio = vf.height.toFloat() / vf.width.toFloat() + val bandTopPx = solution + .viewfinderBandFor(portraitRatio, spec.viewfinders) + ?.top + ?.roundToPx() ?: 0 + if (counterRotation == DeviceRotation.Rotated90) { + (constraints.maxWidth - bandTopPx - vf.width).coerceAtLeast(0) + } else { + bandTopPx.coerceIn(0, (constraints.maxWidth - vf.width).coerceAtLeast(0)) + } + } else { + 0 + } + val yOffset = (constraints.maxHeight - vf.height) / 2 + if (metrics.portraitWidthPx != vf.height) metrics.portraitWidthPx = vf.height + if (metrics.portraitHeightPx != vf.width) metrics.portraitHeightPx = vf.width + if (metrics.reportedTopPx != reportedTop) metrics.reportedTopPx = reportedTop + layout(constraints.maxWidth, constraints.maxHeight) { + vf.place(xOffset, yOffset) + } + } else { + val topBarPx = topBarHeight.roundToPx().coerceIn(0, constraints.maxWidth) + val availWidth = (constraints.maxWidth - topBarPx).coerceAtLeast(0) + val vf = measurable.measure( + Constraints( + minWidth = 0, + maxWidth = availWidth, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + ) + val xOffset = if (counterRotation == DeviceRotation.Rotated90) { + (availWidth - vf.width) / 2 + } else { + topBarPx + (availWidth - vf.width) / 2 + } + val yOffset = (constraints.maxHeight - vf.height) / 2 + if (metrics.portraitWidthPx != vf.height) metrics.portraitWidthPx = vf.height + if (metrics.portraitHeightPx != vf.width) metrics.portraitHeightPx = vf.width + if (metrics.reportedTopPx != AlignmentLine.Unspecified) { + metrics.reportedTopPx = AlignmentLine.Unspecified + } + layout(constraints.maxWidth, constraints.maxHeight) { + vf.place(xOffset, yOffset) + } + } + } +} + +@Composable +private fun ExternalViewfinderProxy( + metrics: ExternalViewfinderMetrics, + modifier: Modifier = Modifier +) { + Layout(modifier = modifier) { _, constraints -> + val w = metrics.portraitWidthPx.coerceIn(0, constraints.maxWidth) + val h = metrics.portraitHeightPx.coerceIn(0, constraints.maxHeight) + val reportedTop = metrics.reportedTopPx + val lines: Map = if (reportedTop == AlignmentLine.Unspecified) { + emptyMap() + } else { + mapOf(ViewfinderTopLine to reportedTop) + } + layout(w, h, alignmentLines = lines) {} + } +} + @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, @@ -741,6 +962,7 @@ internal fun AdaptivePortraitCaptureLayout( modifier: Modifier = Modifier, windowRotation: DeviceRotation = LocalWindowRotation.current, includeStatusBarsInTopBar: Boolean = true, + precomputedSolution: CameraLayoutSolution? = null, snackbarHost: @Composable (Modifier) -> Unit = {} ) { val localeDirection = LocalLayoutDirection.current @@ -791,7 +1013,9 @@ internal fun AdaptivePortraitCaptureLayout( val spec = remember(minTouchTarget) { CameraLayoutDefaults.spec(minInteractiveTouchTarget = minTouchTarget) } - val solution = remember(spec, window) { CameraLayoutSolver.solve(spec, window) } + val solution = precomputedSolution ?: remember(spec, window) { + CameraLayoutSolver.solve(spec, window) + } val currentOnRequiresImmersiveChange by rememberUpdatedState(onRequiresImmersiveChange) DisposableEffect(solution.requiresImmersive) { diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt index 5c8478ceed..3bef10c3b0 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -76,6 +76,14 @@ val LocalDeviceRotation: ProvidableCompositionLocal = val LocalWindowRotation: ProvidableCompositionLocal = compositionLocalOf { DeviceRotation.Natural } +/** + * Provides the active counter-rotation applied by [CompactPortraitLayout] when rendering in a + * horizontal (`width > height`) window (`Rotated90` or `Rotated270`), or [DeviceRotation.Natural] + * when no counter-rotation is active. + */ +internal val LocalCompactCounterRotation: ProvidableCompositionLocal = + compositionLocalOf { DeviceRotation.Natural } + /** * Provides a shared, root-animated [State] holding the continuous clockwise gravity compensation * angle in degrees. Reading [State.value] inside [Modifier.graphicsLayer] updates only the draw/ diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffoldTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffoldTest.kt index 204dfe61cd..03b1ee27d5 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffoldTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffoldTest.kt @@ -680,6 +680,52 @@ class CaptureScaffoldTest { .isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) } + @Test + fun cameraSessionEffect_forwardsWindowRotationAndFreezesWhileRecording() { + var isRecording by mutableStateOf(false) + var windowRotation by mutableStateOf(DeviceRotation.Natural) + val deliveredWindowRotations = mutableListOf() + val cameraController = FakeCameraController( + setWindowRotationAction = { deliveredWindowRotations += it } + ) + + composeTestRule.setContent { + CameraSessionEffect( + cameraController = cameraController, + enabled = true, + isRecording = isRecording, + windowRotation = windowRotation + ) + } + composeTestRule.waitForIdle() + assertThat(deliveredWindowRotations).containsExactly(DeviceRotation.Natural) + + composeTestRule.runOnIdle { windowRotation = DeviceRotation.Rotated90 } + composeTestRule.waitForIdle() + assertThat(deliveredWindowRotations) + .containsExactly(DeviceRotation.Natural, DeviceRotation.Rotated90) + .inOrder() + + // While recording, window rotation updates are frozen and queued until recording stops. + composeTestRule.runOnIdle { isRecording = true } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { windowRotation = DeviceRotation.Rotated270 } + composeTestRule.waitForIdle() + assertThat(deliveredWindowRotations) + .containsExactly(DeviceRotation.Natural, DeviceRotation.Rotated90) + .inOrder() + + composeTestRule.runOnIdle { isRecording = false } + composeTestRule.waitForIdle() + assertThat(deliveredWindowRotations) + .containsExactly( + DeviceRotation.Natural, + DeviceRotation.Rotated90, + DeviceRotation.Rotated270 + ) + .inOrder() + } + @Test fun cameraControlIconButton_rendersAndInvokesOnClick() { var clicked = false diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt index 57e5cf5c72..da7803655f 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt @@ -25,11 +25,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.Density @@ -37,13 +39,21 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolution import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -52,6 +62,7 @@ import org.robolectric.annotation.Config private object LayoutRotationTestTokens { const val INDICATOR_TAG = "RotationTestIndicatorRow" + const val VIEWFINDER_TOP_MARKER_TAG = "RotationTestViewfinderTopMarker" val CaptureButtonSize = 80.dp val ControlSize = 48.dp @@ -63,9 +74,9 @@ private object LayoutRotationTestTokens { /** * Robolectric Compose UI tests verifying layout-level counter-rotation scoping across full-screen - * compact handhelds, portrait & horizontal split-screen panes (`550x500dp` and `800x400dp`), - * 7-mode side-rail postures (`FOLDABLE`, `TABLET_PORTRAIT`, `TABLET_LANDSCAPE`, `COMPACT_SHORT`), - * and [CutoutAwareRow] cutout remapping. + * compact handhelds, portrait & horizontal split-screen panes (`852x420dp`, `800x400dp`, + * `550x500dp`), un-rotated viewfinder hosting, 7-mode side-rail postures (`FOLDABLE`, + * `TABLET_PORTRAIT`, `TABLET_LANDSCAPE`, `COMPACT_SHORT`), and [CutoutAwareRow] cutout remapping. */ @RunWith(RobolectricTestRunner::class) class LayoutRotationTest { @@ -81,14 +92,22 @@ class LayoutRotationTest { slots: CaptureSlots? = null, onViewfinderObserved: ( mode: CameraLayoutMode, - effectiveWindowRotation: DeviceRotation, - gravityRotationDegrees: Float, + vfWindowRotation: DeviceRotation, + vfGravityDegrees: Float, solution: CameraLayoutSolution?, viewfinderSize: IntSize - ) -> Unit = { _, _, _, _, _ -> } + ) -> Unit = { _, _, _, _, _ -> }, + onChromeObserved: ( + chromeWindowRotation: DeviceRotation, + chromeGravityDegrees: Float, + solution: CameraLayoutSolution? + ) -> Unit = { _, _, _ -> } ) { MaterialTheme { - CompositionLocalProvider(LocalDisableAnimations provides true) { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { CaptureScaffoldLayout( modifier = modifier, deviceRotation = deviceRotation, @@ -96,8 +115,8 @@ class LayoutRotationTest { slots = slots, viewfinder = { vfModifier -> val mode = LocalCameraLayoutMode.current - val effectiveWinRot = LocalWindowRotation.current - val gravityDeg = LocalGravityRotationState.current.value + val vfWinRot = LocalWindowRotation.current + val vfGravityDeg = LocalGravityRotationState.current.value val solution = LocalCameraLayoutSolution.current Box( modifier = vfModifier @@ -105,16 +124,28 @@ class LayoutRotationTest { .onSizeChanged { vfSize -> onViewfinderObserved( mode, - effectiveWinRot, - gravityDeg, + vfWinRot, + vfGravityDeg, solution, vfSize ) } .testTag(PREVIEW_DISPLAY) - ) + ) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .size(width = 24.dp, height = 6.dp) + .testTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG) + ) + } }, captureButton = { mod -> + onChromeObserved( + LocalWindowRotation.current, + LocalGravityRotationState.current.value, + LocalCameraLayoutSolution.current + ) Box( mod .size(LayoutRotationTestTokens.CaptureButtonSize) @@ -180,11 +211,12 @@ class LayoutRotationTest { @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi") @Test - fun compactPortraitLayout_fullScreenLandscapePhone_counterRotatesToPortrait() { + fun compactPortraitLayout_landscapePhone_counterRotatesChromeWithUprightViewfinder() { var windowRotation by mutableStateOf(DeviceRotation.Rotated90) var observedMode: CameraLayoutMode? = null - var observedEffectiveWinRot: DeviceRotation? = null - var observedGravityDeg: Float? = null + var observedVfWinRot: DeviceRotation? = null + var observedChromeWinRot: DeviceRotation? = null + var observedChromeGravityDeg: Float? = null var observedSolution: CameraLayoutSolution? = null var observedViewfinderSize = IntSize.Zero @@ -192,22 +224,32 @@ class LayoutRotationTest { RotationLayoutUnderTest( deviceRotation = windowRotation, windowRotation = windowRotation, - onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + onViewfinderObserved = { mode, vfWinRot, _, solution, vfSize -> observedMode = mode - observedEffectiveWinRot = effWinRot - observedGravityDeg = gravDeg + observedVfWinRot = vfWinRot observedSolution = solution observedViewfinderSize = vfSize + }, + onChromeObserved = { chromeWinRot, chromeGravDeg, _ -> + observedChromeWinRot = chromeWinRot + observedChromeGravityDeg = chromeGravDeg } ) } composeTestRule.waitForIdle() assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) - assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated90) + assertThat(observedVfWinRot).isEqualTo(DeviceRotation.Rotated90) + assertThat(observedChromeWinRot).isEqualTo(DeviceRotation.Rotated90) assertThat(observedSolution).isNotNull() - assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) - assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f) + assertThat(observedViewfinderSize).isEqualTo(IntSize(740, 360)) + assertThat(((observedChromeGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f) + + // Viewfinder top marker remains at physical top of the 740x360dp window (0° rotation). + val topMarker90 = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG) + .getBoundsInRoot() + assertThat(topMarker90.top.value).isEqualTo(0f) val captureBand90 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) val expectedCaptureCenterX90 = @@ -228,10 +270,11 @@ class LayoutRotationTest { composeTestRule.waitForIdle() assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) - assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedVfWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedChromeWinRot).isEqualTo(DeviceRotation.Rotated270) assertThat(observedSolution).isNotNull() - assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) - assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + assertThat(observedViewfinderSize).isEqualTo(IntSize(740, 360)) + assertThat(((observedChromeGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) val captureBand270 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) val expectedCaptureCenterX270 = @@ -283,39 +326,148 @@ class LayoutRotationTest { @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") @Test - fun compactHorizontalPanes_800x400And550x500_rotateCompactLayout90DegreesToFillSpace() { - var paneSize by mutableStateOf(DpSize(800.dp, 400.dp)) + fun compactHorizontalSplitScreen_keepsViewfinderUnrotatedAcrossSizesAndHostRotations() { + var paneSize by mutableStateOf(DpSize(852.dp, 420.dp)) + var hostWindowRotation by mutableStateOf(DeviceRotation.Natural) var observedMode: CameraLayoutMode? = null - var observedEffectiveWinRot: DeviceRotation? = null - var observedGravityDeg: Float? = null + var observedVfWinRot: DeviceRotation? = null + var observedVfGravityDeg: Float? = null + var observedChromeWinRot: DeviceRotation? = null + var observedChromeGravityDeg: Float? = null var observedViewfinderSize = IntSize.Zero composeTestRule.setContent { RotationLayoutUnderTest( modifier = Modifier.size(paneSize), - deviceRotation = DeviceRotation.Natural, - windowRotation = DeviceRotation.Natural, - onViewfinderObserved = { mode, effWinRot, gravDeg, _, vfSize -> + deviceRotation = hostWindowRotation, + windowRotation = hostWindowRotation, + onViewfinderObserved = { mode, vfWinRot, vfGravDeg, _, vfSize -> observedMode = mode - observedEffectiveWinRot = effWinRot - observedGravityDeg = gravDeg + observedVfWinRot = vfWinRot + observedVfGravityDeg = vfGravDeg observedViewfinderSize = vfSize + }, + onChromeObserved = { chromeWinRot, chromeGravDeg, _ -> + observedChromeWinRot = chromeWinRot + observedChromeGravityDeg = chromeGravDeg } ) } + + val paneSizes = listOf( + DpSize(852.dp, 420.dp) to IntSize(852, 420), + DpSize(800.dp, 400.dp) to IntSize(800, 400), + DpSize(550.dp, 500.dp) to IntSize(502, 500) + ) + val hostRotations = listOf( + DeviceRotation.Natural, + DeviceRotation.Rotated90, + DeviceRotation.Rotated270 + ) + + for ((size, expectedVfSize) in paneSizes) { + for (winRot in hostRotations) { + paneSize = size + hostWindowRotation = winRot + composeTestRule.waitForIdle() + + val label = "pane=$size hostWinRot=$winRot" + assertWithMessage(label).that(observedMode).isEqualTo(CameraLayoutMode.COMPACT) + // Viewfinder stays in un-rotated host window frame (0° upright, LocalWindowRotation + // == windowRotation), while UI chrome counter-rotates into portrait bands. + assertWithMessage(label).that(observedVfWinRot).isEqualTo(winRot) + assertWithMessage(label).that(observedVfGravityDeg).isEqualTo(0f) + assertWithMessage(label).that(observedViewfinderSize).isEqualTo(expectedVfSize) + assertWithMessage(label).that(observedChromeWinRot?.isLandscape).isTrue() + assertWithMessage(label) + .that(((observedChromeGravityDeg!! % 360f) + 360f) % 360f) + .isIn(listOf(90f, 270f)) + + val topMarker = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG) + .getBoundsInRoot() + assertWithMessage("$label viewfinder top marker upright at y=0") + .that(topMarker.top.value) + .isEqualTo(0f) + assertWithMessage("$label viewfinder top marker horizontal") + .that(topMarker.width.value) + .isGreaterThan(topMarker.height.value) + } + } + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun previewDisplay_inHorizontalCompactSplitScreen_alignsSolvedBandHorizontallyWithChrome() { + var hostRotation by mutableStateOf(DeviceRotation.Rotated270) + var observedSolution: CameraLayoutSolution? = null + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + CaptureScaffoldLayout( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = hostRotation, + windowRotation = hostRotation, + viewfinder = { vfModifier -> + observedSolution = LocalCameraLayoutSolution.current + PreviewDisplay( + modifier = vfModifier, + previewDisplayUiState = PreviewDisplayUiState( + lastBlinkTimeStamp = 0L, + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = AspectRatio.THREE_FOUR, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi( + AspectRatio.THREE_FOUR + ) + ) + ) + ), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified, + useLandscapeAspectRatio = true + ) + }, + indicatorRow = { mod -> + Box( + mod + .size(120.dp, 36.dp) + .testTag(LayoutRotationTestTokens.INDICATOR_TAG) + ) + }, + captureButton = { mod -> + Box(mod.size(80.dp).testTag(CAPTURE_BUTTON)) + } + ) + } + } + } composeTestRule.waitForIdle() - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) - assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) - assertThat(observedViewfinderSize).isEqualTo(IntSize(400, 800)) - assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + val band270 = checkNotNull(observedSolution).viewfinderBandFor(3f / 4f)!! + val vfBounds270 = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getBoundsInRoot() + // Rotated270 (-90° CCW chrome): top bar on left [0, 48], 4:3 frame [48, 48 + 533.33] x 400. + assertThat(vfBounds270.left.value).isWithin(1f).of(band270.top.value) + assertThat(vfBounds270.width.value).isWithin(1f).of(band270.height.value) + assertThat(vfBounds270.height.value).isWithin(1f).of(400f) - // 550x500dp (W > H with H >= 480dp) also resolves to COMPACT and counter-rotates cleanly! - paneSize = DpSize(550.dp, 500.dp) + // Rotated90 (+90° CW chrome): top bar on right [752, 800], 4:3 frame flush against x=752. + hostRotation = DeviceRotation.Rotated90 composeTestRule.waitForIdle() - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) - assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) - assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + + val band90 = checkNotNull(observedSolution).viewfinderBandFor(3f / 4f)!! + val vfBounds90 = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getBoundsInRoot() + assertThat(vfBounds90.right.value).isWithin(1f).of(800f - band90.top.value) + assertThat(vfBounds90.width.value).isWithin(1f).of(band90.height.value) + assertThat(vfBounds90.height.value).isWithin(1f).of(400f) } @Config(qualifiers = "sw800dp-w1400dp-h1400dp-mdpi") diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt index acd1e1c720..6c19372ba9 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt @@ -17,11 +17,13 @@ package com.google.jetpackcamera.ui.components.capture import android.annotation.SuppressLint import android.content.Context +import android.content.pm.ActivityInfo import android.graphics.Matrix import android.graphics.Rect as AndroidRect import android.util.Size import android.view.KeyEvent as AndroidKeyEvent import android.view.Surface +import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.core.impl.CameraInternal import androidx.compose.foundation.layout.Box @@ -68,6 +70,7 @@ import androidx.compose.ui.unit.width import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState @@ -700,6 +703,59 @@ class PreviewDisplayTest { } } + @Test + fun previewDisplay_withHdrSurfaceRequest_requestsColorModeHdrAcrossAllPostures() { + val hdrSurfaceRequest = createFakeSurfaceRequest( + width = 1920, + height = 1080, + dynamicRange = CXDynamicRange.HLG_10_BIT + ) + var containerSize by mutableStateOf(DpSize(412.dp, 915.dp)) + var windowRotation by mutableStateOf(DeviceRotation.Natural) + val requestedColorModes = mutableListOf() + + composeTestRule.setContent { + CaptureScaffoldLayout( + modifier = Modifier.size(containerSize), + deviceRotation = windowRotation, + windowRotation = windowRotation, + viewfinder = { vfModifier -> + PreviewDisplay( + modifier = vfModifier, + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = { requestedColorModes += it }, + surfaceRequest = hdrSurfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified, + useLandscapeAspectRatio = true + ) + } + ) + } + composeTestRule.waitForIdle() + assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR) + + // Counter-rotated horizontal compact split-screen (800x400dp) preserves EXTERNAL + HDR. + containerSize = DpSize(800.dp, 400.dp) + windowRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR) + + // Side-rail tablet landscape (1280x800dp) and round-trip back to compact portrait + // (412x915dp) preserve EXTERNAL + HDR without resetting to COLOR_MODE_DEFAULT. + containerSize = DpSize(1280.dp, 800.dp) + windowRotation = DeviceRotation.Natural + composeTestRule.waitForIdle() + assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR) + + containerSize = DpSize(412.dp, 915.dp) + composeTestRule.waitForIdle() + assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR) + assertThat(requestedColorModes).doesNotContain(ActivityInfo.COLOR_MODE_DEFAULT) + } + private fun createUiState( aspectRatio: AspectRatio, lastBlinkTimeStamp: Long = 0L @@ -716,7 +772,11 @@ class PreviewDisplayTest { ) @SuppressLint("RestrictedApi") - private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest { + private fun createFakeSurfaceRequest( + width: Int, + height: Int, + dynamicRange: CXDynamicRange = CXDynamicRange.SDR + ): SurfaceRequest { val fakeCamera = Proxy.newProxyInstance( CameraInternal::class.java.classLoader, arrayOf(CameraInternal::class.java) @@ -739,7 +799,12 @@ class PreviewDisplayTest { } } as CameraInternal - return SurfaceRequest(Size(width, height), fakeCamera) {}.also { request -> + return SurfaceRequest( + Size(width, height), + fakeCamera, + dynamicRange, + SurfaceRequest.FRAME_RATE_RANGE_UNSPECIFIED + ) {}.also { request -> request.updateTransformationInfo( SurfaceRequest.TransformationInfo.of( AndroidRect(0, 0, width, height), diff --git a/ui/controller/impl/src/main/java/com/google/jetpackcamera/ui/controller/impl/CameraControllerImpl.kt b/ui/controller/impl/src/main/java/com/google/jetpackcamera/ui/controller/impl/CameraControllerImpl.kt index d7d70ffb40..bbc6996c1e 100644 --- a/ui/controller/impl/src/main/java/com/google/jetpackcamera/ui/controller/impl/CameraControllerImpl.kt +++ b/ui/controller/impl/src/main/java/com/google/jetpackcamera/ui/controller/impl/CameraControllerImpl.kt @@ -101,6 +101,12 @@ class CameraControllerImpl( } } + override fun setWindowRotation(windowRotation: DeviceRotation) { + scope.launch { + cameraSystemProvider().setWindowRotation(windowRotation) + } + } + override fun dismissCameraError() { scope.launch { cameraSystemProvider().clearCameraError() diff --git a/ui/controller/src/main/java/com/google/jetpackcamera/ui/controller/CameraController.kt b/ui/controller/src/main/java/com/google/jetpackcamera/ui/controller/CameraController.kt index 97ebfd2e84..3194c690a3 100644 --- a/ui/controller/src/main/java/com/google/jetpackcamera/ui/controller/CameraController.kt +++ b/ui/controller/src/main/java/com/google/jetpackcamera/ui/controller/CameraController.kt @@ -46,6 +46,14 @@ interface CameraController { */ fun setDisplayRotation(deviceRotation: DeviceRotation) + /** + * Informs the camera system of the current host OS display/window rotation so preview framing, + * [androidx.camera.core.ViewPort], and stream-sharing buffers match the active window. + * + * @param windowRotation The host display/window [DeviceRotation] to set. + */ + fun setWindowRotation(windowRotation: DeviceRotation) {} + /** * Acknowledges and dismisses the currently active camera error dialog. */ diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt index 3f437f25f0..d197e631e0 100644 --- a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt @@ -25,12 +25,14 @@ import com.google.jetpackcamera.ui.controller.CameraController * @param stopCameraAction The action to perform when [stopCamera] is called. * @param tapToFocusAction The action to perform when [tapToFocus] is called. * @param setDisplayRotationAction The action to perform when [setDisplayRotation] is called. + * @param setWindowRotationAction The action to perform when [setWindowRotation] is called. */ class FakeCameraController( var startCameraAction: () -> Unit = {}, var stopCameraAction: () -> Unit = {}, var tapToFocusAction: (x: Float, y: Float) -> Unit = { _, _ -> }, - var setDisplayRotationAction: (DeviceRotation) -> Unit = {} + var setDisplayRotationAction: (DeviceRotation) -> Unit = {}, + var setWindowRotationAction: (DeviceRotation) -> Unit = {} ) : CameraController { override fun startCamera() { startCameraAction() @@ -47,4 +49,8 @@ class FakeCameraController( override fun setDisplayRotation(deviceRotation: DeviceRotation) { setDisplayRotationAction(deviceRotation) } + + override fun setWindowRotation(windowRotation: DeviceRotation) { + setWindowRotationAction(windowRotation) + } } diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt index 14cad20121..b94d86fa8a 100644 --- a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt @@ -56,4 +56,14 @@ class FakeCameraControllerTest { controller.setDisplayRotation(DeviceRotation.Rotated90) assertThat(calledRotation).isEqualTo(DeviceRotation.Rotated90) } + + @Test + fun setWindowRotation_invokesAction() { + var calledRotation: DeviceRotation? = null + val controller = FakeCameraController( + setWindowRotationAction = { calledRotation = it } + ) + controller.setWindowRotation(DeviceRotation.Rotated270) + assertThat(calledRotation).isEqualTo(DeviceRotation.Rotated270) + } } From 2c81c8459ced5cd334e453de8b515ea0753ad2f4 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 13 Nov 2026 08:59:14 -0800 Subject: [PATCH 2/2] refactor(camera): move windowRotation to TransientSessionSettings and note PR #592 --- .../concurrent/ConcurrentCameraSession.kt | 4 +- .../core/camera/CameraSession.kt | 87 +++++---- .../core/camera/CameraSessionSettings.kt | 10 +- .../core/camera/CameraXCameraSystem.kt | 9 +- .../core/camera/CameraSessionTest.kt | 166 +++++------------- 5 files changed, 96 insertions(+), 180 deletions(-) diff --git a/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt b/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt index 03e4a65046..cd61f50d94 100644 --- a/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt +++ b/core/camera/concurrent/src/main/java/com/google/jetpackcamera/core/camera/concurrent/ConcurrentCameraSession.kt @@ -34,6 +34,7 @@ import com.google.jetpackcamera.core.camera.InternalCameraApi import com.google.jetpackcamera.core.camera.PerpetualSessionSettings import com.google.jetpackcamera.core.camera.appLensFacing import com.google.jetpackcamera.core.camera.applyDeviceRotation +import com.google.jetpackcamera.core.camera.applyWindowRotation import com.google.jetpackcamera.core.camera.attachPreviewSurfaceProvider import com.google.jetpackcamera.core.camera.concurrentCompositionScaleX import com.google.jetpackcamera.core.camera.createUseCaseGroup @@ -148,7 +149,7 @@ internal suspend fun CameraSessionContext.runConcurrentCameraSession( videoCaptureUseCase = videoCapture, previewMirrorMode = sessionSettings.mirrorMode .toCXConcurrentPreviewMirrorMode(), - windowRotation = sessionSettings.windowRotation + windowRotation = initialTransientSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) } @@ -308,6 +309,7 @@ internal suspend fun CameraSessionContext.runConcurrentCameraSession( } applyDeviceRotation(initialTransientSettings.deviceRotation, useCaseGroup) + applyWindowRotation(initialTransientSettings.windowRotation, useCaseGroup) processTransientSettingEvents( primaryCamera, perLensConstraints[primaryLensFacing], diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index 99f905d70d..130c9e342b 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -303,7 +303,7 @@ internal suspend fun runSingleCameraSession( captureMode = sessionSettings.captureMode, effect = effect, captureResults = captureResults, - windowRotation = sessionSettings.windowRotation + windowRotation = currentTransientSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) @@ -334,6 +334,7 @@ internal suspend fun runSingleCameraSession( } applyDeviceRotation(currentTransientSettings.deviceRotation, useCaseGroup) + applyWindowRotation(currentTransientSettings.windowRotation, useCaseGroup) if (videoCaptureUseCase != null) { val resolutionInfo = videoCaptureUseCase.resolutionInfo @@ -614,6 +615,16 @@ suspend fun processTransientSettingEvents( } } + if (prevTransientSettings.windowRotation != newTransientSettings.windowRotation) { + Log.d( + TAG, + "Updating window rotation from " + + "${prevTransientSettings.windowRotation} -> " + + "${newTransientSettings.windowRotation}" + ) + applyWindowRotation(newTransientSettings.windowRotation, useCaseGroup) + } + // setzoomratio when the primary zoom value changes. if (prevTransientSettings.primaryLensFacing == newTransientSettings.primaryLensFacing && prevTransientSettings.zoomRatios[prevTransientSettings.primaryLensFacing] != @@ -741,9 +752,8 @@ fun CameraSessionContext.applyDeviceRotation( useCaseGroup.useCases.forEach { when (it) { is Preview -> { - // Preview's target rotation, ViewPort, and StreamSharing OutConfigs track - // PerpetualSessionSettings.windowRotation via session rebind rather than physical - // device gravity rotation. + // Preview's target rotation tracks TransientSessionSettings.windowRotation via + // applyWindowRotation rather than physical device gravity rotation. } is ImageCapture -> { @@ -757,6 +767,27 @@ fun CameraSessionContext.applyDeviceRotation( } } +/** + * Updates [Preview.setTargetRotation] in place when [TransientSessionSettings.windowRotation] + * changes, without rebinding the camera session. + * + * Depends on CameraX fixes `aosp/4408335`, `aosp/4408336`, and `aosp/4408337`, which dynamically + * recalculate [ViewPort] and StreamSharing crop/rotation when [Preview.setTargetRotation] is + * updated on a bound use case. + */ +@InternalCameraApi +fun applyWindowRotation( + windowRotation: DeviceRotation, + useCaseGroup: UseCaseGroup +) { + val targetRotation = windowRotation.toUiSurfaceRotation() + useCaseGroup.useCases.forEach { useCase -> + if (useCase is Preview) { + useCase.targetRotation = targetRotation + } + } +} + context(CameraSessionContext) @InternalCameraApi fun createUseCaseGroup( @@ -770,7 +801,7 @@ fun createUseCaseGroup( effect: CameraEffect? = null, captureResults: MutableStateFlow? = null, previewMirrorMode: Int = CX_SINGLE_CAMERA_PREVIEW_MIRROR_MODE, - windowRotation: DeviceRotation = sessionSettings?.windowRotation ?: DeviceRotation.Natural + windowRotation: DeviceRotation = initialTransientSettings.windowRotation ): UseCaseGroup { val previewUseCase = createPreviewUseCase( @@ -803,12 +834,13 @@ fun createUseCaseGroup( "Setting initial device rotation to ${initialTransientSettings.deviceRotation}, " + "window rotation to $windowRotation" ) + // TODO(#592): Plumb windowRotation into PR #592's getViewPortAspectRatio / + // isPreviewLandscape once #592 merges into main. setViewPort( - createSensorAlignedViewPort( - cameraInfo = cameraInfo, - aspectRatio = aspectRatio, - targetRotation = previewUseCase.targetRotation - ) + ViewPort.Builder( + Rational(aspectRatio.numerator, aspectRatio.denominator), + previewUseCase.targetRotation + ).build() ) addUseCase(previewUseCase) @@ -820,47 +852,12 @@ fun createUseCaseGroup( }.build() } -/** - * Builds a [ViewPort] aligned with [targetRotation] whose aspect-ratio [Rational] matches the - * camera sensor's upright orientation at [targetRotation]. - */ -internal fun createSensorAlignedViewPort( - cameraInfo: CameraInfo, - aspectRatio: AspectRatio, - targetRotation: Int -): ViewPort = ViewPort.Builder( - resolveViewPortAspectRatio( - cameraInfo = cameraInfo, - aspectRatio = aspectRatio, - targetRotation = targetRotation - ), - targetRotation -).build() - /** * Applies [windowRotation] as the target rotation of this [Preview.Builder]. */ internal fun Preview.Builder.applyWindowRotation(windowRotation: DeviceRotation): Preview.Builder = setTargetRotation(windowRotation.toUiSurfaceRotation()) -/** - * Resolves the [ViewPort] aspect-ratio [Rational] in upright target-rotation coordinates using - * [CameraInfo.getSensorRotationDegrees] so landscape sessions on landscape sensors frame `4:3` / - * `16:9` rather than cropping a portrait strip. - */ -internal fun resolveViewPortAspectRatio( - cameraInfo: CameraInfo, - aspectRatio: AspectRatio, - targetRotation: Int -): Rational { - val sensorRotationDegrees = cameraInfo.getSensorRotationDegrees(targetRotation) - return if (sensorRotationDegrees % 180 == 0) { - Rational(aspectRatio.denominator, aspectRatio.numerator) - } else { - Rational(aspectRatio.numerator, aspectRatio.denominator) - } -} - /** * Maps a given resolution [Size] to its corresponding [VideoQuality] based on the short edge. * diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt index 069215802d..a5df1ab725 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt @@ -40,7 +40,6 @@ import com.google.jetpackcamera.model.VideoQuality sealed interface PerpetualSessionSettings { val aspectRatio: AspectRatio val captureMode: CaptureMode - val windowRotation: DeviceRotation data class SingleCamera( override val aspectRatio: AspectRatio, @@ -53,8 +52,7 @@ sealed interface PerpetualSessionSettings { val mirrorMode: MirrorMode, val imageFormat: ImageOutputFormat, val lowLightBoostPriority: LowLightBoostPriority, - val activeCaptureSubMode: CaptureSubModeFeatureKey? = null, - override val windowRotation: DeviceRotation = DeviceRotation.Natural + val activeCaptureSubMode: CaptureSubModeFeatureKey? = null ) : PerpetualSessionSettings /** @@ -64,8 +62,7 @@ sealed interface PerpetualSessionSettings { val subModeKey: CaptureSubModeFeatureKey, override val aspectRatio: AspectRatio, override val captureMode: CaptureMode, - val mirrorMode: MirrorMode = MirrorMode.OFF, - override val windowRotation: DeviceRotation = DeviceRotation.Natural + val mirrorMode: MirrorMode = MirrorMode.OFF ) : PerpetualSessionSettings } @@ -83,7 +80,8 @@ data class TransientSessionSettings( val flashMode: FlashMode, val primaryLensFacing: LensFacing, val zoomRatios: Map, - val testPattern: TestPattern + val testPattern: TestPattern, + val windowRotation: DeviceRotation = DeviceRotation.Natural ) data class InitialRecordingSettings( diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index f6b25c3cd4..92673d1ffc 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -610,7 +610,8 @@ class CameraXCameraSystem( flashMode = currentCameraSettings.flashMode, primaryLensFacing = currentCameraSettings.cameraLensFacing, zoomRatios = currentCameraSettings.defaultZoomRatios, - testPattern = currentCameraSettings.debugSettings.testPattern + testPattern = currentCameraSettings.debugSettings.testPattern, + windowRotation = windowRotation ) val activeSubMode = @@ -623,8 +624,7 @@ class CameraXCameraSystem( subModeKey = activeSubMode.key, aspectRatio = currentCameraSettings.aspectRatio, captureMode = currentCameraSettings.captureMode, - mirrorMode = currentCameraSettings.mirrorMode, - windowRotation = windowRotation + mirrorMode = currentCameraSettings.mirrorMode ) } else { val cameraConstraints = @@ -665,8 +665,7 @@ class CameraXCameraSystem( mirrorMode = currentCameraSettings.mirrorMode, imageFormat = currentCameraSettings.imageFormat, lowLightBoostPriority = currentCameraSettings.lowLightBoostPriority, - activeCaptureSubMode = activeSubMode?.key, - windowRotation = windowRotation + activeCaptureSubMode = activeSubMode?.key ) } } diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt index 74151daa48..9011fb026b 100644 --- a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt +++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/CameraSessionTest.kt @@ -18,28 +18,18 @@ package com.google.jetpackcamera.core.camera import android.graphics.Rect -import android.util.Rational import android.util.Size import android.view.Surface -import androidx.camera.core.CameraInfo -import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.Preview import androidx.camera.core.UseCaseGroup import com.google.common.truth.Truth.assertThat -import com.google.jetpackcamera.core.camera.submode.CaptureSubModeFeatureKey import com.google.jetpackcamera.core.camera.testing.FakeCameraSystem -import com.google.jetpackcamera.model.AspectRatio -import com.google.jetpackcamera.model.CaptureMode -import com.google.jetpackcamera.model.CaptureSubModeId import com.google.jetpackcamera.model.DeviceRotation -import com.google.jetpackcamera.model.DynamicRange -import com.google.jetpackcamera.model.ImageOutputFormat -import com.google.jetpackcamera.model.LowLightBoostPriority -import com.google.jetpackcamera.model.MirrorMode -import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.TestPattern import com.google.jetpackcamera.model.VideoQuality -import java.lang.reflect.Proxy import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -122,128 +112,58 @@ class CameraSessionTest { } @Test - fun previewAndViewPort_syncWithWindowRotation_independentlyOfDeviceRotation() { - val phoneSensorInfo = fakeBackCameraInfo(sensorOrientationDegrees = 90) - val tabletLandscapeSensorInfo = fakeBackCameraInfo(sensorOrientationDegrees = 0) - - val expectedPhoneViewPorts = mapOf( - DeviceRotation.Natural to (Surface.ROTATION_0 to Rational(3, 4)), - DeviceRotation.Rotated270 to (Surface.ROTATION_90 to Rational(4, 3)), - DeviceRotation.Rotated180 to (Surface.ROTATION_180 to Rational(3, 4)), - DeviceRotation.Rotated90 to (Surface.ROTATION_270 to Rational(4, 3)) - ) - val expectedTabletViewPorts = mapOf( - DeviceRotation.Natural to (Surface.ROTATION_0 to Rational(4, 3)), - DeviceRotation.Rotated270 to (Surface.ROTATION_90 to Rational(3, 4)), - DeviceRotation.Rotated180 to (Surface.ROTATION_180 to Rational(4, 3)), - DeviceRotation.Rotated90 to (Surface.ROTATION_270 to Rational(3, 4)) + fun previewTargetRotation_syncsWithWindowRotationInPlace_independentlyOfDeviceRotation() { + val expectedSurfaceRotations = mapOf( + DeviceRotation.Natural to Surface.ROTATION_0, + DeviceRotation.Rotated270 to Surface.ROTATION_90, + DeviceRotation.Rotated180 to Surface.ROTATION_180, + DeviceRotation.Rotated90 to Surface.ROTATION_270 ) - // Covers foldables, landscape-native tablets, and portrait W <= H split-screen panes on - // Rotated90 / Rotated270 displays across independent physical deviceRotations. - for (windowRot in DeviceRotation.entries) { - for (deviceRot in DeviceRotation.entries) { - val (expectedSurfaceRot, expectedPhoneRatio) = - expectedPhoneViewPorts.getValue(windowRot) - - val phonePreview = Preview.Builder().applyWindowRotation(windowRot).build() - val phoneImage = ImageCapture.Builder() - .setTargetRotation(deviceRot.toUiSurfaceRotation()) - .build() - val phoneViewPort = createSensorAlignedViewPort( - cameraInfo = phoneSensorInfo, - aspectRatio = AspectRatio.THREE_FOUR, - targetRotation = phonePreview.targetRotation - ) - val phoneGroup = UseCaseGroup.Builder() - .setViewPort(phoneViewPort) - .addUseCase(phonePreview) - .addUseCase(phoneImage) - .build() - - assertThat(phonePreview.targetRotation).isEqualTo(expectedSurfaceRot) - assertThat(phoneGroup.viewPort!!.rotation).isEqualTo(expectedSurfaceRot) - assertThat(phoneGroup.viewPort!!.aspectRatio).isEqualTo(expectedPhoneRatio) - assertThat(phoneImage.targetRotation).isEqualTo(deviceRot.toUiSurfaceRotation()) - - val (_, expectedTabletRatio) = expectedTabletViewPorts.getValue(windowRot) - val tabletPreview = Preview.Builder().applyWindowRotation(windowRot).build() - val tabletViewPort = createSensorAlignedViewPort( - cameraInfo = tabletLandscapeSensorInfo, - aspectRatio = AspectRatio.THREE_FOUR, - targetRotation = tabletPreview.targetRotation - ) - assertThat(tabletPreview.targetRotation).isEqualTo(expectedSurfaceRot) - assertThat(tabletViewPort.rotation).isEqualTo(expectedSurfaceRot) - assertThat(tabletViewPort.aspectRatio).isEqualTo(expectedTabletRatio) + for (initialWindowRot in DeviceRotation.entries) { + val preview = Preview.Builder().applyWindowRotation(initialWindowRot).build() + val imageCapture = ImageCapture.Builder() + .setTargetRotation(DeviceRotation.Natural.toUiSurfaceRotation()) + .build() + val group = UseCaseGroup.Builder() + .addUseCase(preview) + .addUseCase(imageCapture) + .build() + + assertThat(preview.targetRotation) + .isEqualTo(expectedSurfaceRotations.getValue(initialWindowRot)) + + for (updatedWindowRot in DeviceRotation.entries) { + for (deviceRot in DeviceRotation.entries) { + imageCapture.targetRotation = deviceRot.toUiSurfaceRotation() + applyWindowRotation(updatedWindowRot, group) + + assertThat(preview.targetRotation) + .isEqualTo(expectedSurfaceRotations.getValue(updatedWindowRot)) + assertThat(imageCapture.targetRotation) + .isEqualTo(deviceRot.toUiSurfaceRotation()) + } } } } @Test - fun perpetualSessionSettingsAndFakeCameraSystem_trackWindowRotationAcrossSessionTypes() { - val baseSingle = PerpetualSessionSettings.SingleCamera( - aspectRatio = AspectRatio.THREE_FOUR, - captureMode = CaptureMode.STANDARD, - activeCameraEffect = null, - targetFrameRate = 30, - stabilizationMode = StabilizationMode.OFF, - dynamicRange = DynamicRange.SDR, - videoQuality = VideoQuality.FHD, - mirrorMode = MirrorMode.OFF, - imageFormat = ImageOutputFormat.JPEG, - lowLightBoostPriority = LowLightBoostPriority.PRIORITIZE_AE_MODE - ) - assertThat(baseSingle.windowRotation).isEqualTo(DeviceRotation.Natural) - assertThat(baseSingle.copy(windowRotation = DeviceRotation.Rotated90)) - .isNotEqualTo(baseSingle) - - val testKey = object : CaptureSubModeFeatureKey { - override val id: CaptureSubModeId = CaptureSubModeId("test_concurrent") - } - val baseCustom = PerpetualSessionSettings.CustomSession( - subModeKey = testKey, - aspectRatio = AspectRatio.NINE_SIXTEEN, - captureMode = CaptureMode.STANDARD + fun transientSessionSettingsAndFakeCameraSystem_trackWindowRotation() { + val baseTransient = TransientSessionSettings( + isAudioEnabled = true, + deviceRotation = DeviceRotation.Natural, + flashMode = FlashMode.OFF, + primaryLensFacing = LensFacing.BACK, + zoomRatios = mapOf(LensFacing.BACK to 1f), + testPattern = TestPattern.Off ) - assertThat(baseCustom.windowRotation).isEqualTo(DeviceRotation.Natural) - assertThat(baseCustom.copy(windowRotation = DeviceRotation.Rotated270)) - .isNotEqualTo(baseCustom) + assertThat(baseTransient.windowRotation).isEqualTo(DeviceRotation.Natural) + assertThat(baseTransient.copy(windowRotation = DeviceRotation.Rotated90)) + .isNotEqualTo(baseTransient) val fakeSystem = FakeCameraSystem() assertThat(fakeSystem.windowRotation).isEqualTo(DeviceRotation.Natural) fakeSystem.setWindowRotation(DeviceRotation.Rotated180) assertThat(fakeSystem.windowRotation).isEqualTo(DeviceRotation.Rotated180) } - - private fun fakeBackCameraInfo(sensorOrientationDegrees: Int): CameraInfo = - Proxy.newProxyInstance( - CameraInfo::class.java.classLoader, - arrayOf(CameraInfo::class.java) - ) { proxy, method, args -> - when (method.name) { - "equals" -> proxy === args?.firstOrNull() - "hashCode" -> System.identityHashCode(proxy) - "toString" -> "FakeCameraInfo($sensorOrientationDegrees)" - "getLensFacing" -> CameraSelector.LENS_FACING_BACK - "getSensorRotationDegrees" -> { - val relativeSurfaceRotation = - (args?.firstOrNull() as? Int) ?: Surface.ROTATION_0 - val surfaceCounterClockwiseDegrees = when (relativeSurfaceRotation) { - Surface.ROTATION_0 -> 0 - Surface.ROTATION_90 -> 90 - Surface.ROTATION_180 -> 180 - Surface.ROTATION_270 -> 270 - else -> 0 - } - (sensorOrientationDegrees - surfaceCounterClockwiseDegrees + 360) % 360 - } - else -> when (method.returnType) { - Boolean::class.javaPrimitiveType -> false - Int::class.javaPrimitiveType -> 0 - Float::class.javaPrimitiveType -> 0f - else -> null - } - } - } as CameraInfo }