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..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 @@ -147,7 +148,8 @@ internal suspend fun CameraSessionContext.runConcurrentCameraSession( captureMode = sessionSettings.captureMode, videoCaptureUseCase = videoCapture, previewMirrorMode = sessionSettings.mirrorMode - .toCXConcurrentPreviewMirrorMode() + .toCXConcurrentPreviewMirrorMode(), + windowRotation = initialTransientSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) } @@ -307,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 cd1fb74050..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 @@ -302,7 +302,8 @@ internal suspend fun runSingleCameraSession( imageFormat = sessionSettings.imageFormat, captureMode = sessionSettings.captureMode, effect = effect, - captureResults = captureResults + captureResults = captureResults, + windowRotation = currentTransientSettings.windowRotation ) }.apply { attachPreviewSurfaceProvider(useCaseGroup = this) @@ -333,6 +334,7 @@ internal suspend fun runSingleCameraSession( } applyDeviceRotation(currentTransientSettings.deviceRotation, useCaseGroup) + applyWindowRotation(currentTransientSettings.windowRotation, useCaseGroup) if (videoCaptureUseCase != null) { val resolutionInfo = videoCaptureUseCase.resolutionInfo @@ -613,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] != @@ -740,11 +752,8 @@ 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 tracks TransientSessionSettings.windowRotation via + // applyWindowRotation rather than physical device gravity rotation. } is ImageCapture -> { @@ -758,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,15 +800,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 = initialTransientSettings.windowRotation ): 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,12 +831,14 @@ 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" ) + // TODO(#592): Plumb windowRotation into PR #592's getViewPortAspectRatio / + // isPreviewLandscape once #592 merges into main. setViewPort( ViewPort.Builder( Rational(aspectRatio.numerator, aspectRatio.denominator), - // Initialize rotation to Preview's rotation, which comes from Display rotation previewUseCase.targetRotation ).build() ) @@ -818,6 +852,12 @@ fun createUseCaseGroup( }.build() } +/** + * Applies [windowRotation] as the target rotation of this [Preview.Builder]. + */ +internal fun Preview.Builder.applyWindowRotation(windowRotation: DeviceRotation): Preview.Builder = + setTargetRotation(windowRotation.toUiSurfaceRotation()) + /** * Maps a given resolution [Size] to its corresponding [VideoQuality] based on the short edge. * @@ -946,8 +986,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..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 @@ -80,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/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..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 @@ -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,75 @@ 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, + windowRotation = windowRotation + ) - 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 + ) + } 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 + ) } + } .distinctUntilChanged() .collectLatest { sessionSettings -> if (sessionSettings == null) return@collectLatest @@ -1394,6 +1398,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..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 @@ -13,11 +13,22 @@ * 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.Size +import android.view.Surface +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.testing.FakeCameraSystem +import com.google.jetpackcamera.model.DeviceRotation +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 org.junit.Test import org.junit.runner.RunWith @@ -99,4 +110,60 @@ class CameraSessionTest { assertThat(getWidthFromCropRect(null, rotationDegrees = 90)).isEqualTo(0) assertThat(getHeightFromCropRect(null, rotationDegrees = 90)).isEqualTo(0) } + + @Test + 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 + ) + + 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 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(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) + } } 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/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/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) + } }