Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@ enum class DeviceRotation {
}
}

/**
* Returns the clockwise rotation in degrees needed inside a window at [windowRotation] so that
* UI elements remain oriented upright against physical gravity (`this` device rotation).
*
* An upside-down (`180°`) delta is mapped to `0` so camera controls and layouts never flip
* upside-down.
*/
fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int {
val rawDelta =
(
(windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) %
360 + 360
) % 360
return if (rawDelta == 180) 0 else rawDelta
}

/**
* Returns the clockwise rotation in degrees needed to counter-rotate a root container back to
* the device's natural portrait coordinate frame when the OS window is rotated to `this`
* orientation.
*/
fun toWindowCounterRotationDegrees(): Float {
return when (this) {
Natural -> 0f
Rotated90 -> 90f
Rotated180 -> 180f
Rotated270 -> -90f
}
}

val isLandscape: Boolean
get() = this == Rotated90 || this == Rotated270

companion object {
/**
* Returns the [DeviceRotation] corresponding to a UI [Surface] rotation constant.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.model

import android.view.Surface
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class DeviceRotationTest {
Comment on lines +18 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, all test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. Since this test uses Android-specific classes like Surface, it should be run with RobolectricTestRunner.

import android.view.Surface
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class DeviceRotationTest {
References
  1. All test classes must be annotated with @RunWith(...) to explicitly declare which test runner should be used. (link)


@Test
fun fromUiSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() {
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_0))
.isEqualTo(DeviceRotation.Natural)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_90))
.isEqualTo(DeviceRotation.Rotated270)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_180))
.isEqualTo(DeviceRotation.Rotated180)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_270))
.isEqualTo(DeviceRotation.Rotated90)
assertThat(DeviceRotation.fromUiSurfaceRotation(-1))
.isEqualTo(DeviceRotation.Natural)

for (rotation in DeviceRotation.entries) {
assertThat(DeviceRotation.fromUiSurfaceRotation(rotation.toUiSurfaceRotation()))
.isEqualTo(rotation)
}
}

@Test
fun toGravityRotationDegrees_computesInWindowGravityCompensationAngle() {
// Phone upright (Dev 0°, Win 0°) -> Icons 0°
assertThat(
DeviceRotation.Natural.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(0)

// Phone turned 90° CCW with locked portrait window (Dev 270°, Win 0°) -> Icons 90°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(90)

// Phone turned 90° CW with locked portrait window (Dev 90°, Win 0°) -> Icons 270°
assertThat(
DeviceRotation.Rotated90.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(270)

// Foldable/Tablet turned 90° CCW with OS auto-rotate ON (Dev 270°, Win 270°) -> Icons 0°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Rotated270
)
).isEqualTo(0)

// Foldable/Tablet turned 90° CCW with OS auto-rotate OFF (Dev 270°, Win 0°) -> Icons 90°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(90)

// Upside-down delta (180°) clamps to 0° so UI never flips upside-down
assertThat(
DeviceRotation.Rotated180.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(0)
assertThat(
DeviceRotation.Rotated90.toGravityRotationDegrees(
windowRotation = DeviceRotation.Rotated270
)
).isEqualTo(0)
}

@Test
fun toWindowCounterRotationDegrees_andIsLandscape_matchAllQuadrants() {
assertThat(DeviceRotation.Natural.toWindowCounterRotationDegrees()).isEqualTo(0f)
assertThat(DeviceRotation.Rotated90.toWindowCounterRotationDegrees()).isEqualTo(90f)
assertThat(DeviceRotation.Rotated180.toWindowCounterRotationDegrees()).isEqualTo(180f)
assertThat(DeviceRotation.Rotated270.toWindowCounterRotationDegrees()).isEqualTo(-90f)

assertThat(DeviceRotation.Natural.isLandscape).isFalse()
assertThat(DeviceRotation.Rotated180.isLandscape).isFalse()
assertThat(DeviceRotation.Rotated90.isLandscape).isTrue()
assertThat(DeviceRotation.Rotated270.isLandscape).isTrue()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tracing.Trace
import com.google.jetpackcamera.core.camera.VideoRecordingState
import com.google.jetpackcamera.model.CaptureEvent
import com.google.jetpackcamera.model.ExternalCaptureMode
import com.google.jetpackcamera.model.ImageCaptureEvent
Expand All @@ -40,11 +45,15 @@ import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldDefaults
import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldLayout
import com.google.jetpackcamera.ui.components.capture.LOADING_CAPTURE_UI_STATE_SKELETON
import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton
import com.google.jetpackcamera.ui.components.capture.rememberWindowRotation
import com.google.jetpackcamera.ui.debug.DebugLayoutReadoutState
import com.google.jetpackcamera.ui.debug.DebugOverlay
import com.google.jetpackcamera.ui.debug.DebugUiState
import com.google.jetpackcamera.ui.debug.rememberDebugFormFactor
import com.google.jetpackcamera.ui.uistate.SnackBarUiState
import com.google.jetpackcamera.ui.uistate.capture.AudioUiState
import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.transformWhile

private const val TAG = "PreviewScreen"
Expand Down Expand Up @@ -76,9 +85,26 @@ fun PreviewScreen(
val surfaceRequest: SurfaceRequest?
by viewModel.surfaceRequest.collectAsState()

val readyStateProvider: () -> CaptureUiState.Ready = remember {
{
(rawUiState.value as? CaptureUiState.Ready) ?: LOADING_CAPTURE_UI_STATE_SKELETON
}
}
val videoRecordingState = remember {
derivedStateOf { readyStateProvider().videoRecordingState }
}
val isRecordingInProgress = remember {
derivedStateOf { videoRecordingState.value !is VideoRecordingState.Inactive }
}

val windowRotation = rememberWindowRotation()
var deviceRotation by remember { mutableStateOf(windowRotation) }

CameraSessionEffect(
cameraController = viewModel.cameraController,
enabled = true,
isRecording = isRecordingInProgress.value,
onDeviceRotationChanged = { deviceRotation = it },
onSessionStarted = viewModel::startLocationUpdates,
onSessionStopped = viewModel::stopLocationUpdates
)
Expand Down Expand Up @@ -113,12 +139,6 @@ fun PreviewScreen(
}
}

val readyStateProvider: () -> CaptureUiState.Ready = remember {
{
(rawUiState.value as? CaptureUiState.Ready) ?: LOADING_CAPTURE_UI_STATE_SKELETON
}
}

val debugController = viewModel.debugController
val currentDebugUiState = debugUiState
val isDebugHidingComponents =
Expand All @@ -133,14 +153,13 @@ fun PreviewScreen(
val audioState = remember {
derivedStateOf { readyStateProvider().audioUiState }
}
val videoRecordingState = remember {
derivedStateOf { readyStateProvider().videoRecordingState }
}

CaptureScaffold(
captureUiState = readyStateProvider,
surfaceRequest = surfaceRequest,
modifier = modifier,
deviceRotation = deviceRotation,
windowRotation = windowRotation,
onNavigateToSettings = onNavigateToSettings,
onRequestWindowColorMode = onRequestWindowColorMode,
onRequiresImmersiveChange = onRequiresImmersiveChange,
Expand Down Expand Up @@ -172,6 +191,20 @@ fun PreviewScreen(
},
overlay = { slotModifier ->
if (currentDebugUiState is DebugUiState.Enabled) {
val (widthDp, heightDp) = with(LocalDensity.current) {
val containerSize = LocalWindowInfo.current.containerSize
containerSize.width.toDp().value.roundToInt() to
containerSize.height.toDp().value.roundToInt()
}
val formFactor = rememberDebugFormFactor(widthDp = widthDp, heightDp = heightDp)
val layoutReadoutState = DebugLayoutReadoutState(
widthDp = widthDp,
heightDp = heightDp,
formFactor = formFactor,
activeLayoutName = "CaptureScaffoldLayout",
deviceRotation = deviceRotation,
windowRotation = windowRotation
)
DebugOverlay(
modifier = slotModifier,
onChangeZoomRatio = { ratio ->
Expand All @@ -195,7 +228,8 @@ fun PreviewScreen(
currentRecordingStateProvider = { videoRecordingState.value }
)
},
debugController = debugController
debugController = debugController,
layoutReadoutState = layoutReadoutState
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,61 @@
*/
package com.google.jetpackcamera.ui.components.capture

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.google.jetpackcamera.model.DeviceRotation
import com.google.jetpackcamera.ui.controller.CameraController

/**
* Composable effect that manages the camera session lifecycle and display rotation updates.
* Composable effect that manages the camera session lifecycle, display rotation updates, and
* recording orientation lock.
*
* While [enabled] is `true` and the current [LocalLifecycleOwner] is at least
* [Lifecycle.State.STARTED], this effect:
* 1. Starts the camera via [CameraController.startCamera] and invokes [onSessionStarted], stopping
* the camera via [CameraController.stopCamera] and invoking [onSessionStopped] when the
* lifecycle stops, [enabled] becomes `false`, or the effect leaves the composition.
* 2. Collects [debouncedOrientationFlow] and forwards device rotation changes to
* [CameraController.setDisplayRotation].
* [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.
*
* [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 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 onDeviceRotationChanged optional callback notified with the active (unfrozen) physical
* [DeviceRotation].
* @param onSessionStarted optional callback invoked right after [CameraController.startCamera].
* @param onSessionStopped optional callback invoked right before [CameraController.stopCamera].
*/
@Composable
fun CameraSessionEffect(
cameraController: CameraController,
enabled: Boolean,
isRecording: Boolean = false,
onDeviceRotationChanged: (DeviceRotation) -> Unit = {},
onSessionStarted: () -> Unit = {},
onSessionStopped: () -> Unit = {}
) {
Expand All @@ -72,11 +92,54 @@ fun CameraSessionEffect(
if (enabled) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentIsRecording by rememberUpdatedState(isRecording)
val currentOnDeviceRotationChanged by rememberUpdatedState(onDeviceRotationChanged)
var pendingRotation by remember(cameraController) { mutableStateOf<DeviceRotation?>(null) }
var lastDeliveredRotation by remember(cameraController) {
mutableStateOf<DeviceRotation?>(null)
}

LaunchedEffect(cameraController, context, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
debouncedOrientationFlow(context)
.collect(cameraController::setDisplayRotation)
debouncedOrientationFlow(context).collect { rotation ->
pendingRotation = rotation
if (!currentIsRecording) {
lastDeliveredRotation = rotation
cameraController.setDisplayRotation(rotation)
currentOnDeviceRotationChanged(rotation)
}
}
}
}

LaunchedEffect(isRecording, cameraController) {
if (!isRecording) {
val rotation = pendingRotation
if (rotation != null && rotation != lastDeliveredRotation) {
lastDeliveredRotation = rotation
cameraController.setDisplayRotation(rotation)
currentOnDeviceRotationChanged(rotation)
}
}
}

val activity = LocalActivity.current ?: remember(context) { context.findSessionActivity() }
DisposableEffect(activity, isRecording) {
if (activity != null && isRecording) {
val previousOrientation = activity.requestedOrientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
onDispose {
activity.requestedOrientation = previousOrientation
}
} else {
onDispose {}
}
}
}
}

private tailrec fun Context.findSessionActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findSessionActivity()
else -> null
}
Loading
Loading