From ef62983406fecdd37c4ddab2d5a6f2d7c057b2ef Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 2 Nov 2026 19:38:29 -0800 Subject: [PATCH 01/17] feat(ui/debug): add adaptive layout posture readout to debug console Add DebugLayoutReadoutState and internal DebugLayoutReadout composable to display window container size in dp, display rotation degrees, rotation-lock status, and active adaptive layout mode in DebugConsole, falling back to live LocalWindowInfo container dimensions when explicit posture state is omitted. Also fix DebugTextBar applying its outer Row modifier three times to its child Text nodes, configure Robolectric Compose unit testing for :ui:debug, and add unit tests covering explicit/default readouts, live window fallback, modifier scoping, and hidden-component behavior. --- ui/debug/build.gradle.kts | 15 ++ .../ui/debug/DebugOverlayComponents.kt | 141 ++++++++++++- .../google/jetpackcamera/ui/debug/TestTags.kt | 1 + ui/debug/src/main/res/values/strings.xml | 5 + .../ui/debug/DebugLayoutReadoutTest.kt | 196 ++++++++++++++++++ 5 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt diff --git a/ui/debug/build.gradle.kts b/ui/debug/build.gradle.kts index 33657d8fdb..651496c8bf 100644 --- a/ui/debug/build.gradle.kts +++ b/ui/debug/build.gradle.kts @@ -36,6 +36,13 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + flavorDimensions += "flavor" productFlavors { create("stable") { @@ -73,9 +80,17 @@ dependencies { // CameraX implementation(libs.camera.core) + // Compose - Testing + debugImplementation(libs.compose.test.manifest) + // noinspection TestManifestGradleConfiguration: required for release build unit tests + testImplementation(libs.compose.test.manifest) + testImplementation(libs.compose.junit) + // Testing testImplementation(libs.junit) testImplementation(libs.truth) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) androidTestImplementation(libs.truth) androidTestImplementation(libs.androidx.junit) diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index ad3cfdad72..3935eee8e7 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -42,10 +42,13 @@ import androidx.compose.material3.Button import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,6 +57,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -61,10 +66,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.jetpackcamera.model.TestPattern import kotlin.math.abs +import kotlin.math.roundToInt private const val TAG = "DebugOverlayComponents" @@ -105,12 +112,83 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float ) } +/** + * Snapshot of window geometry, display rotation, rotation-lock state, and adaptive layout mode + * displayed by the debug console's layout readout row. + * + * When passed to [DebugOverlay], these explicit values override the live window-container fallback + * used when adaptive layout posture state is omitted. + * + * @property widthDp Current window container width in density-independent pixels. + * @property heightDp Current window container height in density-independent pixels. + * @property rotationDegrees Display rotation in clockwise degrees (typically 0, 90, 180, or 270). + * @property isRotationLocked Whether system auto-rotate is locked (`LOCKED`) or enabled + * (`UNLOCKED`). + * @property layoutModeName Active adaptive layout mode label, or `null` to display the default + * compact portrait mode label. + */ +@Immutable +data class DebugLayoutReadoutState( + val widthDp: Int, + val heightDp: Int, + val rotationDegrees: Int = 0, + val isRotationLocked: Boolean = false, + val layoutModeName: String? = null +) + +/** + * Displays a compact single-line debug readout showing window container size in dp, display + * rotation degrees, rotation-lock state, and the active adaptive layout mode. + * + * When [state] is `null`, window dimensions are derived live from [LocalWindowInfo] and + * [LocalDensity] with `0°`, `UNLOCKED`, and the default `CompactPortrait` mode label. + * + * @param state Explicit layout readout values to display, or `null` to use live window dimensions. + * @param modifier Modifier applied to the outer readout row. + */ +@Composable +internal fun DebugLayoutReadout( + state: DebugLayoutReadoutState? = null, + modifier: Modifier = Modifier +) { + val resolvedState = state ?: with(LocalDensity.current) { + val containerSize = LocalWindowInfo.current.containerSize + DebugLayoutReadoutState( + widthDp = containerSize.width.toDp().value.roundToInt(), + heightDp = containerSize.height.toDp().value.roundToInt() + ) + } + val lockText = stringResource( + if (resolvedState.isRotationLocked) { + R.string.debug_text_rotation_locked + } else { + R.string.debug_text_rotation_unlocked + } + ) + val modeText = resolvedState.layoutModeName + ?: stringResource(R.string.debug_text_default_layout_mode) + val formattedValue = stringResource( + R.string.debug_text_layout_value, + resolvedState.widthDp, + resolvedState.heightDp, + resolvedState.rotationDegrees, + lockText, + modeText + ) + DebugTextBar( + modifier = modifier, + title = stringResource(R.string.debug_text_layout_prefix), + value = formattedValue, + tag = DEBUG_LAYOUT_READOUT_TAG + ) +} + @Composable private fun DebugTextBar(modifier: Modifier = Modifier, title: String, value: String, tag: String) { Row(modifier = modifier) { - Text(modifier = modifier.background(Color.Black.copy(alpha = .7f)), text = title) + Text(modifier = Modifier.background(Color.Black.copy(alpha = .7f)), text = title) Text( - modifier = modifier + modifier = Modifier .background(Color.Black.copy(alpha = .4f)) .testTag(tag), text = value @@ -150,13 +228,31 @@ private fun ToggleVisibilityButton( } } +/** + * Renders the top-level debug console and, when open, the modal debug inspection dialogs over the + * camera preview. + * + * When [DebugUiState.Enabled.debugHidingComponents] is `true`, only the visibility toggle button is + * shown so the camera feed can be inspected unobstructed. + * + * @param modifier Modifier applied to the root overlay container. + * @param onChangeZoomRatio Callback invoked when the user sets an explicit zoom ratio in the debug + * dialog. + * @param debugUiState Current enabled debug UI state. + * @param extraControls Additional debug action composables rendered alongside the debug menu + * button. + * @param debugController Controller handling visibility, dialog, and test-pattern interactions. + * @param layoutReadoutState Explicit adaptive layout posture readout state, or `null` to derive + * window dimensions from the current window container. + */ @Composable fun DebugOverlay( modifier: Modifier = Modifier, onChangeZoomRatio: (Float) -> Unit, debugUiState: DebugUiState.Enabled, vararg extraControls: @Composable () -> Unit, - debugController: DebugController + debugController: DebugController, + layoutReadoutState: DebugLayoutReadoutState? = null ) { Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.padding(top = 100.dp)) { @@ -168,7 +264,8 @@ fun DebugOverlay( DebugConsole( debugUiState = debugUiState, onToggleDebugOverlay = debugController::toggleDebugOverlay, - extraControls = extraControls + extraControls = extraControls, + layoutReadoutState = layoutReadoutState ) } } @@ -195,13 +292,17 @@ fun DebugOverlay( * @param onToggleDebugOverlay a callback to open and hide the [DebugDialogContainer] * @param extraControls additional composable functions to be displayed in the debug top row. * These should NOT include components intended to be exclusive to the debug screen. + * @param modifier modifier applied to the root console column. + * @param layoutReadoutState explicit layout posture values for [DebugLayoutReadout], or `null` to + * derive window dimensions from the current window container. */ @Composable private fun DebugConsole( debugUiState: DebugUiState.Enabled, onToggleDebugOverlay: () -> Unit, vararg extraControls: @Composable () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + layoutReadoutState: DebugLayoutReadoutState? = null ) { Column(modifier = modifier) { FlowRow( @@ -218,6 +319,7 @@ private fun DebugConsole( modifier = Modifier, primaryZoomRatio = debugUiState.currentPrimaryZoomRatio ) + DebugLayoutReadout(state = layoutReadoutState) } } @@ -470,3 +572,32 @@ private enum class SelectedDialog { SetZoom, SetTestPattern } + +@Preview(name = "Unlocked CompactPortrait", showBackground = true) +@Composable +private fun DebugLayoutReadoutUnlockedPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 412, + heightDp = 915 + ) + ) + } +} + +@Preview(name = "Locked TwoSideRail", showBackground = true) +@Composable +private fun DebugLayoutReadoutLockedTwoSideRailPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 840, + heightDp = 360, + rotationDegrees = 90, + isRotationLocked = true, + layoutModeName = "TwoSideRail" + ) + ) + } +} diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index a4edfcc2dd..528c319808 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -21,6 +21,7 @@ const val PHYSICAL_CAMERA_ID_TAG = "PhysicalCameraIdTag" const val DEBUG_OVERLAY_BUTTON = "DebugOverlayButton" const val BTN_DEBUG_HIDE_COMPONENTS_TAG = "btn_debug_hide_components" +const val DEBUG_LAYOUT_READOUT_TAG = "text_debug_layout_readout" const val DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON = "DebugOverlayShowCameraPropertiesButton" const val DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON = "DebugOverlaySetZoomRatioButton" const val DEBUG_OVERLAY_CAMERA_PROPERTIES_TAG = "DebugOverlayCameraPropertiesTag" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index c7084c4b4b..7cb29777ea 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -23,4 +23,9 @@ Hiding components Displaying components %1$.2fx + "Layout: " + %1$dx%2$ddp | %3$d° | %4$s | %5$s + LOCKED + UNLOCKED + CompactPortrait diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt new file mode 100644 index 0000000000..4e77f16fb9 --- /dev/null +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -0,0 +1,196 @@ +/* + * 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.ui.debug + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.TestPattern +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric Compose unit tests for [DebugLayoutReadout], [DebugTextBar] modifier scoping, and + * [DebugOverlay] integration. + */ +@RunWith(RobolectricTestRunner::class) +class DebugLayoutReadoutTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun debugLayoutReadout_whenLockedTwoSideRail_displaysFormattedState() { + composeTestRule.setContent { + MaterialTheme { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 840, + heightDp = 360, + rotationDegrees = 90, + isRotationLocked = true, + layoutModeName = "TwoSideRail" + ) + ) + } + } + + composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) + .isEqualTo("840x360dp | 90° | LOCKED | TwoSideRail") + } + + @Test + fun debugLayoutReadout_whenDefaultsUsed_displaysUnlockedCompactPortrait() { + composeTestRule.setContent { + MaterialTheme { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 412, + heightDp = 915 + ) + ) + } + } + + composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) + .isEqualTo("412x915dp | 0° | UNLOCKED | CompactPortrait") + } + + @Test + fun debugLayoutReadout_appliesModifierOnlyToOuterRow_notChildTextNodes() { + composeTestRule.setContent { + MaterialTheme { + DebugLayoutReadout( + state = DebugLayoutReadoutState(widthDp = 412, heightDp = 915), + modifier = Modifier.testTag("row_debug_layout") + ) + } + } + + assertThat( + composeTestRule.onAllNodesWithTag("row_debug_layout").fetchSemanticsNodes().size + ).isEqualTo(1) + assertThat( + composeTestRule + .onAllNodesWithTag("row_debug_layout", useUnmergedTree = true) + .fetchSemanticsNodes() + .size + ).isEqualTo(1) + } + + @Test + @Config(qualifiers = "w360dp-h800dp") + fun debugOverlay_whenLayoutReadoutStateIsNull_derivesLiveWindowDimensions() { + composeTestRule.setContent { + MaterialTheme { + DebugOverlay( + onChangeZoomRatio = {}, + debugUiState = DebugUiState.Enabled.Closed( + currentPhysicalCameraId = "0", + currentLogicalCameraId = "0", + currentPrimaryZoomRatio = 1f, + debugHidingComponents = false + ), + debugController = FakeDebugController() + ) + } + } + + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) + .isEqualTo("360x800dp | 0° | UNLOCKED | CompactPortrait") + } + + @Test + fun debugOverlay_whenExplicitLayoutReadoutStateProvided_displaysExplicitState() { + composeTestRule.setContent { + MaterialTheme { + DebugOverlay( + onChangeZoomRatio = {}, + debugUiState = DebugUiState.Enabled.Closed( + currentPhysicalCameraId = "0", + currentLogicalCameraId = "0", + currentPrimaryZoomRatio = 1f, + debugHidingComponents = false + ), + debugController = FakeDebugController(), + layoutReadoutState = DebugLayoutReadoutState( + widthDp = 840, + heightDp = 360, + rotationDegrees = 270, + isRotationLocked = true, + layoutModeName = "TwoSideRail" + ) + ) + } + } + + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) + .isEqualTo("840x360dp | 270° | LOCKED | TwoSideRail") + } + + @Test + fun debugOverlay_whenDebugHidingComponentsIsTrue_hidesLayoutReadout() { + composeTestRule.setContent { + MaterialTheme { + DebugOverlay( + onChangeZoomRatio = {}, + debugUiState = DebugUiState.Enabled.Closed( + currentPhysicalCameraId = "0", + currentLogicalCameraId = "0", + currentPrimaryZoomRatio = 1f, + debugHidingComponents = true + ), + debugController = FakeDebugController(), + layoutReadoutState = DebugLayoutReadoutState(widthDp = 412, heightDp = 915) + ) + } + } + + composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertDoesNotExist() + assertThat( + composeTestRule.onAllNodesWithTag(DEBUG_LAYOUT_READOUT_TAG).fetchSemanticsNodes() + ).isEmpty() + } + + private fun readNodeText(tag: String): String = composeTestRule + .onNodeWithTag(tag) + .fetchSemanticsNode() + .config[SemanticsProperties.Text] + .joinToString(separator = "") { it.text } +} + +private class FakeDebugController : DebugController { + override fun toggleDebugHidingComponents() = Unit + override fun toggleDebugOverlay() = Unit + override fun setTestPattern(testPattern: TestPattern) = Unit +} From 37ff24014d16df198bf2bc38f2bdc89f2bc9a6b0 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 2 Nov 2026 19:43:31 -0800 Subject: [PATCH 02/17] feat(ui): support adaptive landscape framing and LTR isolation in PreviewDisplay - Extract @VisibleForTesting internal calculateViewfinderSize with non-positive bounds guard and landscape aspect ratio selection when useLandscapeAspectRatio && maxWidth > maxHeight - Add contentAlignment and useLandscapeAspectRatio trailing default parameters to PreviewDisplay - Replace BoxWithConstraints with standard Box + Modifier.layout measurement and graphicsLayer draw-phase blink alpha/clip - Stabilize gesture callbacks with rememberUpdatedState and migrate rememberTransformableState to non-deprecated centroid overload - Enforce LayoutDirection.Ltr inside PreviewDisplay viewfinder scope so tap-to-focus coordinates and FocusMeteringIndicator never mirror in RTL - Add PreviewDisplay @Previews and Robolectric unit/Compose tests in PreviewDisplayTest --- .../capture/CaptureScreenComponents.kt | 325 ++++++++---- .../components/capture/PreviewDisplayTest.kt | 498 ++++++++++++++++++ 2 files changed, 719 insertions(+), 104 deletions(-) create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt 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 9134ee7e87..13329b86c4 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 @@ -57,18 +57,15 @@ import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults @@ -103,8 +100,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Brush @@ -113,8 +108,10 @@ import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -126,8 +123,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.unit.sp @@ -774,6 +774,44 @@ private fun DetectWindowColorModeChanges( } } +/** + * Calculates the fitted viewfinder dimensions inside the available container bounds while + * preserving the target [aspectRatio] without cropping. + * + * When [useLandscapeAspectRatio] is `true` and [maxWidth] strictly exceeds [maxHeight], the + * landscape orientation of [aspectRatio] (e.g., 4:3 instead of 3:4) is used so wide containers + * frame horizontal camera output naturally. Otherwise, the portrait [AspectRatio.toFloat] ratio is + * preserved. + * + * @param maxWidth the maximum available container width. + * @param maxHeight the maximum available container height. + * @param aspectRatio the target [AspectRatio] for the preview. + * @param useLandscapeAspectRatio whether to orient [aspectRatio] horizontally when [maxWidth] + * exceeds [maxHeight]. + * @return the fitted [DpSize], or [DpSize.Zero] if either bound is non-positive. + */ +@VisibleForTesting +internal fun calculateViewfinderSize( + maxWidth: Dp, + maxHeight: Dp, + aspectRatio: AspectRatio, + useLandscapeAspectRatio: Boolean +): DpSize { + if (maxWidth <= 0.dp || maxHeight <= 0.dp) { + return DpSize.Zero + } + val maxAspectRatio: Float = maxWidth / maxHeight + val aspectRatioFloat: Float = if (useLandscapeAspectRatio && maxWidth > maxHeight) { + aspectRatio.toLandscapeFloat() + } else { + aspectRatio.toFloat() + } + val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat + val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat + val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat + return DpSize(width, height) +} + /** * A composable that displays the camera preview and handles user input gestures. * @@ -791,6 +829,9 @@ private fun DetectWindowColorModeChanges( * @param surfaceRequest the [SurfaceRequest] for the preview. * @param focusMeteringUiState the [FocusMeteringUiState] for this component. * @param modifier the modifier for this component. + * @param contentAlignment the alignment of the fitted viewfinder within the available bounds. + * @param useLandscapeAspectRatio whether to orient the target [AspectRatio] horizontally + * (e.g., 4:3 instead of 3:4) when the available container width exceeds its height. */ @Composable fun PreviewDisplay( @@ -801,127 +842,203 @@ fun PreviewDisplay( onRequestWindowColorMode: (Int) -> Unit, surfaceRequest: SurfaceRequest?, focusMeteringUiState: FocusMeteringUiState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopCenter, + useLandscapeAspectRatio: Boolean = false ) { val aspectRatio = (previewDisplayUiState.aspectRatioUiState as? AspectRatioUiState.Available) ?.selectedAspectRatio ?: AspectRatio.THREE_FOUR - @Suppress("DEPRECATION") + val currentOnTapToFocus by rememberUpdatedState(onTapToFocus) + val currentOnFlipCamera by rememberUpdatedState(onFlipCamera) + val currentOnScaleZoom by rememberUpdatedState(onScaleZoom) + val transformableState = rememberTransformableState( - onTransformation = { pinchZoomChange, _, _ -> - onScaleZoom(pinchZoomChange) + onTransformation = { _, pinchZoomChange, _, _ -> + currentOnScaleZoom(pinchZoomChange) } ) - BoxWithConstraints( - modifier - .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.TopCenter - ) { - val maxAspectRatio: Float = maxWidth / maxHeight - val aspectRatioFloat: Float = aspectRatio.toFloat() - val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat - val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat - val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat - var imageVisible by remember { mutableStateOf(true) } - val targetBoundsState = LocalOverlapTargetBounds.current + var imageVisible by remember { mutableStateOf(true) } + val targetBoundsState = LocalOverlapTargetBounds.current - val disableAnimations = LocalDisableAnimations.current - val imageAlpha: Float by animateFloatAsState( - targetValue = if (imageVisible) 1f else 0f, - animationSpec = if (disableAnimations) { - snap() - } else { - tween( - durationMillis = (BLINK_TIME / 2).toInt(), - easing = LinearEasing - ) - }, - label = "" - ) + val disableAnimations = LocalDisableAnimations.current + val imageAlphaState = animateFloatAsState( + targetValue = if (imageVisible) 1f else 0f, + animationSpec = if (disableAnimations) { + snap() + } else { + tween( + durationMillis = (BLINK_TIME / 2).toInt(), + easing = LinearEasing + ) + }, + label = "PreviewBlinkAlpha" + ) + val viewfinderShape = remember { RoundedCornerShape(ViewfinderCornerRadius) } - LaunchedEffect(previewDisplayUiState.lastBlinkTimeStamp) { - if (previewDisplayUiState.lastBlinkTimeStamp != 0L) { - imageVisible = false - delay(BLINK_TIME) - imageVisible = true - } + LaunchedEffect(previewDisplayUiState.lastBlinkTimeStamp) { + if (previewDisplayUiState.lastBlinkTimeStamp != 0L) { + imageVisible = false + delay(BLINK_TIME) + imageVisible = true } + } - Box( - modifier = Modifier - .testTag(PREVIEW_DISPLAY) - .onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - if (targetBoundsState.value != bounds) { - targetBoundsState.value = bounds + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = contentAlignment + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( + modifier = Modifier + .testTag(PREVIEW_DISPLAY) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + if (targetBoundsState.value != bounds) { + targetBoundsState.value = bounds + } + } + .layout { measurable, constraints -> + val size = calculateViewfinderSize( + maxWidth = constraints.maxWidth.toDp(), + maxHeight = constraints.maxHeight.toDp(), + aspectRatio = aspectRatio, + useLandscapeAspectRatio = useLandscapeAspectRatio + ) + 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(widthPx, heightPx) { + placeable.place(0, 0) + } + } + .transformable(state = transformableState) + .graphicsLayer { + alpha = imageAlphaState.value + shape = viewfinderShape + clip = true + } + ) { + if (surfaceRequest != null) { + val implementationMode = when { + Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL + else -> ImplementationMode.EMBEDDED } - } - .width(width) - .height(height) - .transformable(state = transformableState) - .alpha(imageAlpha) - .clip(RoundedCornerShape(ViewfinderCornerRadius)) - ) { - if (surfaceRequest != null) { - val implementationMode = when { - Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL - else -> ImplementationMode.EMBEDDED - } - DetectWindowColorModeChanges( - surfaceRequest = surfaceRequest, - implementationMode = implementationMode, - onRequestWindowColorMode = onRequestWindowColorMode - ) + DetectWindowColorModeChanges( + surfaceRequest = surfaceRequest, + implementationMode = implementationMode, + onRequestWindowColorMode = onRequestWindowColorMode + ) - val coordinateTransformer = remember { MutableCoordinateTransformer() } - val viewfinderDescription = - stringResource(R.string.camera_viewfinder_content_description) - CameraXViewfinder( - modifier = Modifier - .fillMaxSize() - .semantics { contentDescription = viewfinderDescription } - .pointerInput(onFlipCamera) { - detectTapGestures( - onDoubleTap = { offset -> - // double tap to flip camera - Log.d(TAG, "onDoubleTap $offset") - onFlipCamera() - }, - onTap = { - with(coordinateTransformer) { - val surfaceCoords = it.transform() - Log.d( - "TAG", - "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" - ) - onTapToFocus(surfaceCoords.x, surfaceCoords.y) + val coordinateTransformer = remember { MutableCoordinateTransformer() } + val viewfinderDescription = + stringResource(R.string.camera_viewfinder_content_description) + CameraXViewfinder( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = viewfinderDescription } + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + // double tap to flip camera + Log.d(TAG, "onDoubleTap $offset") + currentOnFlipCamera() + }, + onTap = { + with(coordinateTransformer) { + val surfaceCoords = it.transform() + Log.d( + TAG, + "onTapToFocus: " + + "input{$it} -> surface{$surfaceCoords}" + ) + currentOnTapToFocus(surfaceCoords.x, surfaceCoords.y) + } } - } - ) - }, - surfaceRequest = surfaceRequest, - implementationMode = implementationMode, - coordinateTransformer = coordinateTransformer - ) - FocusMeteringIndicator( - focusMeteringUiState = focusMeteringUiState, - coordinateTransformer = coordinateTransformer - ) - } else { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center).size(LOADING_SPINNER_SIZE), - color = Color.White - ) + ) + }, + surfaceRequest = surfaceRequest, + implementationMode = implementationMode, + coordinateTransformer = coordinateTransformer + ) + FocusMeteringIndicator( + focusMeteringUiState = focusMeteringUiState, + coordinateTransformer = coordinateTransformer + ) + } else { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(LOADING_SPINNER_SIZE), + color = Color.White + ) + } } } } } +@Composable +private fun PreviewDisplaySample( + aspectRatio: AspectRatio = AspectRatio.THREE_FOUR, + contentAlignment: Alignment = Alignment.TopCenter, + useLandscapeAspectRatio: Boolean = false +) { + MaterialTheme(colorScheme = darkColorScheme()) { + PreviewDisplay( + previewDisplayUiState = PreviewDisplayUiState( + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(aspectRatio) + ) + ) + ), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified, + contentAlignment = contentAlignment, + useLandscapeAspectRatio = useLandscapeAspectRatio + ) + } +} + +@Preview(name = "Tablet 1280x800 Centered 4:3 Pillarbox", widthDp = 1280, heightDp = 800) +@Composable +private fun PreviewDisplayTabletPillarboxPreview() { + PreviewDisplaySample( + aspectRatio = AspectRatio.THREE_FOUR, + contentAlignment = Alignment.Center, + useLandscapeAspectRatio = true + ) +} + +@Preview(name = "Phone 412x915 Top-Aligned 3:4", widthDp = 412, heightDp = 915) +@Composable +private fun PreviewDisplayPhoneTopAlignedPreview() { + PreviewDisplaySample( + aspectRatio = AspectRatio.THREE_FOUR, + contentAlignment = Alignment.TopCenter, + useLandscapeAspectRatio = false + ) +} + +@Preview(name = "PreviewDisplay RTL", locale = "ar", widthDp = 412, heightDp = 915) +@Composable +private fun PreviewDisplayRtlPreview() { + PreviewDisplaySample( + aspectRatio = AspectRatio.THREE_FOUR, + contentAlignment = Alignment.TopCenter, + useLandscapeAspectRatio = false + ) +} + /** * A wrapper composable for the primary capture button. * 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 new file mode 100644 index 0000000000..28183cd0b0 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt @@ -0,0 +1,498 @@ +/* + * 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.ui.components.capture + +import android.annotation.SuppressLint +import android.graphics.Matrix +import android.graphics.Rect as AndroidRect +import android.util.Size +import android.view.Surface +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.impl.CameraInternal +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.click +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +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.jetpackcamera.model.AspectRatio +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 java.lang.reflect.Proxy +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit and Robolectric Compose UI tests for [calculateViewfinderSize] and [PreviewDisplay]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1400dp-h1400dp") +class PreviewDisplayTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val createdSurfaceRequests = mutableListOf() + + @SuppressLint("RestrictedApi") + @After + fun tearDown() { + createdSurfaceRequests.forEach { request -> + request.willNotProvideSurface() + request.deferrableSurface.close() + } + createdSurfaceRequests.clear() + } + + @Test + fun calculateViewfinderSize_phonePortrait_usesPortraitAspectRatios() { + val threeFour = calculateViewfinderSize( + maxWidth = 412.dp, + maxHeight = 915.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = false + ) + assertThat(threeFour.width.value).isWithin(0.01f).of(412f) + assertThat(threeFour.height.value).isWithin(0.01f).of(412f * 4f / 3f) + + val nineSixteen = calculateViewfinderSize( + maxWidth = 412.dp, + maxHeight = 915.dp, + aspectRatio = AspectRatio.NINE_SIXTEEN, + useLandscapeAspectRatio = false + ) + assertThat(nineSixteen.width.value).isWithin(0.01f).of(412f) + assertThat(nineSixteen.height.value).isWithin(0.01f).of(412f * 16f / 9f) + + val oneOne = calculateViewfinderSize( + maxWidth = 412.dp, + maxHeight = 915.dp, + aspectRatio = AspectRatio.ONE_ONE, + useLandscapeAspectRatio = false + ) + assertThat(oneOne).isEqualTo(DpSize(412.dp, 412.dp)) + } + + @Test + fun calculateViewfinderSize_phoneRotated_withoutLandscapeFlag_keepsPortraitRatio() { + val rotatedThreeFour = calculateViewfinderSize( + maxWidth = 915.dp, + maxHeight = 412.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = false + ) + assertThat(rotatedThreeFour.width.value).isWithin(0.01f).of(309f) + assertThat(rotatedThreeFour.height.value).isWithin(0.01f).of(412f) + } + + @Test + fun calculateViewfinderSize_tabletHorizontal_withLandscapeFlag_usesLandscapeRatios() { + val fourThree = calculateViewfinderSize( + maxWidth = 1280.dp, + maxHeight = 800.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = true + ) + assertThat(fourThree.width.value).isWithin(0.01f).of(800f * 4f / 3f) + assertThat(fourThree.height.value).isWithin(0.01f).of(800f) + + val sixteenNine = calculateViewfinderSize( + maxWidth = 1280.dp, + maxHeight = 800.dp, + aspectRatio = AspectRatio.NINE_SIXTEEN, + useLandscapeAspectRatio = true + ) + assertThat(sixteenNine.width.value).isWithin(0.01f).of(1280f) + assertThat(sixteenNine.height.value).isWithin(0.01f).of(720f) + + val oneOne = calculateViewfinderSize( + maxWidth = 1280.dp, + maxHeight = 800.dp, + aspectRatio = AspectRatio.ONE_ONE, + useLandscapeAspectRatio = true + ) + assertThat(oneOne).isEqualTo(DpSize(800.dp, 800.dp)) + } + + @Test + fun calculateViewfinderSize_tabletVertical_withLandscapeFlag_keepsPortraitRatios() { + val threeFour = calculateViewfinderSize( + maxWidth = 800.dp, + maxHeight = 1280.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = true + ) + assertThat(threeFour.width.value).isWithin(0.01f).of(800f) + assertThat(threeFour.height.value).isWithin(0.01f).of(800f * 4f / 3f) + + val nineSixteen = calculateViewfinderSize( + maxWidth = 800.dp, + maxHeight = 1280.dp, + aspectRatio = AspectRatio.NINE_SIXTEEN, + useLandscapeAspectRatio = true + ) + assertThat(nineSixteen.width.value).isWithin(0.01f).of(720f) + assertThat(nineSixteen.height.value).isWithin(0.01f).of(1280f) + } + + @Test + fun calculateViewfinderSize_foldable_handlesNearSquareBounds() { + val portraitFoldableThreeFour = calculateViewfinderSize( + maxWidth = 840.dp, + maxHeight = 900.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = true + ) + assertThat(portraitFoldableThreeFour.width.value).isWithin(0.01f).of(675f) + assertThat(portraitFoldableThreeFour.height.value).isWithin(0.01f).of(900f) + + val portraitFoldableOneOne = calculateViewfinderSize( + maxWidth = 840.dp, + maxHeight = 900.dp, + aspectRatio = AspectRatio.ONE_ONE, + useLandscapeAspectRatio = true + ) + assertThat(portraitFoldableOneOne).isEqualTo(DpSize(840.dp, 840.dp)) + + val landscapeFoldableFourThree = calculateViewfinderSize( + maxWidth = 900.dp, + maxHeight = 840.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = true + ) + assertThat(landscapeFoldableFourThree.width.value).isWithin(0.01f).of(900f) + assertThat(landscapeFoldableFourThree.height.value).isWithin(0.01f).of(675f) + } + + @Test + fun calculateViewfinderSize_zeroOrNegativeBounds_returnsZero() { + assertThat( + calculateViewfinderSize( + maxWidth = 0.dp, + maxHeight = 800.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = true + ) + ).isEqualTo(DpSize.Zero) + + assertThat( + calculateViewfinderSize( + maxWidth = 412.dp, + maxHeight = 0.dp, + aspectRatio = AspectRatio.THREE_FOUR, + useLandscapeAspectRatio = false + ) + ).isEqualTo(DpSize.Zero) + + assertThat( + calculateViewfinderSize( + maxWidth = (-1).dp, + maxHeight = 800.dp, + aspectRatio = AspectRatio.ONE_ONE, + useLandscapeAspectRatio = false + ) + ).isEqualTo(DpSize.Zero) + } + + @Test + fun previewDisplay_topCenteredDefault_positionsViewfinderAndUpdatesOverlapTargetBounds() { + val targetBoundsState = mutableStateOf(Rect.Zero) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalOverlapTargetBounds provides targetBoundsState, + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 300.dp, height = 500.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + val bounds = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot() + assertThat(bounds.left).isEqualTo(0.dp) + assertThat(bounds.top).isEqualTo(0.dp) + assertThat(bounds.width).isEqualTo(300.dp) + assertThat(bounds.height).isEqualTo(400.dp) + + assertThat(targetBoundsState.value).isEqualTo(Rect(0f, 0f, 300f, 400f)) + } + + @Test + fun previewDisplay_centeredLandscapeFraming_updatesBoundsAndOverlapTargetOnRatioChange() { + val targetBoundsState = mutableStateOf(Rect.Zero) + var selectedRatio by mutableStateOf(AspectRatio.THREE_FOUR) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalOverlapTargetBounds provides targetBoundsState, + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 1280.dp, height = 800.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(selectedRatio), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified, + contentAlignment = Alignment.Center, + useLandscapeAspectRatio = true + ) + } + } + } + + composeTestRule.waitForIdle() + val pillarboxBounds = + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot() + assertThat(pillarboxBounds.left.value).isWithin(1f).of(106.5f) + assertThat(pillarboxBounds.top).isEqualTo(0.dp) + assertThat(pillarboxBounds.width.value).isWithin(1f).of(1067f) + assertThat(pillarboxBounds.height).isEqualTo(800.dp) + assertThat(targetBoundsState.value.left).isWithin(1f).of(106.5f) + assertThat(targetBoundsState.value.top).isEqualTo(0f) + assertThat(targetBoundsState.value.width).isWithin(1f).of(1067f) + assertThat(targetBoundsState.value.height).isEqualTo(800f) + + selectedRatio = AspectRatio.NINE_SIXTEEN + composeTestRule.waitForIdle() + + val letterboxBounds = + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot() + assertThat(letterboxBounds.left).isEqualTo(0.dp) + assertThat(letterboxBounds.top).isEqualTo(40.dp) + assertThat(letterboxBounds.width).isEqualTo(1280.dp) + assertThat(letterboxBounds.height).isEqualTo(720.dp) + assertThat(targetBoundsState.value).isEqualTo(Rect(0f, 40f, 1280f, 760f)) + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_ltrAndRtl_produceIdenticalTapToFocusCoordinatesAndIndicatorBounds() { + val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800) + var layoutDirection by mutableStateOf(LayoutDirection.Ltr) + var focusMeteringUiState by + mutableStateOf(FocusMeteringUiState.Unspecified) + val recordedTaps = mutableListOf() + var flipCount = 0 + + composeTestRule.setContent { + CompositionLocalProvider( + LocalLayoutDirection provides layoutDirection, + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) }, + onFlipCamera = { flipCount++ }, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = focusMeteringUiState + ) + } + } + } + + composeTestRule.waitForIdle() + + // Tap at an asymmetric physical point (left quarter of the 300x400 viewfinder) in LTR. + val physicalTapOffset = Offset(75f, 120f) + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput { + click(physicalTapOffset) + } + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.waitForIdle() + + assertThat(recordedTaps).hasSize(1) + val ltrTap = recordedTaps.single() + assertThat(ltrTap.x).isLessThan(300f) + + focusMeteringUiState = FocusMeteringUiState.Specified( + surfaceCoordinates = ltrTap, + status = FocusMeteringUiState.Status.RUNNING + ) + composeTestRule.waitForIdle() + + val ltrIndicatorBounds = + composeTestRule.onNodeWithTag(FOCUS_METERING_INDICATOR_TAG).getUnclippedBoundsInRoot() + assertThat(ltrIndicatorBounds.left.value).isLessThan(150f) + + // Switch ambient layout direction to RTL and repeat the identical physical tap. + recordedTaps.clear() + focusMeteringUiState = FocusMeteringUiState.Unspecified + layoutDirection = LayoutDirection.Rtl + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput { + click(physicalTapOffset) + } + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.waitForIdle() + + assertThat(recordedTaps).hasSize(1) + val rtlTap = recordedTaps.single() + assertThat(rtlTap.x).isWithin(0.01f).of(ltrTap.x) + assertThat(rtlTap.y).isWithin(0.01f).of(ltrTap.y) + + focusMeteringUiState = FocusMeteringUiState.Specified( + surfaceCoordinates = rtlTap, + status = FocusMeteringUiState.Status.RUNNING + ) + composeTestRule.waitForIdle() + + val rtlIndicatorBounds = + composeTestRule.onNodeWithTag(FOCUS_METERING_INDICATOR_TAG).getUnclippedBoundsInRoot() + assertThat(rtlIndicatorBounds).isEqualTo(ltrIndicatorBounds) + + // Verify double-tap flip callback still dispatches through rememberUpdatedState. + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput { + doubleClick(physicalTapOffset) + } + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.waitForIdle() + assertThat(flipCount).isEqualTo(1) + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_updatedCallbacksAndPinchZoom_dispatchLatestHandlers() { + val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800) + val zoomFactors = mutableListOf() + var activeGeneration by mutableStateOf(1) + val flippedGenerations = mutableListOf() + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + val capturedGeneration = activeGeneration + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { _, _ -> }, + onFlipCamera = { flippedGenerations.add(capturedGeneration) }, + onScaleZoom = { zoom -> zoomFactors.add(zoom) }, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + activeGeneration = 2 + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput { + doubleClick(Offset(150f, 200f)) + } + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.waitForIdle() + assertThat(flippedGenerations).containsExactly(2) + + composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput { + pinch( + start0 = Offset(100f, 200f), + end0 = Offset(50f, 200f), + start1 = Offset(200f, 200f), + end1 = Offset(250f, 200f) + ) + } + composeTestRule.waitForIdle() + assertThat(zoomFactors).isNotEmpty() + assertThat(zoomFactors.any { it > 1f }).isTrue() + } + + private fun createUiState(aspectRatio: AspectRatio): PreviewDisplayUiState = + PreviewDisplayUiState( + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE) + ) + ) + ) + + @SuppressLint("RestrictedApi") + private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest { + val fakeCamera = Proxy.newProxyInstance( + CameraInternal::class.java.classLoader, + arrayOf(CameraInternal::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString" -> "FakeCameraInternal" + "getHasTransform" -> true + "isFrontFacing" -> false + "isRemoved" -> false + else -> null + } + } as CameraInternal + + return SurfaceRequest(Size(width, height), fakeCamera) {}.also { request -> + request.updateTransformationInfo( + SurfaceRequest.TransformationInfo.of( + AndroidRect(0, 0, width, height), + 0, + Surface.ROTATION_0, + true, + Matrix(), + false + ) + ) + createdSurfaceRequests.add(request) + } + } +} From fe40e9dd7cec6f9edbc0df8a52bfa390123936e0 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 3 Nov 2026 14:27:02 -0800 Subject: [PATCH 03/17] Improve PreviewDisplay accessibility semantics, custom actions, and keyboard focus --- .../capture/CaptureScreenComponentsTest.kt | 190 ++++++++++++ .../capture/CaptureScreenComponents.kt | 140 +++++++-- .../capture/src/main/res/values/strings.xml | 4 + .../components/capture/PreviewDisplayTest.kt | 284 +++++++++++++++++- 4 files changed, 588 insertions(+), 30 deletions(-) diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt index 0e68aa73a3..014ba1629d 100644 --- a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt @@ -15,21 +15,34 @@ */ package com.google.jetpackcamera.ui.components.capture +import android.annotation.SuppressLint import android.content.Context +import android.graphics.Matrix +import android.graphics.Rect as AndroidRect +import android.util.Size +import android.view.Surface +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.impl.CameraInternal import androidx.camera.viewfinder.compose.MutableCoordinateTransformer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertRangeInfoEquals import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks import androidx.compose.ui.test.junit4.createComposeRule @@ -38,15 +51,24 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.dp import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.ui.uistate.CustomSnackbarVisuals +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState +import java.lang.reflect.Proxy +import kotlin.math.roundToInt +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -59,6 +81,8 @@ class CaptureScreenComponentsTest { @get:Rule val composeTestRule = createComposeRule() + private val createdSurfaceRequests = mutableListOf() + @Before fun setUp() { composeTestRule.enableAccessibilityChecks( @@ -68,6 +92,16 @@ class CaptureScreenComponentsTest { ) } + @SuppressLint("RestrictedApi") + @After + fun tearDown() { + createdSurfaceRequests.forEach { request -> + request.willNotProvideSurface() + request.deferrableSurface.close() + } + createdSurfaceRequests.clear() + } + @Test fun elapsedTimeText_unavailableState_doesNotRender() { composeTestRule.setContent { @@ -341,4 +375,160 @@ class CaptureScreenComponentsTest { useUnmergedTree = true ).assertCountEquals(0) } + + @Test + fun previewDisplay_loadingStateDarkTheme_passesAccessibilityChecks() { + val loadingDescription = + ApplicationProvider.getApplicationContext().getString( + R.string.camera_not_ready + ) + + composeTestRule.setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createPreviewDisplayUiState(), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + } + + composeTestRule.onNodeWithContentDescription(loadingDescription) + .assertIsDisplayed() + .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate) + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun previewDisplay_loadingStateLightTheme_passesAccessibilityChecks() { + val loadingDescription = + ApplicationProvider.getApplicationContext().getString( + R.string.camera_not_ready + ) + + composeTestRule.setContent { + MaterialTheme(colorScheme = lightColorScheme()) { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createPreviewDisplayUiState(), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + } + + composeTestRule.onNodeWithContentDescription(loadingDescription) + .assertIsDisplayed() + .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate) + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_withSurfaceRequest_passesAccessibilityChecksAndFocusesCenter() { + val context = ApplicationProvider.getApplicationContext() + val viewfinderDescription = + context.getString(R.string.preview_viewfinder_content_description) + val density = context.resources.displayMetrics.density + val widthPx = (300 * density).roundToInt() + val heightPx = (400 * density).roundToInt() + val surfaceRequest = createFakeSurfaceRequest(width = widthPx, height = heightPx) + val recordedTaps = mutableListOf() + + composeTestRule.setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createPreviewDisplayUiState(), + onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + } + + composeTestRule.waitForIdle() + val viewfinderNode = composeTestRule.onNodeWithContentDescription(viewfinderDescription) + viewfinderNode.assertIsDisplayed() + composeTestRule.onRoot().tryPerformAccessibilityChecks() + + viewfinderNode.performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + assertThat(recordedTaps).hasSize(1) + assertThat(recordedTaps.single().x).isWithin(1f).of(widthPx / 2f) + assertThat(recordedTaps.single().y).isWithin(1f).of(heightPx / 2f) + } + + private fun createPreviewDisplayUiState( + aspectRatio: AspectRatio = AspectRatio.THREE_FOUR + ): PreviewDisplayUiState = PreviewDisplayUiState( + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE) + ) + ) + ) + + @SuppressLint("RestrictedApi") + private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest { + val fakeCamera = Proxy.newProxyInstance( + CameraInternal::class.java.classLoader, + arrayOf(CameraInternal::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString" -> "FakeCameraInternal" + "getHasTransform" -> true + "isFrontFacing" -> false + "isRemoved" -> false + else -> when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + Float::class.javaPrimitiveType -> 0f + Double::class.javaPrimitiveType -> 0.0 + else -> null + } + } + } as CameraInternal + + return SurfaceRequest(Size(width, height), fakeCamera) {}.also { request -> + request.updateTransformationInfo( + SurfaceRequest.TransformationInfo.of( + AndroidRect(0, 0, width, height), + 0, + Surface.ROTATION_0, + true, + Matrix(), + false + ) + ) + createdSurfaceRequests.add(request) + } + } } 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 13329b86c4..2464fd0dc3 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 @@ -52,6 +52,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable @@ -102,21 +103,34 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight @@ -127,6 +141,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round @@ -163,7 +178,13 @@ import kotlinx.coroutines.flow.onCompletion private const val TAG = "CaptureScreenComponents" private val LOADING_SPINNER_SIZE = 40.dp +private const val LOADING_SPINNER_STATIC_PROGRESS = 0.75f private val ViewfinderCornerRadius = 16.dp +private val ViewfinderMinTouchTargetSize = 48.dp + +@VisibleForTesting +internal val PreviewBlinkAlphaProviderKey = + SemanticsPropertyKey<() -> Float>("PreviewBlinkAlphaProvider") private val SnackbarMinHeight = 48.dp private val SnackbarHorizontalMargin = 16.dp @@ -821,6 +842,9 @@ internal fun calculateViewfinderSize( * - **Double Tap:** Flips the camera between front and back lenses. * - **Pinch Gesture:** Scales the camera's zoom level. * + * Assistive technologies and hardware keyboards can also focus the center of the viewfinder via + * its click semantics action or activation keys (`Enter`, `Space`, `DirectionCenter`). + * * @param previewDisplayUiState the [PreviewDisplayUiState] for this component. * @param onTapToFocus the callback for tapping to focus. * @param onFlipCamera the callback for flipping the camera. @@ -864,8 +888,9 @@ fun PreviewDisplay( val targetBoundsState = LocalOverlapTargetBounds.current val disableAnimations = LocalDisableAnimations.current + val currentDisableAnimations by rememberUpdatedState(disableAnimations) val imageAlphaState = animateFloatAsState( - targetValue = if (imageVisible) 1f else 0f, + targetValue = if (imageVisible || disableAnimations) 1f else 0f, animationSpec = if (disableAnimations) { snap() } else { @@ -876,13 +901,23 @@ fun PreviewDisplay( }, label = "PreviewBlinkAlpha" ) + val blinkAlphaProvider = remember { { imageAlphaState.value } } val viewfinderShape = remember { RoundedCornerShape(ViewfinderCornerRadius) } + LaunchedEffect(disableAnimations) { + if (disableAnimations) { + imageVisible = true + } + } + LaunchedEffect(previewDisplayUiState.lastBlinkTimeStamp) { - if (previewDisplayUiState.lastBlinkTimeStamp != 0L) { + if (previewDisplayUiState.lastBlinkTimeStamp != 0L && !currentDisableAnimations) { imageVisible = false - delay(BLINK_TIME) - imageVisible = true + try { + delay(BLINK_TIME) + } finally { + imageVisible = true + } } } @@ -896,6 +931,9 @@ fun PreviewDisplay( Box( modifier = Modifier .testTag(PREVIEW_DISPLAY) + .semantics { + this[PreviewBlinkAlphaProviderKey] = blinkAlphaProvider + } .onGloballyPositioned { coordinates -> val bounds = coordinates.boundsInWindow() if (targetBoundsState.value != bounds) { @@ -936,12 +974,65 @@ fun PreviewDisplay( ) val coordinateTransformer = remember { MutableCoordinateTransformer() } + var viewfinderSize by remember { mutableStateOf(IntSize.Zero) } + val minTouchTargetPx = with(LocalDensity.current) { + ViewfinderMinTouchTargetSize.roundToPx() + } + val hasInteractiveBounds = viewfinderSize.width >= minTouchTargetPx && + viewfinderSize.height >= minTouchTargetPx val viewfinderDescription = - stringResource(R.string.camera_viewfinder_content_description) + stringResource(R.string.preview_viewfinder_content_description) + val focusActionLabel = + stringResource(R.string.preview_tap_to_focus_action_label) + val focusAtViewfinderOffset: (Offset) -> Unit = { offset -> + with(coordinateTransformer) { + val surfaceCoords = offset.transform() + Log.d( + TAG, + "onTapToFocus: input{$offset} -> surface{$surfaceCoords}" + ) + currentOnTapToFocus(surfaceCoords.x, surfaceCoords.y) + } + } + val triggerCenterFocus: () -> Boolean = { + if (viewfinderSize.width > 0 && viewfinderSize.height > 0) { + focusAtViewfinderOffset( + Offset( + viewfinderSize.width / 2f, + viewfinderSize.height / 2f + ) + ) + true + } else { + false + } + } CameraXViewfinder( modifier = Modifier .fillMaxSize() - .semantics { contentDescription = viewfinderDescription } + .onSizeChanged { viewfinderSize = it } + .semantics { + contentDescription = viewfinderDescription + if (hasInteractiveBounds) { + onClick(label = focusActionLabel) { triggerCenterFocus() } + } + } + .onKeyEvent { event -> + val isActivationKey = event.key == Key.Enter || + event.key == Key.NumPadEnter || + event.key == Key.DirectionCenter || + event.key == Key.Spacebar + if (hasInteractiveBounds && isActivationKey) { + when (event.type) { + KeyEventType.KeyUp -> triggerCenterFocus() + KeyEventType.KeyDown -> true + else -> false + } + } else { + false + } + } + .focusable(enabled = hasInteractiveBounds) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { offset -> @@ -949,16 +1040,8 @@ fun PreviewDisplay( Log.d(TAG, "onDoubleTap $offset") currentOnFlipCamera() }, - onTap = { - with(coordinateTransformer) { - val surfaceCoords = it.transform() - Log.d( - TAG, - "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" - ) - currentOnTapToFocus(surfaceCoords.x, surfaceCoords.y) - } + onTap = { offset -> + focusAtViewfinderOffset(offset) } ) }, @@ -971,10 +1054,27 @@ fun PreviewDisplay( coordinateTransformer = coordinateTransformer ) } else { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center).size(LOADING_SPINNER_SIZE), - color = Color.White - ) + val loadingDescription = stringResource(R.string.camera_not_ready) + val indicatorModifier = Modifier + .align(Alignment.Center) + .size(LOADING_SPINNER_SIZE) + .clearAndSetSemantics { + contentDescription = loadingDescription + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } + if (disableAnimations) { + CircularProgressIndicator( + progress = { LOADING_SPINNER_STATIC_PROGRESS }, + modifier = indicatorModifier, + color = Color.White, + trackColor = Color.Transparent + ) + } else { + CircularProgressIndicator( + modifier = indicatorModifier, + color = Color.White + ) + } } } } diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index 4106fac29a..7266c1c910 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -137,6 +137,10 @@ Camera viewfinder + + Camera viewfinder + + Focus center Focusing 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 28183cd0b0..7e8d653f1e 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 @@ -16,9 +16,11 @@ package com.google.jetpackcamera.ui.components.capture import android.annotation.SuppressLint +import android.content.Context 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.SurfaceRequest import androidx.camera.core.impl.CameraInternal @@ -26,18 +28,36 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertRangeInfoEquals import androidx.compose.ui.test.click import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.isFocusable +import androidx.compose.ui.test.isNotFocusable import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyPress +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.pinch import androidx.compose.ui.unit.DpSize @@ -45,6 +65,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.height 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.ui.uistate.SingleSelectableUiState @@ -452,17 +473,253 @@ class PreviewDisplayTest { assertThat(zoomFactors.any { it > 1f }).isTrue() } - private fun createUiState(aspectRatio: AspectRatio): PreviewDisplayUiState = - PreviewDisplayUiState( - aspectRatioUiState = AspectRatioUiState.Available( - selectedAspectRatio = aspectRatio, - availableAspectRatios = listOf( - SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), - SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN), - SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE) - ) + @Test + fun previewDisplay_loadingState_withAnimationsDisabled_settlesAndExposesLoadingSemantics() { + val context = ApplicationProvider.getApplicationContext() + val loadingDescription = context.getString(R.string.camera_not_ready) + assertThat(loadingDescription).isEqualTo("Camera Loading…") + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = null, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription(loadingDescription) + .assertIsDisplayed() + .assertContentDescriptionEquals("Camera Loading…") + .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate) + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_viewfinder_exposesCenterFocusSemanticsAndKeyboardActivation() { + val context = ApplicationProvider.getApplicationContext() + val viewfinderDescription = + context.getString(R.string.preview_viewfinder_content_description) + val focusActionLabel = context.getString(R.string.preview_tap_to_focus_action_label) + assertThat(viewfinderDescription).isEqualTo("Camera viewfinder") + assertThat(focusActionLabel).isEqualTo("Focus center") + + val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400) + val recordedTaps = mutableListOf() + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR), + onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + + val viewfinderNode = composeTestRule.onNodeWithContentDescription("Camera viewfinder") + viewfinderNode + .assertIsDisplayed() + .assertContentDescriptionEquals("Camera viewfinder") + .assert(isFocusable()) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role)) + + val onClickAction = + viewfinderNode.fetchSemanticsNode().config.getOrNull(SemanticsActions.OnClick) + assertThat(onClickAction).isNotNull() + assertThat(onClickAction?.label).isEqualTo("Focus center") + + viewfinderNode.performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + assertThat(recordedTaps).hasSize(1) + assertThat(recordedTaps.single().x).isWithin(0.5f).of(150f) + assertThat(recordedTaps.single().y).isWithin(0.5f).of(200f) + + viewfinderNode.performSemanticsAction(SemanticsActions.RequestFocus) + viewfinderNode.assertIsFocused() + + val activationKeyCodes = listOf( + AndroidKeyEvent.KEYCODE_ENTER, + AndroidKeyEvent.KEYCODE_NUMPAD_ENTER, + AndroidKeyEvent.KEYCODE_DPAD_CENTER, + AndroidKeyEvent.KEYCODE_SPACE + ) + activationKeyCodes.forEach { keyCode -> + recordedTaps.clear() + val downConsumed = viewfinderNode.performKeyPress( + KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_DOWN, keyCode)) + ) + assertThat(downConsumed).isTrue() + assertThat(recordedTaps).isEmpty() + + val upConsumed = viewfinderNode.performKeyPress( + KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_UP, keyCode)) + ) + assertThat(upConsumed).isTrue() + assertThat(recordedTaps).hasSize(1) + assertThat(recordedTaps.single().x).isWithin(0.5f).of(150f) + assertThat(recordedTaps.single().y).isWithin(0.5f).of(200f) + } + + recordedTaps.clear() + val nonActivationConsumed = viewfinderNode.performKeyPress( + KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_A)) + ) + assertThat(nonActivationConsumed).isFalse() + assertThat(recordedTaps).isEmpty() + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_viewfinderBelowMinimumTouchTarget_disablesFocusAndClickSemantics() { + val surfaceRequest = createFakeSurfaceRequest(width = 32, height = 32) + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 32.dp, height = 32.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState(AspectRatio.ONE_ONE), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Camera viewfinder") + .assertIsDisplayed() + .assert(isNotFocusable()) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick)) + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_captureBlink_skipsZeroAlphaStrobeWhenAnimationsDisabled() { + val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400) + var lastBlinkTimeStamp by mutableLongStateOf(0L) + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState( + aspectRatio = AspectRatio.THREE_FOUR, + lastBlinkTimeStamp = lastBlinkTimeStamp + ), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + val alphaProvider = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY) + .fetchSemanticsNode() + .config[PreviewBlinkAlphaProviderKey] + assertThat(alphaProvider()).isEqualTo(1f) + + composeTestRule.mainClock.autoAdvance = false + try { + lastBlinkTimeStamp = 1_000L + val observedAlphas = mutableListOf() + repeat(15) { + composeTestRule.mainClock.advanceTimeBy(16L) + observedAlphas.add(alphaProvider()) + } + assertThat(observedAlphas).isNotEmpty() + assertThat(observedAlphas.all { it == 1f }).isTrue() + } finally { + composeTestRule.mainClock.autoAdvance = true + } + } + + @SuppressLint("RestrictedApi") + @Test + fun previewDisplay_captureBlink_animatesToZeroAndRecoversWhenAnimationsEnabled() { + val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400) + var lastBlinkTimeStamp by mutableLongStateOf(0L) + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides false) { + Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) { + PreviewDisplay( + previewDisplayUiState = createUiState( + aspectRatio = AspectRatio.THREE_FOUR, + lastBlinkTimeStamp = lastBlinkTimeStamp + ), + onTapToFocus = { _, _ -> }, + onFlipCamera = {}, + onScaleZoom = {}, + onRequestWindowColorMode = {}, + surfaceRequest = surfaceRequest, + focusMeteringUiState = FocusMeteringUiState.Unspecified + ) + } + } + } + + composeTestRule.waitForIdle() + val alphaProvider = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY) + .fetchSemanticsNode() + .config[PreviewBlinkAlphaProviderKey] + assertThat(alphaProvider()).isEqualTo(1f) + + composeTestRule.mainClock.autoAdvance = false + try { + lastBlinkTimeStamp = 1_000L + val observedAlphas = mutableListOf() + repeat(20) { + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(16L) + observedAlphas.add(alphaProvider()) + } + assertThat(observedAlphas).contains(0f) + assertThat(observedAlphas.last()).isEqualTo(1f) + } finally { + composeTestRule.mainClock.autoAdvance = true + } + } + + private fun createUiState( + aspectRatio: AspectRatio, + lastBlinkTimeStamp: Long = 0L + ): PreviewDisplayUiState = PreviewDisplayUiState( + lastBlinkTimeStamp = lastBlinkTimeStamp, + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE) ) ) + ) @SuppressLint("RestrictedApi") private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest { @@ -477,7 +734,14 @@ class PreviewDisplayTest { "getHasTransform" -> true "isFrontFacing" -> false "isRemoved" -> false - else -> null + else -> when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + Float::class.javaPrimitiveType -> 0f + Double::class.javaPrimitiveType -> 0.0 + else -> null + } } } as CameraInternal From 56cf9da27c044ad83eb0c1a05563b05bcb4b9149 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 3 Nov 2026 16:20:13 -0800 Subject: [PATCH 04/17] Remove placeholder layout mode name and tag each debug layout readout field --- .../ui/debug/DebugOverlayComponents.kt | 71 +++++++++++-------- .../google/jetpackcamera/ui/debug/TestTags.kt | 5 +- ui/debug/src/main/res/values/strings.xml | 5 +- .../ui/debug/DebugLayoutReadoutTest.kt | 45 ++++++------ 4 files changed, 72 insertions(+), 54 deletions(-) diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index f0b060e44e..85c3ffbb5b 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -112,35 +112,32 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float } /** - * Snapshot of window geometry, display rotation, rotation-lock state, and adaptive layout mode - * displayed by the debug console's layout readout row. + * Snapshot of window geometry, display rotation, and rotation-lock state displayed by the debug + * console's layout readout row. * * When passed to [DebugOverlay], these explicit values override the live window-container fallback - * used when adaptive layout posture state is omitted. + * used when layout readout state is omitted. * * @property widthDp Current window container width in density-independent pixels. * @property heightDp Current window container height in density-independent pixels. * @property rotationDegrees Display rotation in clockwise degrees (typically 0, 90, 180, or 270). - * @property isRotationLocked Whether system auto-rotate is locked (`LOCKED`) or enabled + * @property isRotationLocked Whether orientation rotation is locked (`LOCKED`) or unlocked * (`UNLOCKED`). - * @property layoutModeName Active adaptive layout mode label, or `null` to display the default - * compact portrait mode label. */ @Immutable data class DebugLayoutReadoutState( val widthDp: Int, val heightDp: Int, val rotationDegrees: Int = 0, - val isRotationLocked: Boolean = false, - val layoutModeName: String? = null + val isRotationLocked: Boolean = false ) /** * Displays a compact single-line debug readout showing window container size in dp, display - * rotation degrees, rotation-lock state, and the active adaptive layout mode. + * rotation degrees, and rotation-lock state as individually tagged text blocks. * * When [state] is `null`, window dimensions are derived live from [LocalWindowInfo] and - * [LocalDensity] with `0°`, `UNLOCKED`, and the default `CompactPortrait` mode label. + * [LocalDensity] with `0°` and `UNLOCKED`. * * @param state Explicit layout readout values to display, or `null` to use live window dimensions. * @param modifier Modifier applied to the outer readout row. @@ -157,6 +154,7 @@ internal fun DebugLayoutReadout( heightDp = containerSize.height.toDp().value.roundToInt() ) } + val separator = stringResource(R.string.debug_text_layout_separator) val lockText = stringResource( if (resolvedState.isRotationLocked) { R.string.debug_text_rotation_locked @@ -164,22 +162,34 @@ internal fun DebugLayoutReadout( R.string.debug_text_rotation_unlocked } ) - val modeText = resolvedState.layoutModeName - ?: stringResource(R.string.debug_text_default_layout_mode) - val formattedValue = stringResource( - R.string.debug_text_layout_value, - resolvedState.widthDp, - resolvedState.heightDp, - resolvedState.rotationDegrees, - lockText, - modeText - ) - DebugTextBar( - modifier = modifier, - title = stringResource(R.string.debug_text_layout_prefix), - value = formattedValue, - tag = DEBUG_LAYOUT_READOUT_TAG - ) + val valueBackgroundModifier = Modifier.background(Color.Black.copy(alpha = .4f)) + Row(modifier = modifier.testTag(DEBUG_LAYOUT_READOUT_TAG)) { + Text( + modifier = Modifier.background(Color.Black.copy(alpha = .7f)), + text = stringResource(R.string.debug_text_layout_prefix) + ) + Text( + modifier = valueBackgroundModifier.testTag(DEBUG_WINDOW_SIZE_TAG), + text = stringResource( + R.string.debug_text_window_size, + resolvedState.widthDp, + resolvedState.heightDp + ) + ) + Text(modifier = valueBackgroundModifier, text = separator) + Text( + modifier = valueBackgroundModifier.testTag(DEBUG_ROTATION_DEG_TAG), + text = stringResource( + R.string.debug_text_rotation_degrees, + resolvedState.rotationDegrees + ) + ) + Text(modifier = valueBackgroundModifier, text = separator) + Text( + modifier = valueBackgroundModifier.testTag(DEBUG_ROTATION_LOCK_TAG), + text = lockText + ) + } } @Composable @@ -572,7 +582,7 @@ private enum class SelectedDialog { SetTestPattern } -@Preview(name = "Unlocked CompactPortrait", showBackground = true) +@Preview(name = "Unlocked", showBackground = true) @Composable private fun DebugLayoutReadoutUnlockedPreview() { MaterialTheme(colorScheme = darkColorScheme()) { @@ -585,17 +595,16 @@ private fun DebugLayoutReadoutUnlockedPreview() { } } -@Preview(name = "Locked TwoSideRail", showBackground = true) +@Preview(name = "Locked", showBackground = true) @Composable -private fun DebugLayoutReadoutLockedTwoSideRailPreview() { +private fun DebugLayoutReadoutLockedPreview() { MaterialTheme(colorScheme = darkColorScheme()) { DebugLayoutReadout( state = DebugLayoutReadoutState( widthDp = 840, heightDp = 360, rotationDegrees = 90, - isRotationLocked = true, - layoutModeName = "TwoSideRail" + isRotationLocked = true ) ) } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index 528c319808..4edebfede9 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -21,7 +21,10 @@ const val PHYSICAL_CAMERA_ID_TAG = "PhysicalCameraIdTag" const val DEBUG_OVERLAY_BUTTON = "DebugOverlayButton" const val BTN_DEBUG_HIDE_COMPONENTS_TAG = "btn_debug_hide_components" -const val DEBUG_LAYOUT_READOUT_TAG = "text_debug_layout_readout" +const val DEBUG_LAYOUT_READOUT_TAG = "row_debug_layout_readout" +const val DEBUG_WINDOW_SIZE_TAG = "text_debug_window_size" +const val DEBUG_ROTATION_DEG_TAG = "text_debug_rotation_degrees" +const val DEBUG_ROTATION_LOCK_TAG = "text_debug_rotation_lock" const val DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON = "DebugOverlayShowCameraPropertiesButton" const val DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON = "DebugOverlaySetZoomRatioButton" const val DEBUG_OVERLAY_CAMERA_PROPERTIES_TAG = "DebugOverlayCameraPropertiesTag" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index 7cb29777ea..fd8f55879b 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -24,8 +24,9 @@ Displaying components %1$.2fx "Layout: " - %1$dx%2$ddp | %3$d° | %4$s | %5$s + " | " + %1$dx%2$ddp + %1$d° LOCKED UNLOCKED - CompactPortrait diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 4e77f16fb9..2141f426de 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -43,7 +43,7 @@ class DebugLayoutReadoutTest { val composeTestRule = createComposeRule() @Test - fun debugLayoutReadout_whenLockedTwoSideRail_displaysFormattedState() { + fun debugLayoutReadout_whenLocked_displaysEachTaggedBlock() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -51,8 +51,7 @@ class DebugLayoutReadoutTest { widthDp = 840, heightDp = 360, rotationDegrees = 90, - isRotationLocked = true, - layoutModeName = "TwoSideRail" + isRotationLocked = true ) ) } @@ -60,12 +59,16 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) - .isEqualTo("840x360dp | 90° | LOCKED | TwoSideRail") + composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_ROTATION_DEG_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_ROTATION_LOCK_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("840x360dp") + assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("90°") + assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("LOCKED") } @Test - fun debugLayoutReadout_whenDefaultsUsed_displaysUnlockedCompactPortrait() { + fun debugLayoutReadout_whenDefaultsUsed_displaysUnlockedDefaults() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -79,8 +82,9 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) - .isEqualTo("412x915dp | 0° | UNLOCKED | CompactPortrait") + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp") + assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("UNLOCKED") } @Test @@ -89,17 +93,17 @@ class DebugLayoutReadoutTest { MaterialTheme { DebugLayoutReadout( state = DebugLayoutReadoutState(widthDp = 412, heightDp = 915), - modifier = Modifier.testTag("row_debug_layout") + modifier = Modifier.testTag("custom_row_tag") ) } } assertThat( - composeTestRule.onAllNodesWithTag("row_debug_layout").fetchSemanticsNodes().size + composeTestRule.onAllNodesWithTag("custom_row_tag").fetchSemanticsNodes().size ).isEqualTo(1) assertThat( composeTestRule - .onAllNodesWithTag("row_debug_layout", useUnmergedTree = true) + .onAllNodesWithTag("custom_row_tag", useUnmergedTree = true) .fetchSemanticsNodes() .size ).isEqualTo(1) @@ -124,8 +128,9 @@ class DebugLayoutReadoutTest { } composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) - .isEqualTo("360x800dp | 0° | UNLOCKED | CompactPortrait") + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("360x800dp") + assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("UNLOCKED") } @Test @@ -145,16 +150,16 @@ class DebugLayoutReadoutTest { widthDp = 840, heightDp = 360, rotationDegrees = 270, - isRotationLocked = true, - layoutModeName = "TwoSideRail" + isRotationLocked = true ) ) } } composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_LAYOUT_READOUT_TAG)) - .isEqualTo("840x360dp | 270° | LOCKED | TwoSideRail") + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("840x360dp") + assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("270°") + assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("LOCKED") } @Test @@ -177,9 +182,9 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertDoesNotExist() - assertThat( - composeTestRule.onAllNodesWithTag(DEBUG_LAYOUT_READOUT_TAG).fetchSemanticsNodes() - ).isEmpty() + composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_ROTATION_DEG_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_ROTATION_LOCK_TAG).assertDoesNotExist() } private fun readNodeText(tag: String): String = composeTestRule From 7f089ce60f44319689e146984732e99b3d99b8b2 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 4 Nov 2026 16:40:34 -0800 Subject: [PATCH 05/17] feat(ui/debug): replace unwired rotation readout with DebugFormFactor --- .../ui/debug/DebugOverlayComponents.kt | 94 +++++++++++------- .../google/jetpackcamera/ui/debug/TestTags.kt | 3 +- ui/debug/src/main/res/values/strings.xml | 11 ++- .../ui/debug/DebugLayoutReadoutTest.kt | 98 ++++++++++++++----- 4 files changed, 141 insertions(+), 65 deletions(-) diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index 85c3ffbb5b..6cba56e490 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -112,7 +112,45 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float } /** - * Snapshot of window geometry, display rotation, and rotation-lock state displayed by the debug + * Window form-factor bucket derived from available window container dimensions in `dp`. + */ +enum class DebugFormFactor { + COMPACT_SQUARE, + COMPACT_SLIM, + COMPACT, + FOLDABLE, + TABLET, + UNKNOWN; + + companion object { + private const val COMPACT_MIN_WIDTH_DP = 360 + private const val MEDIUM_MIN_DIM_DP = 600 + private const val TABLET_MAX_DIM_DP = 960 + private const val COMPACT_SQUARE_MAX_RATIO = 1.5f + private const val FOLDABLE_MAX_RATIO = 1.45f + + /** + * Classifies a window container of [widthDp] by [heightDp] into a [DebugFormFactor] + * bucket regardless of portrait or landscape orientation. + */ + fun fromWindowDp(widthDp: Int, heightDp: Int): DebugFormFactor { + if (widthDp <= 0 || heightDp <= 0) return UNKNOWN + val minDim = minOf(widthDp, heightDp) + val maxDim = maxOf(widthDp, heightDp) + val ratio = maxDim.toFloat() / minDim.toFloat() + return when { + maxDim < MEDIUM_MIN_DIM_DP && ratio < COMPACT_SQUARE_MAX_RATIO -> COMPACT_SQUARE + minDim < COMPACT_MIN_WIDTH_DP -> COMPACT_SLIM + minDim < MEDIUM_MIN_DIM_DP -> COMPACT + maxDim < TABLET_MAX_DIM_DP && ratio < FOLDABLE_MAX_RATIO -> FOLDABLE + else -> TABLET + } + } + } +} + +/** + * Snapshot of window container dimensions and derived form factor displayed by the debug * console's layout readout row. * * When passed to [DebugOverlay], these explicit values override the live window-container fallback @@ -120,24 +158,21 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float * * @property widthDp Current window container width in density-independent pixels. * @property heightDp Current window container height in density-independent pixels. - * @property rotationDegrees Display rotation in clockwise degrees (typically 0, 90, 180, or 270). - * @property isRotationLocked Whether orientation rotation is locked (`LOCKED`) or unlocked - * (`UNLOCKED`). + * @property formFactor Calculated [DebugFormFactor] for the window container dimensions. */ @Immutable data class DebugLayoutReadoutState( val widthDp: Int, val heightDp: Int, - val rotationDegrees: Int = 0, - val isRotationLocked: Boolean = false + val formFactor: DebugFormFactor = DebugFormFactor.fromWindowDp(widthDp, heightDp) ) /** - * Displays a compact single-line debug readout showing window container size in dp, display - * rotation degrees, and rotation-lock state as individually tagged text blocks. + * Displays a compact single-line debug readout showing window container size in dp and the + * calculated [DebugFormFactor] as individually tagged text blocks. * - * When [state] is `null`, window dimensions are derived live from [LocalWindowInfo] and - * [LocalDensity] with `0°` and `UNLOCKED`. + * When [state] is `null`, window dimensions and form factor are derived live from + * [LocalWindowInfo] and [LocalDensity]. * * @param state Explicit layout readout values to display, or `null` to use live window dimensions. * @param modifier Modifier applied to the outer readout row. @@ -155,11 +190,14 @@ internal fun DebugLayoutReadout( ) } val separator = stringResource(R.string.debug_text_layout_separator) - val lockText = stringResource( - if (resolvedState.isRotationLocked) { - R.string.debug_text_rotation_locked - } else { - R.string.debug_text_rotation_unlocked + val formFactorText = stringResource( + when (resolvedState.formFactor) { + DebugFormFactor.COMPACT_SQUARE -> R.string.debug_text_form_factor_compact_square + DebugFormFactor.COMPACT_SLIM -> R.string.debug_text_form_factor_compact_slim + DebugFormFactor.COMPACT -> R.string.debug_text_form_factor_compact + DebugFormFactor.FOLDABLE -> R.string.debug_text_form_factor_foldable + DebugFormFactor.TABLET -> R.string.debug_text_form_factor_tablet + DebugFormFactor.UNKNOWN -> R.string.debug_text_form_factor_unknown } ) val valueBackgroundModifier = Modifier.background(Color.Black.copy(alpha = .4f)) @@ -178,16 +216,8 @@ internal fun DebugLayoutReadout( ) Text(modifier = valueBackgroundModifier, text = separator) Text( - modifier = valueBackgroundModifier.testTag(DEBUG_ROTATION_DEG_TAG), - text = stringResource( - R.string.debug_text_rotation_degrees, - resolvedState.rotationDegrees - ) - ) - Text(modifier = valueBackgroundModifier, text = separator) - Text( - modifier = valueBackgroundModifier.testTag(DEBUG_ROTATION_LOCK_TAG), - text = lockText + modifier = valueBackgroundModifier.testTag(DEBUG_FORM_FACTOR_TAG), + text = formFactorText ) } } @@ -582,9 +612,9 @@ private enum class SelectedDialog { SetTestPattern } -@Preview(name = "Unlocked", showBackground = true) +@Preview(name = "Compact", showBackground = true) @Composable -private fun DebugLayoutReadoutUnlockedPreview() { +private fun DebugLayoutReadoutCompactPreview() { MaterialTheme(colorScheme = darkColorScheme()) { DebugLayoutReadout( state = DebugLayoutReadoutState( @@ -595,16 +625,14 @@ private fun DebugLayoutReadoutUnlockedPreview() { } } -@Preview(name = "Locked", showBackground = true) +@Preview(name = "Foldable", showBackground = true) @Composable -private fun DebugLayoutReadoutLockedPreview() { +private fun DebugLayoutReadoutFoldablePreview() { MaterialTheme(colorScheme = darkColorScheme()) { DebugLayoutReadout( state = DebugLayoutReadoutState( - widthDp = 840, - heightDp = 360, - rotationDegrees = 90, - isRotationLocked = true + widthDp = 673, + heightDp = 841 ) ) } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index 4edebfede9..e0cbe023fd 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -23,8 +23,7 @@ const val DEBUG_OVERLAY_BUTTON = "DebugOverlayButton" const val BTN_DEBUG_HIDE_COMPONENTS_TAG = "btn_debug_hide_components" const val DEBUG_LAYOUT_READOUT_TAG = "row_debug_layout_readout" const val DEBUG_WINDOW_SIZE_TAG = "text_debug_window_size" -const val DEBUG_ROTATION_DEG_TAG = "text_debug_rotation_degrees" -const val DEBUG_ROTATION_LOCK_TAG = "text_debug_rotation_lock" +const val DEBUG_FORM_FACTOR_TAG = "text_debug_form_factor" const val DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON = "DebugOverlayShowCameraPropertiesButton" const val DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON = "DebugOverlaySetZoomRatioButton" const val DEBUG_OVERLAY_CAMERA_PROPERTIES_TAG = "DebugOverlayCameraPropertiesTag" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index fd8f55879b..43c2f2474f 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -26,7 +26,12 @@ "Layout: " " | " %1$dx%2$ddp - %1$d° - LOCKED - UNLOCKED + COMPACT_SQUARE + COMPACT_SLIM + COMPACT + FOLDABLE + TABLET + UNKNOWN diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 2141f426de..341e89591f 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -33,8 +33,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Robolectric Compose unit tests for [DebugLayoutReadout], [DebugTextBar] modifier scoping, and - * [DebugOverlay] integration. + * Robolectric Compose unit tests for [DebugLayoutReadout], [DebugFormFactor], [DebugTextBar] + * modifier scoping, and [DebugOverlay] integration. */ @RunWith(RobolectricTestRunner::class) class DebugLayoutReadoutTest { @@ -43,15 +43,14 @@ class DebugLayoutReadoutTest { val composeTestRule = createComposeRule() @Test - fun debugLayoutReadout_whenLocked_displaysEachTaggedBlock() { + fun debugLayoutReadout_whenExplicitFormFactorProvided_displaysEachTaggedBlock() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( state = DebugLayoutReadoutState( - widthDp = 840, - heightDp = 360, - rotationDegrees = 90, - isRotationLocked = true + widthDp = 673, + heightDp = 841, + formFactor = DebugFormFactor.FOLDABLE ) ) } @@ -60,15 +59,13 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertIsDisplayed() - composeTestRule.onNodeWithTag(DEBUG_ROTATION_DEG_TAG).assertIsDisplayed() - composeTestRule.onNodeWithTag(DEBUG_ROTATION_LOCK_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("840x360dp") - assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("90°") - assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("LOCKED") + composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("673x841dp") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("FOLDABLE") } @Test - fun debugLayoutReadout_whenDefaultsUsed_displaysUnlockedDefaults() { + fun debugLayoutReadout_whenDefaultFormFactorUsed_derivesFormFactorFromDimensions() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -83,8 +80,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp") - assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("0°") - assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("UNLOCKED") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") } @Test @@ -111,7 +107,7 @@ class DebugLayoutReadoutTest { @Test @Config(qualifiers = "w360dp-h800dp") - fun debugOverlay_whenLayoutReadoutStateIsNull_derivesLiveWindowDimensions() { + fun debugOverlay_whenLayoutReadoutStateIsNull_derivesLiveWindowDimensionsAndFormFactor() { composeTestRule.setContent { MaterialTheme { DebugOverlay( @@ -129,8 +125,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("360x800dp") - assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("0°") - assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("UNLOCKED") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") } @Test @@ -147,19 +142,16 @@ class DebugLayoutReadoutTest { ), debugController = FakeDebugController(), layoutReadoutState = DebugLayoutReadoutState( - widthDp = 840, - heightDp = 360, - rotationDegrees = 270, - isRotationLocked = true + widthDp = 800, + heightDp = 1280 ) ) } } composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() - assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("840x360dp") - assertThat(readNodeText(DEBUG_ROTATION_DEG_TAG)).isEqualTo("270°") - assertThat(readNodeText(DEBUG_ROTATION_LOCK_TAG)).isEqualTo("LOCKED") + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("800x1280dp") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TABLET") } @Test @@ -183,8 +175,60 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_ROTATION_DEG_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_ROTATION_LOCK_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertDoesNotExist() + } + + @Test + fun debugFormFactor_fromWindowDp_classifiesAllFormFactorBuckets() { + assertThat(DebugFormFactor.fromWindowDp(412, 412)) + .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + assertThat(DebugFormFactor.fromWindowDp(500, 360)) + .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + assertThat(DebugFormFactor.fromWindowDp(433, 346)) + .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + assertThat(DebugFormFactor.fromWindowDp(346, 433)) + .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + + assertThat(DebugFormFactor.fromWindowDp(276, 883)) + .isEqualTo(DebugFormFactor.COMPACT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(883, 276)) + .isEqualTo(DebugFormFactor.COMPACT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(250, 602)) + .isEqualTo(DebugFormFactor.COMPACT_SLIM) + + assertThat(DebugFormFactor.fromWindowDp(412, 915)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(840, 360)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(512, 650)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(566, 833)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(494, 602)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(383, 602)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(585, 602)) + .isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(480, 760)) + .isEqualTo(DebugFormFactor.COMPACT) + + assertThat(DebugFormFactor.fromWindowDp(673, 841)) + .isEqualTo(DebugFormFactor.FOLDABLE) + assertThat(DebugFormFactor.fromWindowDp(841, 673)) + .isEqualTo(DebugFormFactor.FOLDABLE) + assertThat(DebugFormFactor.fromWindowDp(850, 900)) + .isEqualTo(DebugFormFactor.FOLDABLE) + + assertThat(DebugFormFactor.fromWindowDp(800, 1280)) + .isEqualTo(DebugFormFactor.TABLET) + assertThat(DebugFormFactor.fromWindowDp(1280, 800)) + .isEqualTo(DebugFormFactor.TABLET) + + assertThat(DebugFormFactor.fromWindowDp(0, 800)) + .isEqualTo(DebugFormFactor.UNKNOWN) + assertThat(DebugFormFactor.fromWindowDp(412, -1)) + .isEqualTo(DebugFormFactor.UNKNOWN) } private fun readNodeText(tag: String): String = composeTestRule From 74b15dbf718401d40f633e8f78a00fa359ce0356 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 5 Nov 2026 08:18:45 -0800 Subject: [PATCH 06/17] feat(ui): wire 3-signal rotation and layout posture pipe into DebugLayoutReadout --- .../jetpackcamera/model/DeviceRotation.kt | 54 +++++ .../jetpackcamera/model/DeviceRotationTest.kt | 80 +++++++ .../feature/preview/PreviewScreen.kt | 82 ++++++- .../ui/components/capture/CaptureLayout.kt | 16 +- .../capture/DebouncedOrientationFlow.kt | 26 ++- .../components/capture/RotationModifiers.kt | 218 ++++++++++++++++++ .../capture/RotationModifiersTest.kt | 111 +++++++++ .../ui/debug/DebugOverlayComponents.kt | 170 +++++++++++--- .../google/jetpackcamera/ui/debug/TestTags.kt | 4 + ui/debug/src/main/res/values/strings.xml | 6 +- .../ui/debug/DebugLayoutReadoutTest.kt | 44 +++- 11 files changed, 755 insertions(+), 56 deletions(-) create mode 100644 core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt create mode 100644 ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index 82bfe6c5c8..c74defd9e8 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -49,7 +49,61 @@ 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). + */ + fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int { + return ((windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % + 360 + 360) % 360 + } + + /** + * Returns the clockwise rotation in degrees needed to orient UI elements upright within a + * natural-portrait coordinate frame. + * + * [Rotated180] (reverse portrait) is mapped to `0f` so camera controls do not flip upside-down. + */ + fun toUiRotationDegrees(): Float { + return when (this) { + Natural, Rotated180 -> 0f + Rotated90 -> -90f + Rotated270 -> 90f + } + } + + /** + * 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 { + /** + * Maps an OS display [Surface] rotation constant (`Surface.ROTATION_*`) to the + * corresponding hardware-relative [DeviceRotation]. + */ + fun fromSurfaceRotation(surfaceRotation: Int): DeviceRotation { + return when (surfaceRotation) { + Surface.ROTATION_0 -> Natural + Surface.ROTATION_90 -> Rotated270 + Surface.ROTATION_180 -> Rotated180 + Surface.ROTATION_270 -> Rotated90 + else -> Natural + } + } + fun snapFrom(degrees: Int): DeviceRotation { check(degrees in 0..359) { "Degrees must be in the range [0, 360)" diff --git a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt new file mode 100644 index 0000000000..d77f64e60f --- /dev/null +++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt @@ -0,0 +1,80 @@ +/* + * 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 { + + @Test + fun fromSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() { + assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_0)) + .isEqualTo(DeviceRotation.Natural) + assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_90)) + .isEqualTo(DeviceRotation.Rotated270) + assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_180)) + .isEqualTo(DeviceRotation.Rotated180) + assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_270)) + .isEqualTo(DeviceRotation.Rotated90) + assertThat(DeviceRotation.fromSurfaceRotation(-1)) + .isEqualTo(DeviceRotation.Natural) + + for (rotation in DeviceRotation.entries) { + assertThat(DeviceRotation.fromSurfaceRotation(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) + } +} 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 eea11d17dd..9b4ee9b83c 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 @@ -15,6 +15,8 @@ */ package com.google.jetpackcamera.feature.preview +import android.app.Activity +import android.content.pm.ActivityInfo import android.util.Log import android.util.Range import androidx.activity.compose.BackHandler @@ -39,6 +41,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -52,11 +55,16 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.tracing.Trace import com.google.jetpackcamera.core.camera.AudioStreamState import com.google.jetpackcamera.core.camera.InitialRecordingSettings @@ -64,6 +72,7 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CameraError import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.ImageCaptureEvent import com.google.jetpackcamera.model.LensFacing @@ -97,6 +106,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSetting import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashModeIndicator import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrIndicator import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.ToggleQuickSettingsButton +import com.google.jetpackcamera.ui.components.capture.rememberWindowRotation import com.google.jetpackcamera.ui.controller.CameraController import com.google.jetpackcamera.ui.controller.CaptureController import com.google.jetpackcamera.ui.controller.ScreenFlashController @@ -104,6 +114,7 @@ import com.google.jetpackcamera.ui.controller.SnackBarController import com.google.jetpackcamera.ui.controller.ZoomController import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController import com.google.jetpackcamera.ui.debug.DebugController +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.uistate.SingleSelectableUiState @@ -118,6 +129,7 @@ import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState +import kotlin.math.roundToInt import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.transformWhile @@ -193,13 +205,6 @@ fun PreviewScreen( } } - val context = LocalContext.current - LaunchedEffect(Unit) { - debouncedOrientationFlow(context).collect( - viewModel.cameraController::setDisplayRotation - ) - } - ContentScreen( modifier = modifier, captureUiStateProvider = readyStateProvider, @@ -258,6 +263,45 @@ internal fun ContentScreen( currentCaptureUiStateProvider().videoRecordingState is VideoRecordingState.Active } } + val isVideoRecordingInProgress = remember { + derivedStateOf { + currentCaptureUiStateProvider().videoRecordingState !is VideoRecordingState.Inactive + } + } + + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val windowRotation = rememberWindowRotation() + var latestSensorRotation by remember { mutableStateOf(windowRotation) } + var deviceRotation by remember { mutableStateOf(windowRotation) } + + LaunchedEffect(lifecycleOwner, context) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + debouncedOrientationFlow(context, initialRotation = windowRotation).collect { + latestSensorRotation = it + } + } + } + + LaunchedEffect(isVideoRecordingInProgress.value, latestSensorRotation, cameraController) { + if (!isVideoRecordingInProgress.value) { + deviceRotation = latestSensorRotation + cameraController?.setDisplayRotation(latestSensorRotation) + } + } + + val activity = context as? Activity + DisposableEffect(activity, isVideoRecordingInProgress.value) { + if (activity != null && isVideoRecordingInProgress.value) { + val previousOrientation = activity.requestedOrientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + onDispose { + activity.requestedOrientation = previousOrientation + } + } else { + onDispose {} + } + } val scope = rememberCoroutineScope() val zoomStateManager = remember(zoomController) { @@ -687,10 +731,21 @@ internal fun ContentScreen( } } + val currentDeviceRotation = rememberUpdatedState(deviceRotation) + val currentWindowRotation = rememberUpdatedState(windowRotation) val debugOverlayLambda = remember(debugController, debugUiState) { @Composable { modifier: Modifier, extraControls: Array<@Composable () -> Unit>? -> if (debugUiState is DebugUiState.Enabled) { debugController?.let { debugController -> + val layoutReadoutState = with(LocalDensity.current) { + val containerSize = LocalWindowInfo.current.containerSize + DebugLayoutReadoutState( + widthDp = containerSize.width.toDp().value.roundToInt(), + heightDp = containerSize.height.toDp().value.roundToInt(), + deviceRotation = currentDeviceRotation.value, + windowRotation = currentWindowRotation.value + ) + } DebugOverlay( modifier = modifier, debugUiState = debugUiState, @@ -698,7 +753,8 @@ internal fun ContentScreen( scope.launch { zoomStateManager.absoluteZoom(f, LensToZoom.PRIMARY) } }, extraControls = extraControls.orEmpty(), - debugController = debugController + debugController = debugController, + layoutReadoutState = layoutReadoutState ) } } @@ -834,7 +890,9 @@ internal fun ContentScreen( snackBar = snackBarLambda, pauseToggleButton = pauseToggleButtonLambda, imageWell = imageWellLambda, - errorDialog = errorDialogLambda + errorDialog = errorDialogLambda, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } @@ -918,7 +976,9 @@ private fun LayoutWrapper( debugVisibilityWrapper: (@Composable (@Composable () -> Unit) -> Unit), screenFlashOverlay: @Composable (modifier: Modifier) -> Unit, snackBar: @Composable (modifier: Modifier, snackbarHostState: SnackbarHostState) -> Unit, - errorDialog: @Composable (modifier: Modifier) -> Unit = {} + errorDialog: @Composable (modifier: Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = DeviceRotation.Natural, + windowRotation: DeviceRotation = DeviceRotation.Natural ) { PreviewLayout( modifier = modifier, @@ -934,6 +994,8 @@ private fun LayoutWrapper( captureModeToggle = captureModeToggle, captureModeCarousel = captureModeCarousel, quickSettingsOverlay = quickSettingsOverlay, + deviceRotation = deviceRotation, + windowRotation = windowRotation, indicatorRow = { modifier -> if (!isErrorDialogShowing) { IndicatorPill( diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index ce40ef6d1e..9578304886 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.takeOrElse +import com.google.jetpackcamera.model.DeviceRotation /** * The base layout for the camera capture screen. @@ -119,13 +120,24 @@ fun PreviewLayout( topStartContent: @Composable (Modifier) -> Unit = {}, topBarTrailingContent: @Composable (Modifier) -> Unit = {}, captureModeCarousel: @Composable (Modifier) -> Unit = {}, - errorDialog: @Composable (Modifier) -> Unit = {} + errorDialog: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = DeviceRotation.Natural, + windowRotation: DeviceRotation = rememberWindowRotation() ) { val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } + val gravityRotationState = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = windowRotation + ) val dismissQuickSettingsLabel = stringResource(R.string.quick_settings_btn_close_expanded_settings_description) - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalDeviceRotation provides deviceRotation, + LocalWindowRotation provides windowRotation, + LocalGravityRotationState provides gravityRotationState + ) { Box(modifier = modifier.fillMaxSize()) { BottomSheetScaffold( modifier = Modifier.fillMaxSize(), diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt index ffe34280c3..cf536ebc02 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt @@ -35,8 +35,13 @@ private const val ORIENTATION_HYSTERESIS = 5 * A flow that emits the device's orientation, debounced to avoid rapid changes. * * @param context the application context + * @param initialRotation the initial [DeviceRotation] emitted before the first valid sensor reading + * (and retained while the device is flat at [ORIENTATION_UNKNOWN]). */ -fun debouncedOrientationFlow(context: Context) = callbackFlow { +fun debouncedOrientationFlow( + context: Context, + initialRotation: DeviceRotation = DeviceRotation.Natural +) = callbackFlow { val orientationListener = object : OrientationEventListener(context) { override fun onOrientationChanged(orientation: Int) { trySend(orientation) @@ -49,14 +54,17 @@ fun debouncedOrientationFlow(context: Context) = callbackFlow { orientationListener.disable() } }.buffer(capacity = CONFLATED) - .runningFold(initial = DeviceRotation.Natural) { prevSnap, newDegrees -> - if ( - newDegrees != ORIENTATION_UNKNOWN && - abs(prevSnap.toClockwiseRotationDegrees() - newDegrees).let { min(it, 360 - it) } >= - 45 + ORIENTATION_HYSTERESIS - ) { - DeviceRotation.snapFrom(newDegrees) - } else { + .runningFold(initial = initialRotation) { prevSnap, newDegrees -> + if (newDegrees == ORIENTATION_UNKNOWN) { prevSnap + } else { + val candidate = DeviceRotation.snapFrom(newDegrees) + val distToCandidateCenter = + abs(candidate.toClockwiseRotationDegrees() - newDegrees).let { min(it, 360 - it) } + if (candidate != prevSnap && distToCandidateCenter <= 45 - ORIENTATION_HYSTERESIS) { + candidate + } else { + prevSnap + } } }.distinctUntilChanged() 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 new file mode 100644 index 0000000000..5e13865c29 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -0,0 +1,218 @@ +/* + * 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.ui.components.capture + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.Surface +import android.view.View +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Constraints +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LifecycleStartEffect +import com.google.jetpackcamera.model.DeviceRotation + +private const val UI_ROTATION_DURATION_MS = 250 +private val DefaultGravityRotationState: State = mutableFloatStateOf(0f) + +/** + * Provides the current physical gravity [DeviceRotation] to descendant composables in the capture + * screen. + */ +val LocalDeviceRotation: ProvidableCompositionLocal = + compositionLocalOf { DeviceRotation.Natural } + +/** + * Provides the current OS window/display [DeviceRotation] to descendant composables in the capture + * screen. + */ +val LocalWindowRotation: 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/ + * RenderNode phase with zero recompositions or layout passes. + */ +val LocalGravityRotationState: ProvidableCompositionLocal> = + compositionLocalOf { DefaultGravityRotationState } + +/** + * Tracks the current OS display rotation synchronously on configuration changes and via + * [DisplayManager.DisplayListener] while `STARTED` (catching seamless 180-degree + * landscape-to-landscape flips that do not trigger configuration changes). + */ +@Composable +fun rememberWindowRotation(): DeviceRotation { + val view = LocalView.current + val context = LocalContext.current + val configuration = LocalConfiguration.current + var windowRotation by remember { + mutableStateOf(readCurrentDisplayRotation(view, context)) + } + + LaunchedEffect(configuration, view, context) { + windowRotation = readCurrentDisplayRotation(view, context) + } + + LifecycleStartEffect(view, context) { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + val listener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + val targetDisplayId = view.display?.displayId ?: Display.DEFAULT_DISPLAY + if (displayId == targetDisplayId) { + val updated = readCurrentDisplayRotation(view, context) + if (updated != windowRotation) { + windowRotation = updated + } + } + } + } + windowRotation = readCurrentDisplayRotation(view, context) + displayManager?.registerDisplayListener(listener, null) + onStopOrDispose { + displayManager?.unregisterDisplayListener(listener) + } + } + + return windowRotation +} + +private fun readCurrentDisplayRotation(view: View, context: Context): DeviceRotation { + val surfaceRotation = runCatching { + (view.display ?: ContextCompat.getDisplayOrDefault(context)).rotation + }.getOrDefault(Surface.ROTATION_0) + return DeviceRotation.fromSurfaceRotation(surfaceRotation) +} + +/** + * Remembers a single continuous clockwise rotation [State] in degrees that keeps UI elements + * oriented upright against gravity inside the current window: + * - Uses shortest-path angle interpolation (`±90°`) so transitions across `0° <-> 270°` never spin + * `270°` the long way around. + * - Snaps immediately when [windowRotation] changes (since the OS is already animating the window + * rotation) or when [LocalDisableAnimations] is `true`. + */ +@Composable +fun rememberGravityRotationState( + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current +): State { + val targetClockwiseDegrees = deviceRotation.toGravityRotationDegrees(windowRotation) + var continuousTarget by remember { mutableFloatStateOf(targetClockwiseDegrees.toFloat()) } + var previousWindowRotation by remember { mutableStateOf(windowRotation) } + + val windowRotationChanged = previousWindowRotation != windowRotation + if (windowRotationChanged) { + previousWindowRotation = windowRotation + } + + val currentMod = ((continuousTarget % 360f) + 360f) % 360f + val rawDelta = ((targetClockwiseDegrees - currentMod + 540f) % 360f) - 180f + val delta = if (rawDelta == -180f) 180f else rawDelta + if (delta != 0f) { + continuousTarget += delta + } + + val disableAnimations = LocalDisableAnimations.current + return animateFloatAsState( + targetValue = continuousTarget, + animationSpec = if (disableAnimations || windowRotationChanged) { + snap() + } else { + tween( + durationMillis = UI_ROTATION_DURATION_MS, + easing = FastOutSlowInEasing + ) + }, + label = "GravityRotationDegrees" + ) +} + +/** + * Rotates a square or circular icon/button in-place around its center in the draw/layer phase so it + * stays upright relative to physical gravity. + */ +fun Modifier.animateToUpright(rotationState: State? = null): Modifier = composed { + val effectiveState = rotationState ?: LocalGravityRotationState.current + graphicsLayer { + rotationZ = effectiveState.value + } +} + +/** + * Measures and places a rectangular composable with swapped width and height constraints when + * [rotationDegrees] is `90°` or `270°`, centering the rotated layer within the swapped layout box + * so rectangular overlays orient upright without clipping or misaligned hit targets. + */ +fun Modifier.rotateLayoutToGravity(rotationDegrees: Int): Modifier = + layout { measurable, constraints -> + val normalized = ((rotationDegrees % 360) + 360) % 360 + val swapAxes = normalized == 90 || normalized == 270 + val childConstraints = if (swapAxes) { + val swappedMaxWidth = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + constraints.maxWidth + } + val swappedMaxHeight = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + constraints.maxHeight + } + Constraints( + minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), + maxWidth = swappedMaxWidth, + minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), + maxHeight = swappedMaxHeight + ) + } else { + constraints + } + val placeable = measurable.measure(childConstraints) + val layoutWidth = if (swapAxes) placeable.height else placeable.width + val layoutHeight = if (swapAxes) placeable.width else placeable.height + layout(layoutWidth, layoutHeight) { + val offsetX = (layoutWidth - placeable.width) / 2 + val offsetY = (layoutHeight - placeable.height) / 2 + placeable.placeWithLayer(offsetX, offsetY) { + rotationZ = normalized.toFloat() + } + } + } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt new file mode 100644 index 0000000000..658896dc9e --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -0,0 +1,111 @@ +/* + * 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.ui.components.capture + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.DeviceRotation +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RotationModifiersTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rememberGravityRotationState_usesShortestPathAcrossZeroAnd270Boundary() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + var currentDegrees = 0f + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + val state = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + currentDegrees = state.value + } + } + + assertThat(currentDegrees).isEqualTo(0f) + + // Tilting 90° CW (Dev 90°, Win 0°) -> target 270°, shortest path from 0° is -90° + deviceRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(-90f) + + // Returning to Natural (Dev 0°, Win 0°) -> shortest path from -90° back to 0° is 0° + deviceRotation = DeviceRotation.Natural + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(0f) + + // Tilting 90° CCW (Dev 270°, Win 0°) -> target 90°, shortest path from 0° is +90° + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(90f) + } + + @Test + fun rotateLayoutToGravity_swapsOuterDimensionsAt90And270Degrees() { + var outerSize = IntSize.Zero + var rotationDegrees by mutableStateOf(0) + + composeTestRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + Box( + modifier = Modifier.onSizeChanged { outerSize = it } + ) { + Box( + modifier = Modifier + .rotateLayoutToGravity(rotationDegrees) + .size(width = 200.dp, height = 60.dp) + ) + } + } + } + + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(200, 60)) + + rotationDegrees = 90 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(60, 200)) + + rotationDegrees = 180 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(200, 60)) + + rotationDegrees = 270 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(60, 200)) + } +} diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index 6cba56e490..f5307c93bd 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -16,11 +16,15 @@ package com.google.jetpackcamera.ui.debug import android.util.Log +import android.view.Surface import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -42,6 +47,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -50,6 +56,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -57,7 +64,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -69,6 +79,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.TestPattern import kotlin.math.roundToInt @@ -150,8 +161,8 @@ enum class DebugFormFactor { } /** - * Snapshot of window container dimensions and derived form factor displayed by the debug - * console's layout readout row. + * Snapshot of window container dimensions, derived form factor, and rotation signals displayed by + * the debug console's layout readout. * * When passed to [DebugOverlay], these explicit values override the live window-container fallback * used when layout readout state is omitted. @@ -159,37 +170,56 @@ enum class DebugFormFactor { * @property widthDp Current window container width in density-independent pixels. * @property heightDp Current window container height in density-independent pixels. * @property formFactor Calculated [DebugFormFactor] for the window container dimensions. + * @property deviceRotation Physical hardware orientation relative to gravity. + * @property windowRotation OS window/display orientation relative to natural posture. */ @Immutable data class DebugLayoutReadoutState( val widthDp: Int, val heightDp: Int, - val formFactor: DebugFormFactor = DebugFormFactor.fromWindowDp(widthDp, heightDp) -) + val formFactor: DebugFormFactor = DebugFormFactor.fromWindowDp(widthDp, heightDp), + val deviceRotation: DeviceRotation = DeviceRotation.Natural, + val windowRotation: DeviceRotation = DeviceRotation.Natural +) { + /** + * Clockwise rotation in degrees needed inside the current window so that icons and overlays + * point upright against gravity. + */ + val iconRotationDegrees: Int + get() = deviceRotation.toGravityRotationDegrees(windowRotation) +} /** - * Displays a compact single-line debug readout showing window container size in dp and the - * calculated [DebugFormFactor] as individually tagged text blocks. + * Displays the debug layout and rotation readout as individually tagged rows for window container + * size, [DebugFormFactor], physical device rotation, OS window rotation, and derived in-window icon + * rotation. * - * When [state] is `null`, window dimensions and form factor are derived live from - * [LocalWindowInfo] and [LocalDensity]. + * When [state] is `null`, window dimensions, form factor, and window rotation are derived live from + * [LocalWindowInfo], [LocalDensity], and [LocalView]. * - * @param state Explicit layout readout values to display, or `null` to use live window dimensions. - * @param modifier Modifier applied to the outer readout row. + * @param state Explicit layout readout values to display, or `null` to use live window values. + * @param modifier Modifier applied to the outer readout container. */ @Composable internal fun DebugLayoutReadout( state: DebugLayoutReadoutState? = null, modifier: Modifier = Modifier ) { - val resolvedState = state ?: with(LocalDensity.current) { - val containerSize = LocalWindowInfo.current.containerSize - DebugLayoutReadoutState( - widthDp = containerSize.width.toDp().value.roundToInt(), - heightDp = containerSize.height.toDp().value.roundToInt() + val resolvedState = state ?: run { + val view = LocalView.current + val liveWindowRotation = DeviceRotation.fromSurfaceRotation( + runCatching { view.display?.rotation }.getOrNull() ?: Surface.ROTATION_0 ) + with(LocalDensity.current) { + val containerSize = LocalWindowInfo.current.containerSize + DebugLayoutReadoutState( + widthDp = containerSize.width.toDp().value.roundToInt(), + heightDp = containerSize.height.toDp().value.roundToInt(), + deviceRotation = liveWindowRotation, + windowRotation = liveWindowRotation + ) + } } - val separator = stringResource(R.string.debug_text_layout_separator) val formFactorText = stringResource( when (resolvedState.formFactor) { DebugFormFactor.COMPACT_SQUARE -> R.string.debug_text_form_factor_compact_square @@ -200,25 +230,105 @@ internal fun DebugLayoutReadout( DebugFormFactor.UNKNOWN -> R.string.debug_text_form_factor_unknown } ) - val valueBackgroundModifier = Modifier.background(Color.Black.copy(alpha = .4f)) - Row(modifier = modifier.testTag(DEBUG_LAYOUT_READOUT_TAG)) { - Text( - modifier = Modifier.background(Color.Black.copy(alpha = .7f)), - text = stringResource(R.string.debug_text_layout_prefix) - ) - Text( - modifier = valueBackgroundModifier.testTag(DEBUG_WINDOW_SIZE_TAG), - text = stringResource( + Column(modifier = modifier.testTag(DEBUG_LAYOUT_READOUT_TAG)) { + DebugTextBar( + title = stringResource(R.string.debug_text_layout_prefix), + value = stringResource( R.string.debug_text_window_size, resolvedState.widthDp, resolvedState.heightDp - ) + ), + tag = DEBUG_WINDOW_SIZE_TAG ) - Text(modifier = valueBackgroundModifier, text = separator) - Text( - modifier = valueBackgroundModifier.testTag(DEBUG_FORM_FACTOR_TAG), - text = formFactorText + DebugTextBar( + title = stringResource(R.string.debug_text_form_factor_prefix), + value = formFactorText, + tag = DEBUG_FORM_FACTOR_TAG + ) + DebugTextBar( + title = stringResource(R.string.debug_text_device_rotation_prefix), + value = stringResource( + R.string.debug_text_rotation_degrees, + resolvedState.deviceRotation.toClockwiseRotationDegrees() + ), + tag = DEBUG_DEVICE_ROTATION_TAG ) + DebugTextBar( + title = stringResource(R.string.debug_text_window_rotation_prefix), + value = stringResource( + R.string.debug_text_rotation_degrees, + resolvedState.windowRotation.toClockwiseRotationDegrees() + ), + tag = DEBUG_WINDOW_ROTATION_TAG + ) + Row(verticalAlignment = Alignment.CenterVertically) { + DebugTextBar( + title = stringResource(R.string.debug_text_icon_rotation_prefix), + value = stringResource( + R.string.debug_text_rotation_degrees, + resolvedState.iconRotationDegrees + ), + tag = DEBUG_ICON_ROTATION_TAG + ) + DebugUpwardTriangleIndicator( + rotationDegrees = resolvedState.iconRotationDegrees, + modifier = Modifier.testTag(DEBUG_ICON_ROTATION_INDICATOR_TAG) + ) + } + } +} + +/** + * Draws an upward-pointing triangle rotated clockwise by [rotationDegrees] so its apex continuously + * points upward against physical gravity inside the current window. + */ +@Composable +private fun DebugUpwardTriangleIndicator( + rotationDegrees: Int, + modifier: Modifier = Modifier +) { + val targetAngle = rotationDegrees.toFloat() + var cumulativeTargetDegrees by remember { mutableFloatStateOf(targetAngle) } + val currentNormalized = ((cumulativeTargetDegrees % 360f) + 360f) % 360f + var delta = targetAngle - currentNormalized + if (delta > 180f) delta -= 360f + if (delta <= -180f) delta += 360f + if (delta != 0f) { + cumulativeTargetDegrees += delta + } + val animatedDegrees = animateFloatAsState( + targetValue = cumulativeTargetDegrees, + animationSpec = tween(durationMillis = 300), + label = "DebugUpwardTriangleRotation" + ) + val triangleColor = LocalContentColor.current + val rotationDescription = stringResource(R.string.debug_text_rotation_degrees, rotationDegrees) + + Box( + modifier = modifier + .background(Color.Black.copy(alpha = .4f)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .size(14.dp) + .semantics { + stateDescription = rotationDescription + }, + contentAlignment = Alignment.Center + ) { + Canvas( + modifier = Modifier + .size(10.dp) + .graphicsLayer { + rotationZ = animatedDegrees.value + } + ) { + val path = Path().apply { + moveTo(size.width / 2f, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + drawPath(path = path, color = triangleColor) + } } } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index e0cbe023fd..927f9b9f69 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -24,6 +24,10 @@ const val BTN_DEBUG_HIDE_COMPONENTS_TAG = "btn_debug_hide_components" const val DEBUG_LAYOUT_READOUT_TAG = "row_debug_layout_readout" const val DEBUG_WINDOW_SIZE_TAG = "text_debug_window_size" const val DEBUG_FORM_FACTOR_TAG = "text_debug_form_factor" +const val DEBUG_DEVICE_ROTATION_TAG = "text_debug_device_rotation" +const val DEBUG_WINDOW_ROTATION_TAG = "text_debug_window_rotation" +const val DEBUG_ICON_ROTATION_TAG = "text_debug_icon_rotation" +const val DEBUG_ICON_ROTATION_INDICATOR_TAG = "icon_debug_rotation_indicator" const val DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON = "DebugOverlayShowCameraPropertiesButton" const val DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON = "DebugOverlaySetZoomRatioButton" const val DEBUG_OVERLAY_CAMERA_PROPERTIES_TAG = "DebugOverlayCameraPropertiesTag" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index 43c2f2474f..5f8c8b06b8 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -24,8 +24,12 @@ Displaying components %1$.2fx "Layout: " - " | " + "Form Factor: " + "Device Rotation: " + "Window Rotation: " + "Icon Rotation: " %1$dx%2$ddp + %1$d° COMPACT_SQUARE diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 341e89591f..fae89b2d8b 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.TestPattern import org.junit.Rule import org.junit.Test @@ -50,22 +51,36 @@ class DebugLayoutReadoutTest { state = DebugLayoutReadoutState( widthDp = 673, heightDp = 841, - formFactor = DebugFormFactor.FOLDABLE + formFactor = DebugFormFactor.FOLDABLE, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Natural ) ) } } composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Form Factor: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Device Rotation: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Window Rotation: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Icon Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("673x841dp") assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("FOLDABLE") + assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("270°") + assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("90°") + assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("90°") } @Test - fun debugLayoutReadout_whenDefaultFormFactorUsed_derivesFormFactorFromDimensions() { + fun debugLayoutReadout_whenDefaultFormFactorUsed_derivesFormFactorAndDefaultRotations() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -81,10 +96,13 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp") assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°") } @Test - fun debugLayoutReadout_appliesModifierOnlyToOuterRow_notChildTextNodes() { + fun debugLayoutReadout_appliesModifierOnlyToOuterContainer_notChildTextNodes() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -126,6 +144,9 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("360x800dp") assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°") } @Test @@ -143,7 +164,9 @@ class DebugLayoutReadoutTest { debugController = FakeDebugController(), layoutReadoutState = DebugLayoutReadoutState( widthDp = 800, - heightDp = 1280 + heightDp = 1280, + deviceRotation = DeviceRotation.Rotated90, + windowRotation = DeviceRotation.Natural ) ) } @@ -152,6 +175,10 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("800x1280dp") assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TABLET") + assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("90°") + assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("270°") + assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("270°") } @Test @@ -176,6 +203,10 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertDoesNotExist() } @Test @@ -236,6 +267,11 @@ class DebugLayoutReadoutTest { .fetchSemanticsNode() .config[SemanticsProperties.Text] .joinToString(separator = "") { it.text } + + private fun readNodeStateDescription(tag: String): String = composeTestRule + .onNodeWithTag(tag) + .fetchSemanticsNode() + .config[SemanticsProperties.StateDescription] } private class FakeDebugController : DebugController { From 3915575b1aa9534c6ff9659b0a2fad4f224c083b Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 5 Nov 2026 10:06:57 -0800 Subject: [PATCH 07/17] feat(ui/debug): add expectedLayoutRotationDegrees readout and clamp 180-degree gravity delta --- .../jetpackcamera/model/DeviceRotation.kt | 9 +- .../jetpackcamera/model/DeviceRotationTest.kt | 12 +++ .../ui/debug/DebugOverlayComponents.kt | 51 ++++++++++- .../google/jetpackcamera/ui/debug/TestTags.kt | 1 + ui/debug/src/main/res/values/strings.xml | 1 + .../ui/debug/DebugLayoutReadoutTest.kt | 88 +++++++++++++++++++ 6 files changed, 157 insertions(+), 5 deletions(-) diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index c74defd9e8..35b477893f 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -52,10 +52,15 @@ 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 { - return ((windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % - 360 + 360) % 360 + val rawDelta = + ((windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % + 360 + 360) % 360 + return if (rawDelta == 180) 0 else rawDelta } /** diff --git a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt index d77f64e60f..212bd3d2f0 100644 --- a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt +++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt @@ -76,5 +76,17 @@ class DeviceRotationTest { 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) } } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index f5307c93bd..77174dbf80 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -183,16 +183,53 @@ data class DebugLayoutReadoutState( ) { /** * Clockwise rotation in degrees needed inside the current window so that icons and overlays - * point upright against gravity. + * point upright against gravity (clamping `180°` to `0°`). */ val iconRotationDegrees: Int get() = deviceRotation.toGravityRotationDegrees(windowRotation) + + /** + * Clockwise rotation in degrees that the capture layout is expected to apply to align its + * primary axis with the window's dimensions: + * - [DebugFormFactor.FOLDABLE], [DebugFormFactor.TABLET], and [DebugFormFactor.UNKNOWN] always + * remain upright (`0°`) in the window while their side rails adapt structurally. + * - [DebugFormFactor.COMPACT], [DebugFormFactor.COMPACT_SLIM], and + * [DebugFormFactor.COMPACT_SQUARE] preserve the `9:16` portrait layout at `0°` when + * `heightDp >= widthDp`, and rotate `90°` or `270°` when `widthDp > heightDp` so the `9:16` + * layout aligns with the window's horizontal length instead of shrinking to a square. + */ + val expectedLayoutRotationDegrees: Int + get() = when (formFactor) { + DebugFormFactor.FOLDABLE, + DebugFormFactor.TABLET, + DebugFormFactor.UNKNOWN -> 0 + DebugFormFactor.COMPACT, + DebugFormFactor.COMPACT_SLIM, + DebugFormFactor.COMPACT_SQUARE -> + if (heightDp >= widthDp) { + 0 + } else { + val gravity = iconRotationDegrees + val counter = + ((windowRotation.toWindowCounterRotationDegrees().toInt() % 360) + 360) % + 360 + when { + gravity == 90 || gravity == 270 -> gravity + counter == 90 || counter == 270 -> counter + else -> DEFAULT_WIDE_COMPACT_LAYOUT_ROTATION_DEGREES + } + } + } + + companion object { + private const val DEFAULT_WIDE_COMPACT_LAYOUT_ROTATION_DEGREES = 270 + } } /** * Displays the debug layout and rotation readout as individually tagged rows for window container - * size, [DebugFormFactor], physical device rotation, OS window rotation, and derived in-window icon - * rotation. + * size, [DebugFormFactor], physical device rotation, OS window rotation, expected layout rotation, + * and derived in-window icon rotation. * * When [state] is `null`, window dimensions, form factor, and window rotation are derived live from * [LocalWindowInfo], [LocalDensity], and [LocalView]. @@ -261,6 +298,14 @@ internal fun DebugLayoutReadout( ), tag = DEBUG_WINDOW_ROTATION_TAG ) + DebugTextBar( + title = stringResource(R.string.debug_text_expected_layout_rotation_prefix), + value = stringResource( + R.string.debug_text_rotation_degrees, + resolvedState.expectedLayoutRotationDegrees + ), + tag = DEBUG_EXPECTED_LAYOUT_ROTATION_TAG + ) Row(verticalAlignment = Alignment.CenterVertically) { DebugTextBar( title = stringResource(R.string.debug_text_icon_rotation_prefix), diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index 927f9b9f69..a34f9c5880 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -26,6 +26,7 @@ const val DEBUG_WINDOW_SIZE_TAG = "text_debug_window_size" const val DEBUG_FORM_FACTOR_TAG = "text_debug_form_factor" const val DEBUG_DEVICE_ROTATION_TAG = "text_debug_device_rotation" const val DEBUG_WINDOW_ROTATION_TAG = "text_debug_window_rotation" +const val DEBUG_EXPECTED_LAYOUT_ROTATION_TAG = "text_debug_expected_layout_rotation" const val DEBUG_ICON_ROTATION_TAG = "text_debug_icon_rotation" const val DEBUG_ICON_ROTATION_INDICATOR_TAG = "icon_debug_rotation_indicator" const val DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON = "DebugOverlayShowCameraPropertiesButton" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index 5f8c8b06b8..2f5cfcdaa6 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ "Form Factor: " "Device Rotation: " "Window Rotation: " + "Expected Layout Rotation: " "Icon Rotation: " %1$dx%2$ddp %1$d° diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index fae89b2d8b..c36f65c37c 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -63,18 +63,21 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Form Factor: ").assertIsDisplayed() composeTestRule.onNodeWithText("Device Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Window Rotation: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Expected Layout Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Icon Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("673x841dp") assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("FOLDABLE") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("270°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("90°") assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("90°") } @@ -98,6 +101,7 @@ class DebugLayoutReadoutTest { assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°") } @@ -146,6 +150,7 @@ class DebugLayoutReadoutTest { assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°") } @@ -177,6 +182,7 @@ class DebugLayoutReadoutTest { assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TABLET") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("90°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("270°") assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("270°") } @@ -205,10 +211,92 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertDoesNotExist() } + @Test + fun debugLayoutReadoutState_expectedLayoutRotationDegrees_handlesFormFactorsAndAspectRatios() { + // Tall compact phone (heightDp >= widthDp) -> 0° + assertThat( + DebugLayoutReadoutState( + widthDp = 412, + heightDp = 915, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(0) + + // Upside-down compact phone -> 0° (both icon and layout clamp 180° to 0°) + val upsideDownPhone = DebugLayoutReadoutState( + widthDp = 412, + heightDp = 915, + deviceRotation = DeviceRotation.Rotated180, + windowRotation = DeviceRotation.Natural + ) + assertThat(upsideDownPhone.iconRotationDegrees).isEqualTo(0) + assertThat(upsideDownPhone.expectedLayoutRotationDegrees).isEqualTo(0) + + // Wide compact window with OS-rotated landscape window (Win 270°, Dev 270°) -> 270° + assertThat( + DebugLayoutReadoutState( + widthDp = 915, + heightDp = 412, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Rotated270 + ).expectedLayoutRotationDegrees + ).isEqualTo(270) + + // Wide compact split-screen (50/50 top/bottom on landscape tablet: Win 0°, Dev 0°) -> 270° + assertThat( + DebugLayoutReadoutState( + widthDp = 1280, + heightDp = 390, + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(270) + + // Wide compact_square quadrant (433x346, Win 0°, Dev 0°) -> 270° + assertThat( + DebugLayoutReadoutState( + widthDp = 433, + heightDp = 346, + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(270) + + // Wide compact window held at 90° CW with Win 0° -> follows gravity (270°) or 270° CCW (90°) + assertThat( + DebugLayoutReadoutState( + widthDp = 840, + heightDp = 360, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(90) + + // Foldable and Tablet always remain upright (0°) regardless of widthDp vs heightDp + assertThat( + DebugLayoutReadoutState( + widthDp = 841, + heightDp = 673, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Rotated270 + ).expectedLayoutRotationDegrees + ).isEqualTo(0) + assertThat( + DebugLayoutReadoutState( + widthDp = 1280, + heightDp = 800, + deviceRotation = DeviceRotation.Rotated270, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(0) + } + @Test fun debugFormFactor_fromWindowDp_classifiesAllFormFactorBuckets() { assertThat(DebugFormFactor.fromWindowDp(412, 412)) From 766dbe048e656a62c2b8cc2311229fface060eab Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 5 Nov 2026 11:58:50 -0800 Subject: [PATCH 08/17] feat(app, preview): enable landscape viewfinder framing and multi-window orientation - Remove static android:screenOrientation="nosensor" from MainActivity in AndroidManifest.xml and declare orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden configChanges - Add runtime resolveRequestedOrientation in MainActivity to lock full-screen compact phones (< 600dp) to SCREEN_ORIENTATION_NOSENSOR while using SCREEN_ORIENTATION_UNSPECIFIED in multi-window and on large screens (>= 600dp) to prevent 1:1 OS compatibility letterboxing - Pass useLandscapeAspectRatio = true to PreviewDisplay in PreviewScreen so wide containers frame the viewfinder in landscape (4:3 / 16:9) - Add unit tests in MainActivityOrientationTest --- app/src/main/AndroidManifest.xml | 2 +- .../com/google/jetpackcamera/MainActivity.kt | 46 ++++++++++++ .../MainActivityOrientationTest.kt | 70 +++++++++++++++++++ .../feature/preview/PreviewScreen.kt | 3 +- 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d50ebb448..2400b7b561 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,9 +70,9 @@ diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index d230426a61..7b43c7af14 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -18,6 +18,7 @@ package com.google.jetpackcamera import android.app.Activity import android.content.Intent import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.hardware.Camera import android.net.Uri import android.os.Build @@ -31,6 +32,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -81,6 +83,26 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch private const val TAG = "MainActivity" +private const val LARGE_SCREEN_MIN_WIDTH_DP = 600 + +/** + * Resolves the Activity's [ActivityInfo] screen orientation policy for the current window posture. + * + * Full-screen compact handhelds (`!isInMultiWindowMode && smallestScreenWidthDp < 600`) lock the + * window to [ActivityInfo.SCREEN_ORIENTATION_NOSENSOR] so the UI counter-rotates via sensor + * gravity without triggering a system window rotation. Multi-window panes and large screens + * (`smallestScreenWidthDp >= 600`) use [ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED] so the OS + * does not apply 1:1 portrait compatibility letterboxing. + */ +@VisibleForTesting +internal fun resolveRequestedOrientation( + isInMultiWindowMode: Boolean, + smallestScreenWidthDp: Int +): Int = if (!isInMultiWindowMode && smallestScreenWidthDp < LARGE_SCREEN_MIN_WIDTH_DP) { + ActivityInfo.SCREEN_ORIENTATION_NOSENSOR +} else { + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +} /** * Activity for the JetpackCameraApp. @@ -103,6 +125,7 @@ class MainActivity : ComponentActivity() { navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) super.onCreate(savedInstanceState) + updateRequestedOrientation(resources.configuration) cameraLaunchConfigProvider.setIntent(intent) var uiState: MainActivityUiState by mutableStateOf(Loading) @@ -194,6 +217,29 @@ class MainActivity : ComponentActivity() { } } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateRequestedOrientation(newConfig) + } + + override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration) { + super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) + updateRequestedOrientation(newConfig, isInMultiWindowMode) + } + + private fun updateRequestedOrientation( + configuration: Configuration, + multiWindowMode: Boolean = isInMultiWindowMode + ) { + val targetOrientation = resolveRequestedOrientation( + isInMultiWindowMode = multiWindowMode, + smallestScreenWidthDp = configuration.smallestScreenWidthDp + ) + if (requestedOrientation != targetOrientation) { + requestedOrientation = targetOrientation + } + } + private val debugSettings: DebugSettings get() = cameraLaunchConfigProvider.config.value.debugSettings diff --git a/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt new file mode 100644 index 0000000000..8dd26b1d2a --- /dev/null +++ b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt @@ -0,0 +1,70 @@ +/* + * 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 + +import android.content.pm.ActivityInfo +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [resolveRequestedOrientation]. + */ +class MainActivityOrientationTest { + + @Test + fun resolveRequestedOrientation_fullScreenCompactPhone_locksToNoSensor() { + assertThat( + resolveRequestedOrientation( + isInMultiWindowMode = false, + smallestScreenWidthDp = 411 + ) + ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR) + + assertThat( + resolveRequestedOrientation( + isInMultiWindowMode = false, + smallestScreenWidthDp = 599 + ) + ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR) + } + + @Test + fun resolveRequestedOrientation_multiWindowCompact_allowsUnspecifiedOrientation() { + assertThat( + resolveRequestedOrientation( + isInMultiWindowMode = true, + smallestScreenWidthDp = 411 + ) + ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + + @Test + fun resolveRequestedOrientation_largeScreenOrFoldable_allowsUnspecifiedOrientation() { + assertThat( + resolveRequestedOrientation( + isInMultiWindowMode = false, + smallestScreenWidthDp = 600 + ) + ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + + assertThat( + resolveRequestedOrientation( + isInMultiWindowMode = true, + smallestScreenWidthDp = 673 + ) + ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } +} 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 aacff43a8a..4d27f578dd 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 @@ -472,7 +472,8 @@ internal fun ContentScreen( onScaleZoom = { zoomRatio -> onScaleZoomLambda(zoomRatio) }, surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = focusMeteringState.value + focusMeteringUiState = focusMeteringState.value, + useLandscapeAspectRatio = true ) } } From 2a21e30a7660b09dd7e84122952ed71a926c3eb3 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 07:48:27 -0800 Subject: [PATCH 09/17] feat(ui/capture, debug): add compact layout counter-rotation, cutout remapping, and mode readout --- .../jetpackcamera/model/DeviceRotation.kt | 6 +- .../ui/components/capture/CaptureLayout.kt | 8 +- .../capture/CompactPortraitLayout.kt | 417 +++++++++------ .../ui/components/capture/CutoutAwareRow.kt | 72 ++- .../components/capture/RotationModifiers.kt | 164 ++++++ .../components/capture/LayoutRotationTest.kt | 492 ++++++++++++++++++ .../capture/RotationModifiersTest.kt | 142 +++++ .../ui/debug/DebugOverlayComponents.kt | 5 +- .../ui/debug/DebugLayoutReadoutTest.kt | 16 + 9 files changed, 1131 insertions(+), 191 deletions(-) create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index 35b477893f..942cab01a0 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -58,8 +58,10 @@ enum class DeviceRotation { */ fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int { val rawDelta = - ((windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % - 360 + 360) % 360 + ( + (windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % + 360 + 360 + ) % 360 return if (rawDelta == 180) 0 else rawDelta } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index c9ac154e02..3226eff11c 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -195,7 +195,9 @@ fun PreviewLayout( onRequiresImmersiveChange = onRequiresImmersiveChange, debugOverlay = debugOverlay, debugVisibilityWrapper = debugVisibilityWrapper, - screenFlashOverlay = screenFlashOverlay + screenFlashOverlay = screenFlashOverlay, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } else { CompactPortraitLayout( @@ -221,7 +223,9 @@ fun PreviewLayout( viewfinderTopEndContent = viewfinderTopEndContent, topBarTrailingContent = topBarTrailingContent, onRequiresImmersiveChange = onRequiresImmersiveChange, - errorDialog = errorDialog + errorDialog = errorDialog, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } } 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 208f8417dc..1a63f8c849 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 @@ -82,6 +82,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max 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.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds @@ -218,6 +219,8 @@ internal fun Modifier.suppressBackgroundForModal(isModalOpen: Boolean): Modifier * hiding is enabled. * @param screenFlashOverlay Full-bleed screen flash composable drawn above all chrome and system * bar areas. + * @param deviceRotation Physical hardware [DeviceRotation] relative to gravity. + * @param windowRotation Current OS window/display [DeviceRotation]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -233,7 +236,9 @@ fun CompactPortraitLayout( onRequiresImmersiveChange: (Boolean) -> Unit = {}, debugOverlay: @Composable (Modifier) -> Unit = {}, debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit = { it() }, - screenFlashOverlay: @Composable (Modifier) -> Unit = {} + screenFlashOverlay: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { CompactPortraitLayout( modifier = modifier, @@ -375,7 +380,9 @@ fun CompactPortraitLayout( ) }, onRequiresImmersiveChange = onRequiresImmersiveChange, - errorDialog = slots.errorDialog + errorDialog = slots.errorDialog, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } @@ -422,6 +429,8 @@ fun CompactPortraitLayout( * @param onRequiresImmersiveChange Callback notified when the portrait solver requires or releases * immersive navigation-bar hiding. * @param errorDialog In-hierarchy camera error dialog composable. + * @param deviceRotation Physical hardware [DeviceRotation] relative to gravity. + * @param windowRotation Current OS window/display [DeviceRotation]. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -450,7 +459,9 @@ fun CompactPortraitLayout( viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, topBarTrailingContent: @Composable (Modifier) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, - errorDialog: @Composable (Modifier) -> Unit = {} + errorDialog: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } val localeDirection = LocalLayoutDirection.current @@ -460,202 +471,258 @@ fun CompactPortraitLayout( scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded val isModalOpen = isSheetVisible || isErrorDialogShowing - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { - Box( - modifier = modifier - .fillMaxSize() - .testTag(COMPACT_CONTROLS_TAG) + SeamlessRotationAnimationEffect() + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .testTag(COMPACT_CONTROLS_TAG) + ) { + val windowWidth = maxWidth + val windowHeight = maxHeight + val shouldCounterRotate = windowWidth > windowHeight + val effectiveDeviceRotation = if ( + deviceRotation == DeviceRotation.Natural && + windowRotation.isLandscape && + shouldCounterRotate ) { - BottomSheetScaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState, - sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, - sheetDragHandle = { - BottomSheetDefaults.DragHandle( - modifier = Modifier - .testTag(QUICK_SETTINGS_DRAG_HANDLE) - .clickable( - role = Role.Button, - onClickLabel = dismissQuickSettingsLabel, - onClick = onDismissQuickSettings - ) - ) - }, - sheetShape = RoundedCornerShape( - topStart = CompactPortraitTokens.SheetTopCornerRadius, - topEnd = CompactPortraitTokens.SheetTopCornerRadius - ), - sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - quickSettingsOverlay(Modifier) - } - }, - snackbarHost = {} + windowRotation + } else { + deviceRotation + } + val windowGravityDeg = effectiveDeviceRotation.toGravityRotationDegrees(windowRotation) + val effectiveWindowRotation = when { + !shouldCounterRotate -> DeviceRotation.Natural + windowGravityDeg == 90 -> DeviceRotation.Rotated90 + windowGravityDeg == 270 -> DeviceRotation.Rotated270 + windowRotation.isLandscape -> windowRotation + else -> DeviceRotation.Rotated270 + } + val portraitWidth = if (shouldCounterRotate) windowHeight else windowWidth + val portraitHeight = if (shouldCounterRotate) windowWidth else windowHeight + val containerCounterDeg = effectiveWindowRotation.toWindowCounterRotationDegrees().toInt() + val containerFrameRotation = DeviceRotation.snapFrom( + ((windowRotation.toClockwiseRotationDegrees() - containerCounterDeg) % 360 + 360) % 360 + ) + val gravityRotationState = rememberGravityRotationState( + deviceRotation = effectiveDeviceRotation, + windowRotation = containerFrameRotation + ) + + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalDeviceRotation provides effectiveDeviceRotation, + LocalWindowRotation provides effectiveWindowRotation, + LocalGravityRotationState provides gravityRotationState + ) { + Box( + modifier = Modifier + .fillMaxSize() + .counterRotateToPortrait(effectiveWindowRotation) ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - val windowAspectRatio = maxHeight.value / maxWidth.value - val isHandheldPortrait = - windowAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD - if (isHandheldPortrait) { - AdaptivePortraitCaptureLayout( - windowWidth = maxWidth, - windowHeight = maxHeight, - includeStatusBarsInTopBar = includeStatusBarsInTopBar, - isSheetVisible = isSheetVisible, - isModalOpen = isModalOpen, - viewfinder = viewfinder, - topStartContent = topStartContent, - viewfinderTopEndContent = viewfinderTopEndContent, - onRequiresImmersiveChange = onRequiresImmersiveChange, - indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, - zoomControls = zoomLevelDisplay, - imageWell = imageWell, - captureButton = captureButton, - flipCameraButton = flipCameraButton, - captureModeCarousel = captureModeCarousel, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - debugVisibilityWrapper = debugVisibilityWrapper, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } - ) - Box( + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, + sheetDragHandle = { + BottomSheetDefaults.DragHandle( modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + .testTag(QUICK_SETTINGS_DRAG_HANDLE) + .clickable( + role = Role.Button, + onClickLabel = dismissQuickSettingsLabel, + onClick = onDismissQuickSettings ) - ) { - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection - ) { - snackBar(Modifier, scaffoldState.snackbarHostState) - } + ) + }, + sheetShape = RoundedCornerShape( + topStart = CompactPortraitTokens.SheetTopCornerRadius, + topEnd = CompactPortraitTokens.SheetTopCornerRadius + ), + sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + sheetContent = { + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + quickSettingsOverlay(Modifier) } - } else { - Column { - val minTouchTarget = LocalMinimumInteractiveComponentSize.current - .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } - val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + }, + snackbarHost = {} + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + val windowAspectRatio = portraitHeight.value / portraitWidth.value + val isHandheldPortrait = + windowAspectRatio >= + CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + if (isHandheldPortrait) { + AdaptivePortraitCaptureLayout( + windowWidth = portraitWidth, + windowHeight = portraitHeight, + windowRotation = effectiveWindowRotation, includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget - ) - CompactTopBarBox( - topBarHeight = topBarHeight, - topBarTopPadding = topBarTopPadding, - modifier = Modifier.suppressBackgroundForModal(isModalOpen), + isSheetVisible = isSheetVisible, + isModalOpen = isModalOpen, + viewfinder = viewfinder, + topStartContent = topStartContent, + viewfinderTopEndContent = viewfinderTopEndContent, + onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent - ) - Box { - Box(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) { - viewfinder(Modifier) + topBarTrailingContent = topBarTrailingContent, + elapsedTimeDisplay = elapsedTimeDisplay, + zoomControls = zoomLevelDisplay, + imageWell = imageWell, + captureButton = captureButton, + flipCameraButton = flipCameraButton, + captureModeCarousel = captureModeCarousel, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + debugVisibilityWrapper = debugVisibilityWrapper, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) } - OverlapAwareStyleProvider( - Modifier - .align(Alignment.TopStart) - .suppressBackgroundForModal(isSheetVisible) - ) { - topStartContent(Modifier) - } - OverlapAwareStyleProvider( - Modifier - .align(Alignment.TopEnd) - .suppressBackgroundForModal(isModalOpen) + ) + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) + ) { + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection ) { - viewfinderTopEndContent(Modifier) + snackBar(Modifier, scaffoldState.snackbarHostState) } } - } - - Box( - modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + } else { + Column { + val minTouchTarget = LocalMinimumInteractiveComponentSize.current + .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } + val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + includeStatusBarsInTopBar = includeStatusBarsInTopBar, + minTouchTarget = minTouchTarget, + windowRotation = effectiveWindowRotation ) - ) { - debugVisibilityWrapper { - VerticalMaterialControls( - captureButton = captureButton, - imageWell = imageWell, - flipCameraButton = flipCameraButton, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - captureModeCarousel = captureModeCarousel, - zoomControls = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + CompactTopBarBox( + topBarHeight = topBarHeight, + topBarTopPadding = topBarTopPadding, + windowRotation = effectiveWindowRotation, + modifier = Modifier.suppressBackgroundForModal(isModalOpen), + indicatorRow = indicatorRow, + topBarTrailingContent = topBarTrailingContent ) + Box { + Box( + modifier = Modifier.suppressBackgroundForModal(isModalOpen) + ) { + viewfinder(Modifier) + } + OverlapAwareStyleProvider( + Modifier + .align(Alignment.TopStart) + .suppressBackgroundForModal(isSheetVisible) + ) { + topStartContent(Modifier) + } + OverlapAwareStyleProvider( + Modifier + .align(Alignment.TopEnd) + .suppressBackgroundForModal(isModalOpen) + ) { + viewfinderTopEndContent(Modifier) + } + } } - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection + + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) ) { - snackBar(Modifier, scaffoldState.snackbarHostState) + debugVisibilityWrapper { + VerticalMaterialControls( + windowRotation = effectiveWindowRotation, + captureButton = captureButton, + imageWell = imageWell, + flipCameraButton = flipCameraButton, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + captureModeCarousel = captureModeCarousel, + zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) + } + ) + } + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + snackBar(Modifier, scaffoldState.snackbarHostState) + } } } - } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - debugOverlay(Modifier) - } + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + debugOverlay(Modifier) + } - if (isSheetVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .testTag(QUICK_SETTINGS_SCRIM) - .clickable( - onClickLabel = dismissQuickSettingsLabel, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismissQuickSettings - ) - ) - } + if (isSheetVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(QUICK_SETTINGS_SCRIM) + .clickable( + onClickLabel = dismissQuickSettingsLabel, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissQuickSettings + ) + ) + } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - errorDialog(Modifier) + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + errorDialog(Modifier) + } } } } - // Drawn last and outside of both the inset-padded controls and the bottom sheet so the - // flash covers the entire window, including system bar and display cutout areas. - screenFlashOverlay(Modifier) } + // Drawn last and outside of both the counter-rotated controls and the bottom sheet so the + // flash covers the entire window, including system bar and display cutout areas. + screenFlashOverlay(Modifier) } } @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, - minTouchTarget: Dp + minTouchTarget: Dp, + windowRotation: DeviceRotation = LocalWindowRotation.current ): Pair { - val cutoutTopInset = WindowInsets.displayCutout.asPaddingValues().calculateTopPadding() + val portraitCutoutInsets = WindowInsets.displayCutout.toPortraitWindowInsets(windowRotation) + val cutoutTopInset = portraitCutoutInsets.asPaddingValues().calculateTopPadding() return if (includeStatusBarsInTopBar) { - val statusBarTopInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val combinedTopInset = WindowInsets.statusBars - .union(WindowInsets.displayCutout) + val portraitStatusBars = WindowInsets.statusBars.toPortraitWindowInsets(windowRotation) + val statusBarTopInset = portraitStatusBars.asPaddingValues().calculateTopPadding() + val combinedTopInset = portraitStatusBars + .union(portraitCutoutInsets) .asPaddingValues() .calculateTopPadding() max(combinedTopInset, statusBarTopInset + minTouchTarget) to statusBarTopInset @@ -670,7 +737,8 @@ private fun CompactTopBarBox( topBarTopPadding: Dp, indicatorRow: @Composable (Modifier) -> Unit, topBarTrailingContent: @Composable (Modifier) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { val localeDirection = LocalLayoutDirection.current CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { @@ -680,7 +748,9 @@ private fun CompactTopBarBox( .height(topBarHeight) .padding(top = topBarTopPadding) .windowInsetsPadding( - WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal) + WindowInsets.displayCutout + .toPortraitWindowInsets(windowRotation) + .only(WindowInsetsSides.Horizontal) ) .padding(horizontal = CompactPortraitTokens.LowerSectionHorizontalPadding), contentAlignment = Alignment.CenterStart @@ -815,6 +885,7 @@ internal fun AdaptivePortraitCaptureLayout( quickSettingsToggleButton: @Composable (Modifier) -> Unit, captureModeToggleSwitch: @Composable (Modifier) -> Unit, debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit, + windowRotation: DeviceRotation = LocalWindowRotation.current, includeStatusBarsInTopBar: Boolean = true, isSheetVisible: Boolean = false, isModalOpen: Boolean = false, @@ -829,9 +900,11 @@ internal fun AdaptivePortraitCaptureLayout( } val navInset = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val rawGestureInset = WindowInsets.systemGestures + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset) @@ -839,7 +912,8 @@ internal fun AdaptivePortraitCaptureLayout( .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget + minTouchTarget = minTouchTarget, + windowRotation = windowRotation ) val window = remember( @@ -908,6 +982,7 @@ internal fun AdaptivePortraitCaptureLayout( CompactTopBarBox( topBarHeight = topBarHeight, topBarTopPadding = topBarTopPadding, + windowRotation = windowRotation, modifier = Modifier .layoutId(CaptureSlotId.TopBar) .suppressBackgroundForModal(isModalOpen), @@ -1099,6 +1174,7 @@ internal fun AdaptivePortraitCaptureLayout( @Composable internal fun VerticalMaterialControls( modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current, captureButton: @Composable (Modifier) -> Unit, zoomControls: @Composable (Modifier) -> Unit, imageWell: @Composable (Modifier) -> Unit, @@ -1122,6 +1198,7 @@ internal fun VerticalMaterialControls( contentAlignment = Alignment.BottomCenter ) { val navBarBottom = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val isShortScreen = maxHeight < CompactPortraitTokens.ShortScreenThreshold diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt index 44864e0fc9..c3d9356048 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import com.google.jetpackcamera.core.common.ignoreResult +import com.google.jetpackcamera.model.DeviceRotation import kotlin.math.max import kotlin.math.roundToInt @@ -82,7 +83,8 @@ import kotlin.math.roundToInt * @param verticalAlignment vertical alignment of children within the row. * @param cutoutRectsOverride optional list of cutout bounding rectangles in the row's local * coordinate space, used for deterministic testing. When `null`, cutout rects are queried from - * the host window's [android.view.DisplayCutout] and mapped into the row's local coordinates. + * the host window's [androidx.core.view.DisplayCutoutCompat] and mapped into the row's local + * coordinates. * @param onPillSegmentsPlaced optional callback invoked with the physical local [IntRect] bounds of * each active pill segment after layout, used for testing. * @param content the row children, scoped to [RowScope]. @@ -102,6 +104,7 @@ fun CutoutAwareRow( content: @Composable RowScope.() -> Unit ) { val view = LocalView.current + val windowRotation = LocalWindowRotation.current val cutoutInsets = WindowInsets.displayCutout var rowBoundsInWindow by remember { mutableStateOf(null) } val lockedMinX = remember { mutableMapOf() } @@ -145,8 +148,10 @@ fun CutoutAwareRow( } } ) { measurables, constraints -> - // Read WindowInsets.displayCutout inside measure to subscribe to inset updates. + // Read WindowInsets.displayCutout inside measure to subscribe to inset updates across + // all physical edges when counter-rotated. cutoutInsets.getTop(this).ignoreResult() + cutoutInsets.getBottom(this).ignoreResult() cutoutInsets.getLeft(this, layoutDirection).ignoreResult() cutoutInsets.getRight(this, layoutDirection).ignoreResult() @@ -187,7 +192,10 @@ fun CutoutAwareRow( ?.displayCutout?.boundingRects.orEmpty(), rowBoundsInWindow = rowBoundsInWindow, isRtl = isRtl, - rtlReferenceWidth = rtlReferenceWidth + rtlReferenceWidth = rtlReferenceWidth, + windowRotation = windowRotation, + fallbackViewWidth = view.width, + fallbackViewHeight = view.height ) // Convert vertically overlapping cutout rects into sorted horizontal keep-out ranges. @@ -356,23 +364,61 @@ internal fun resolveLocalCutoutRects( windowCutoutRects: List, rowBoundsInWindow: IntRect?, isRtl: Boolean, - rtlReferenceWidth: Int + rtlReferenceWidth: Int, + windowRotation: DeviceRotation = DeviceRotation.Natural, + fallbackViewWidth: Int = 0, + fallbackViewHeight: Int = 0 ): List { val ltrRects = cutoutRectsOverride ?: run { - val offsetX = rowBoundsInWindow?.left ?: 0 - val offsetY = rowBoundsInWindow?.top ?: 0 + val bounds = rowBoundsInWindow windowCutoutRects.map { rect -> - IntRect( - left = rect.left - offsetX, - top = rect.top - offsetY, - right = rect.right - offsetX, - bottom = rect.bottom - offsetY - ) + when (windowRotation) { + DeviceRotation.Natural -> { + val offsetX = bounds?.left ?: 0 + val offsetY = bounds?.top ?: 0 + IntRect( + left = rect.left - offsetX, + top = rect.top - offsetY, + right = rect.right - offsetX, + bottom = rect.bottom - offsetY + ) + } + DeviceRotation.Rotated270 -> { + val refBottom = bounds?.bottom ?: fallbackViewHeight + val refLeft = bounds?.left ?: 0 + IntRect( + left = refBottom - rect.bottom, + top = rect.left - refLeft, + right = refBottom - rect.top, + bottom = rect.right - refLeft + ) + } + DeviceRotation.Rotated90 -> { + val refTop = bounds?.top ?: 0 + val refRight = bounds?.right ?: fallbackViewWidth + IntRect( + left = rect.top - refTop, + top = refRight - rect.right, + right = rect.bottom - refTop, + bottom = refRight - rect.left + ) + } + DeviceRotation.Rotated180 -> { + val refRight = bounds?.right ?: fallbackViewWidth + val refBottom = bounds?.bottom ?: fallbackViewHeight + IntRect( + left = refRight - rect.right, + top = refBottom - rect.bottom, + right = refRight - rect.left, + bottom = refBottom - rect.top + ) + } + } } } return if (isRtl) { val startEdgeX = if (cutoutRectsOverride == null && rowBoundsInWindow != null) { - rowBoundsInWindow.width + if (windowRotation.isLandscape) rowBoundsInWindow.height else rowBoundsInWindow.width } else { rtlReferenceWidth } 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 5e13865c29..23262062c8 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 @@ -15,16 +15,24 @@ */ package com.google.jetpackcamera.ui.components.capture +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.hardware.display.DisplayManager +import android.os.Build import android.view.Display import android.view.Surface import android.view.View +import android.view.WindowManager +import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.State @@ -42,6 +50,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleStartEffect import com.google.jetpackcamera.model.DeviceRotation @@ -216,3 +226,157 @@ fun Modifier.rotateLayoutToGravity(rotationDegrees: Int): Modifier = } } } + +/** + * Counter-rotates a compact portrait container back to the device's canonical portrait (`0°`) + * coordinate frame when [windowRotation] is landscape ([DeviceRotation.Rotated90] or + * [DeviceRotation.Rotated270]). + * + * When [windowRotation] is portrait ([DeviceRotation.Natural] or [DeviceRotation.Rotated180]), + * measures with [Constraints] unchanged and places at `(0, 0)` with zero layer rotation. + * + * @param windowRotation Active OS window [DeviceRotation] to counter-rotate from. + */ +fun Modifier.counterRotateToPortrait(windowRotation: DeviceRotation): Modifier = + layout { measurable, constraints -> + if (!windowRotation.isLandscape) { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } else { + val swappedMaxWidth = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + Constraints.Infinity + } + val swappedMaxHeight = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + Constraints.Infinity + } + val childConstraints = Constraints( + minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), + maxWidth = swappedMaxWidth, + minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), + maxHeight = swappedMaxHeight + ) + val placeable = measurable.measure(childConstraints) + val layoutWidth = placeable.height + val layoutHeight = placeable.width + layout(layoutWidth, layoutHeight) { + val offsetX = (layoutWidth - placeable.width) / 2 + val offsetY = (layoutHeight - placeable.height) / 2 + placeable.placeWithLayer(offsetX, offsetY) { + rotationZ = windowRotation.toWindowCounterRotationDegrees() + } + } + } + } + +/** + * [WindowInsets] wrapper that remaps physical window edges from the host OS window's + * [windowRotation] coordinate frame into the canonical portrait (`0°`) coordinate frame used inside + * [counterRotateToPortrait]. + * + * @property baseInsets Underlying host [WindowInsets] in the OS window's coordinate frame. + * @property windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated + * container. + */ +@Immutable +class PortraitWindowInsets( + private val baseInsets: WindowInsets, + private val windowRotation: DeviceRotation +) : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int = + when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated270 -> baseInsets.getBottom(density) + DeviceRotation.Rotated90 -> baseInsets.getTop(density) + DeviceRotation.Rotated180 -> baseInsets.getRight(density, LayoutDirection.Ltr) + } + + override fun getTop(density: Density): Int = when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getTop(density) + DeviceRotation.Rotated270 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated90 -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated180 -> baseInsets.getBottom(density) + } + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int = + when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated270 -> baseInsets.getTop(density) + DeviceRotation.Rotated90 -> baseInsets.getBottom(density) + DeviceRotation.Rotated180 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + } + + override fun getBottom(density: Density): Int = when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getBottom(density) + DeviceRotation.Rotated270 -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated90 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated180 -> baseInsets.getTop(density) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PortraitWindowInsets) return false + return baseInsets == other.baseInsets && windowRotation == other.windowRotation + } + + override fun hashCode(): Int { + var result = baseInsets.hashCode() + result = 31 * result + windowRotation.hashCode() + return result + } + + override fun toString(): String = + "PortraitWindowInsets(baseInsets=$baseInsets, windowRotation=$windowRotation)" +} + +/** + * Remaps this [WindowInsets] instance from the host OS window's [windowRotation] coordinate frame + * into the canonical portrait (`0°`) coordinate frame. + * + * @param windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated + * container. + * @return A [PortraitWindowInsets] remapping all four edges into portrait coordinates. + */ +fun WindowInsets.toPortraitWindowInsets(windowRotation: DeviceRotation): WindowInsets = + PortraitWindowInsets(this, windowRotation) + +/** + * Requests [WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS] on the host [Activity] window + * on API 26+ while [enabled] is `true`, restoring the previous window rotation animation on + * disposal so counter-rotated compact camera chrome transitions across OS window rotations without + * a crossfade jump. + */ +@Composable +internal fun SeamlessRotationAnimationEffect(enabled: Boolean = true) { + val context = LocalContext.current + val activity = LocalActivity.current ?: remember(context) { context.findActivity() } + DisposableEffect(activity, enabled) { + val window = activity?.window + if (enabled && window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val previousAnimation = window.attributes.rotationAnimation + if (previousAnimation != WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS) { + window.attributes = window.attributes.apply { + rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS + } + } + onDispose { + window.attributes = window.attributes.apply { + rotationAnimation = previousAnimation + } + } + } else { + onDispose {} + } + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} 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 new file mode 100644 index 0000000000..6386cf37eb --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt @@ -0,0 +1,492 @@ +/* + * 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.ui.components.capture + +import android.graphics.Rect as AndroidRect +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Density +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 com.google.common.truth.Truth.assertThat +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private object LayoutRotationTestTokens { + const val INDICATOR_TAG = "RotationTestIndicatorRow" + + val CaptureButtonSize = 80.dp + val ControlSize = 48.dp + val ZoomBarWidth = 160.dp + val IndicatorWidth = 120.dp + val IndicatorHeight = 36.dp + val ModeToggleWidth = 96.dp +} + +/** + * Robolectric Compose UI tests verifying layout-level counter-rotation scoping across full-screen + * compact handhelds, portrait split-screen panes on rotated phones, side-rail layouts, and + * [CutoutAwareRow] cutout remapping. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(RobolectricTestRunner::class) +class LayoutRotationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Composable + private fun RotationPreviewUnderTest( + deviceRotation: DeviceRotation, + windowRotation: DeviceRotation, + modifier: Modifier = Modifier, + slots: CaptureSlots? = null, + onViewfinderObserved: ( + mode: CameraLayoutMode, + effectiveWindowRotation: DeviceRotation, + gravityRotationDegrees: Float, + solution: CameraLayoutSolution?, + viewfinderSize: IntSize + ) -> Unit = { _, _, _, _, _ -> } + ) { + MaterialTheme { + CompositionLocalProvider(LocalDisableAnimations provides true) { + PreviewLayout( + modifier = modifier, + deviceRotation = deviceRotation, + windowRotation = windowRotation, + slots = slots, + viewfinder = { vfModifier -> + val mode = LocalCameraLayoutMode.current + val effectiveWinRot = LocalWindowRotation.current + val gravityDeg = LocalGravityRotationState.current.value + val solution = LocalCameraLayoutSolution.current + Box( + modifier = vfModifier + .fillMaxSize() + .onSizeChanged { vfSize -> + onViewfinderObserved( + mode, + effectiveWinRot, + gravityDeg, + solution, + vfSize + ) + } + .testTag(PREVIEW_DISPLAY) + ) + }, + captureButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.CaptureButtonSize) + .testTag(CAPTURE_BUTTON) + ) + }, + imageWell = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(IMAGE_WELL_TAG) + ) + }, + flipCameraButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(FLIP_CAMERA_BUTTON) + ) + }, + zoomLevelDisplay = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.ZoomBarWidth, + height = LayoutRotationTestTokens.ControlSize + ) + .testTag(ZOOM_BUTTON_ROW_TAG) + ) + }, + elapsedTimeDisplay = {}, + quickSettingsButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(QUICK_SETTINGS_DROP_DOWN) + ) + }, + indicatorRow = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.IndicatorWidth, + height = LayoutRotationTestTokens.IndicatorHeight + ) + .testTag(LayoutRotationTestTokens.INDICATOR_TAG) + ) + }, + captureModeToggle = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.ModeToggleWidth, + height = LayoutRotationTestTokens.ControlSize + ) + .testTag(CAPTURE_MODE_TOGGLE_BUTTON) + ) + }, + quickSettingsOverlay = {}, + debugOverlay = {}, + debugVisibilityWrapper = { content -> content() }, + screenFlashOverlay = {}, + snackBar = { _, _ -> } + ) + } + } + } + + @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi") + @Test + fun compactPortraitLayout_fullScreenLandscapePhone_counterRotatesToPortrait() { + var windowRotation by mutableStateOf(DeviceRotation.Rotated90) + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + deviceRotation = windowRotation, + windowRotation = windowRotation, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // Rotated90 (+90° CW counter-rotation): solves portrait 360x740dp, top bar on right edge, + // shutter button on left side of the 740x360dp window. + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated90) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) + // Inside +90° container, gravity compensation is -90° (270°) so icons net to 0° in window. + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f) + + val captureBand90 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterX90 = + 740f - (captureBand90.top.value + captureBand90.bottom.value) / 2f + val indicatorRotated90 = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureRotated90 = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorRotated90.left).isGreaterThan(680f) + assertThat(captureRotated90.center.x).isWithin(1f).of(expectedCaptureCenterX90) + assertThat(captureRotated90.center.y).isWithin(1f).of(180f) + + // Rotated270 (-90° CCW counter-rotation): solves portrait 360x740dp, top bar on left edge, + // shutter button on right side of the 740x360dp window. + windowRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + + val captureBand270 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterX270 = + (captureBand270.top.value + captureBand270.bottom.value) / 2f + val indicatorRotated270 = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureRotated270 = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorRotated270.right).isLessThan(60f) + assertThat(captureRotated270.center.x).isWithin(1f).of(expectedCaptureCenterX270) + assertThat(captureRotated270.center.y).isWithin(1f).of(180f) + } + + @Config(qualifiers = "sw360dp-w800dp-h800dp-mdpi") + @Test + fun compactPortraitLayout_portraitSplitScreenOnRotatedPhone_doesNotCounterRotate() { + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(width = 380.dp, height = 760.dp), + deviceRotation = DeviceRotation.Rotated90, + windowRotation = DeviceRotation.Rotated90, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // 380x760dp split-screen pane has windowWidth <= windowHeight, so it stays upright (0°) + // without swapping constraints even though the host display is at Rotated90. + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Natural) + assertThat(observedGravityDeg).isEqualTo(0f) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(380, 760)) + + val captureBand = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterY = (captureBand.top.value + captureBand.bottom.value) / 2f + val indicatorBounds = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureBounds = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorBounds.top).isLessThan(60f) + assertThat(captureBounds.center.x).isWithin(1f).of(190f) + assertThat(captureBounds.center.y).isWithin(1f).of(expectedCaptureCenterY) + assertThat(captureBounds.top).isGreaterThan(indicatorBounds.bottom) + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun compactHorizontalSplitScreen_uprightWindow_rotatesCompactLayout90DegreesToFillSpace() { + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // 800x400dp horizontal split-screen resolves to COMPACT_HORIZONTAL, rotating + // CompactPortraitLayout -90° CCW (Rotated270) so it solves 400x800dp horizontally and + // rotates interior icons +90° CW to remain 0° upright in the Natural window! + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(400, 800)) + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun twoSideRailLayout_andCompactShortLayout_neverCounterRotate() { + var containerSize by mutableStateOf(DpSize(1280.dp, 800.dp)) + var windowRotation by mutableStateOf(DeviceRotation.Rotated90) + var observedMode: CameraLayoutMode? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(containerSize), + deviceRotation = windowRotation, + windowRotation = windowRotation, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true), + onViewfinderObserved = { mode, _, _, _, vfSize -> + observedMode = mode + observedViewfinderSize = vfSize + } + ) + } + + val testPostures = listOf( + Triple(DpSize(1280.dp, 800.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(1280, 800)), + Triple(DpSize(883.dp, 852.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(883, 852)), + Triple(DpSize(440.dp, 412.dp), CameraLayoutMode.COMPACT_SHORT, IntSize(440, 412)) + ) + + for ((size, expectedMode, expectedVfSize) in testPostures) { + for (rotation in listOf(DeviceRotation.Rotated90, DeviceRotation.Rotated270)) { + containerSize = size + windowRotation = rotation + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(expectedMode) + assertThat(observedViewfinderSize).isEqualTo(expectedVfSize) + + composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertIsDisplayed() + + val startRailBounds = composeTestRule + .onNodeWithTag(START_SIDE_RAIL_TAG) + .fetchSemanticsNode() + .boundsInRoot + val endRailBounds = composeTestRule + .onNodeWithTag(END_SIDE_RAIL_TAG) + .fetchSemanticsNode() + .boundsInRoot + + // Side rails remain upright on left and right edges (never counter-rotated 90°). + assertThat(startRailBounds.left).isLessThan(endRailBounds.left) + assertThat(startRailBounds.height).isGreaterThan(startRailBounds.width) + assertThat(endRailBounds.height).isGreaterThan(endRailBounds.width) + } + } + } + + @Test + fun cutoutAwareRow_remapsCutoutsUnderRotated90AndRotated270() { + // Portrait top bar (width=360, height=48) counter-rotated inside a 740x360 landscape window: + // - Under Rotated90 (+90° CW): rowBoundsInWindow is [692, 0, 740, 360]. + // Window right-edge cutout [692, 20, 740, 60] maps to portrait [20, 0, 60, 48] in LTR, + // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). + val rotated90RowBounds = IntRect(left = 692, top = 0, right = 740, bottom = 360) + val rotated90WindowCutout = listOf(AndroidRect(692, 20, 740, 60)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated90WindowCutout, + rowBoundsInWindow = rotated90RowBounds, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated90, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated90WindowCutout, + rowBoundsInWindow = rotated90RowBounds, + isRtl = true, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated90, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) + + // - Under Rotated270 (-90° CCW): rowBoundsInWindow is [0, 0, 48, 360]. + // Window left-edge cutout [0, 300, 48, 340] maps to portrait [20, 0, 60, 48] in LTR, + // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). + val rotated270RowBounds = IntRect(left = 0, top = 0, right = 48, bottom = 360) + val rotated270WindowCutout = listOf(AndroidRect(0, 300, 48, 340)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated270WindowCutout, + rowBoundsInWindow = rotated270RowBounds, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated270, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated270WindowCutout, + rowBoundsInWindow = rotated270RowBounds, + isRtl = true, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated270, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) + + // Also verify CutoutAwareRow composes cleanly when LocalWindowRotation is landscape. + var placedSegments = emptyList() + composeTestRule.setContent { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalWindowRotation provides DeviceRotation.Rotated90 + ) { + CutoutAwareRow( + pillHorizontalPadding = 8.dp, + pillMinHeight = 32.dp, + cutoutRectsOverride = listOf(IntRect(20, 0, 60, 48)), + onPillSegmentsPlaced = { placedSegments = it } + ) { + Box(Modifier.size(24.dp)) + } + } + } + composeTestRule.waitForIdle() + assertThat(placedSegments).hasSize(1) + // Item + 2*8dp pill padding = 40px, shifted past cutout [20..60] + 8px clearance = 68px. + assertThat(placedSegments.single().left).isAtLeast(68) + } +} diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index 658896dc9e..ee45984cba 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -16,17 +16,27 @@ package com.google.jetpackcamera.ui.components.capture import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth 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.geometry.Rect import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.model.DeviceRotation @@ -34,8 +44,10 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1000dp-h1000dp-mdpi") class RotationModifiersTest { @get:Rule @@ -108,4 +120,134 @@ class RotationModifiersTest { composeTestRule.waitForIdle() assertThat(outerSize).isEqualTo(IntSize(60, 200)) } + + @Test + fun counterRotateToPortrait_swapsConstraintsAndRotatesForLandscape_andIsNoOpForPortrait() { + val topStripTag = "CounterRotateTopStrip" + var windowRotation by mutableStateOf(DeviceRotation.Natural) + var outerSize = IntSize.Zero + var innerMeasuredSize = IntSize.Zero + + composeTestRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + Box( + modifier = Modifier + .size(width = 740.dp, height = 360.dp) + .onSizeChanged { outerSize = it } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .counterRotateToPortrait(windowRotation) + .onSizeChanged { innerMeasuredSize = it } + ) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .height(48.dp) + .testTag(topStripTag) + ) + // Also exercise unbounded width constraint swapping without crashing. + Box( + modifier = Modifier + .wrapContentWidth(unbounded = true) + .counterRotateToPortrait(windowRotation) + .size(width = 24.dp, height = 12.dp) + ) + } + } + } + } + + // Natural: no constraint swap, placed at (0, 0) with 0° rotation. + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 740f, 48f)) + + // Rotated180: still portrait (!isLandscape), complete no-op with 0° rotation. + windowRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 740f, 48f)) + + // Rotated90: swaps constraints to 360x740 and counter-rotates +90° CW -> top strip on right + windowRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(692f, 0f, 740f, 360f)) + + // Rotated270: swaps constraints to 360x740 and counter-rotates -90° CCW -> top strip on left + windowRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 48f, 360f)) + } + + @Test + fun toPortraitWindowInsets_remapsAllEdges_acrossAllRotationsInLtrAndRtl() { + val density = Density(1f) + val observedDirections = mutableListOf() + val baseInsets = object : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int { + observedDirections.add(layoutDirection) + return 10 + } + + override fun getTop(density: Density): Int = 20 + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int { + observedDirections.add(layoutDirection) + return 30 + } + + override fun getBottom(density: Density): Int = 40 + } + + for (direction in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) { + observedDirections.clear() + val natural = baseInsets.toPortraitWindowInsets(DeviceRotation.Natural) + assertThat(natural.getLeft(density, direction)).isEqualTo(10) + assertThat(natural.getTop(density)).isEqualTo(20) + assertThat(natural.getRight(density, direction)).isEqualTo(30) + assertThat(natural.getBottom(density)).isEqualTo(40) + + val rotated270 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270) + assertThat(rotated270.getLeft(density, direction)).isEqualTo(40) + assertThat(rotated270.getTop(density)).isEqualTo(10) + assertThat(rotated270.getRight(density, direction)).isEqualTo(20) + assertThat(rotated270.getBottom(density)).isEqualTo(30) + + val rotated90 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + assertThat(rotated90.getLeft(density, direction)).isEqualTo(20) + assertThat(rotated90.getTop(density)).isEqualTo(30) + assertThat(rotated90.getRight(density, direction)).isEqualTo(40) + assertThat(rotated90.getBottom(density)).isEqualTo(10) + + val rotated180 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated180) + assertThat(rotated180.getLeft(density, direction)).isEqualTo(30) + assertThat(rotated180.getTop(density)).isEqualTo(40) + assertThat(rotated180.getRight(density, direction)).isEqualTo(10) + assertThat(rotated180.getBottom(density)).isEqualTo(20) + + // Physical window edges must always query baseInsets with LayoutDirection.Ltr. + assertThat(observedDirections).isNotEmpty() + assertThat(observedDirections.all { it == LayoutDirection.Ltr }).isTrue() + } + + // Verify value equality and hashCode contract on PortraitWindowInsets. + val first = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + val second = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + val different = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270) + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + assertThat(first).isNotEqualTo(different) + } } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index 9736a5c291..c7c0a0ef8f 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -338,10 +338,7 @@ internal fun DebugLayoutReadout( * points upward against physical gravity inside the current window. */ @Composable -private fun DebugUpwardTriangleIndicator( - rotationDegrees: Int, - modifier: Modifier = Modifier -) { +private fun DebugUpwardTriangleIndicator(rotationDegrees: Int, modifier: Modifier = Modifier) { val targetAngle = rotationDegrees.toFloat() var cumulativeTargetDegrees by remember { mutableFloatStateOf(targetAngle) } val currentNormalized = ((cumulativeTargetDegrees % 360f) + 360f) % 360f diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index c36f65c37c..a4284558d1 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -350,6 +350,22 @@ class DebugLayoutReadoutTest { .isEqualTo(DebugFormFactor.UNKNOWN) } + @Test + fun debugLayoutReadout_whenCameraLayoutModeProvided_displaysLayoutModeRow() { + composeTestRule.setContent { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 800, + heightDp = 400, + cameraLayoutMode = "COMPACT_HORIZONTAL" + ) + ) + } + composeTestRule.waitForIdle() + + assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("COMPACT_HORIZONTAL") + } + private fun readNodeText(tag: String): String = composeTestRule .onNodeWithTag(tag) .fetchSemanticsNode() From 8784031e552fb834bc465cf21e2cfa740c6be25b Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Sat, 7 Nov 2026 06:34:42 -0800 Subject: [PATCH 10/17] feat(ui/debug): align DebugFormFactor with 6-mode CameraLayoutMode geometry --- .../jetpackcamera/model/DeviceRotation.kt | 6 +- .../feature/preview/PreviewScreen.kt | 21 +- .../ui/debug/DebugOverlayComponents.kt | 204 +++++++++++++----- ui/debug/src/main/res/values/strings.xml | 15 +- .../ui/debug/DebugLayoutReadoutTest.kt | 114 ++++++---- 5 files changed, 251 insertions(+), 109 deletions(-) diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index 35b477893f..942cab01a0 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -58,8 +58,10 @@ enum class DeviceRotation { */ fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int { val rawDelta = - ((windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % - 360 + 360) % 360 + ( + (windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % + 360 + 360 + ) % 360 return if (rawDelta == 180) 0 else rawDelta } 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 105f82d6da..fe101f9e2b 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 @@ -118,6 +118,7 @@ import com.google.jetpackcamera.ui.debug.DebugController 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.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState @@ -745,15 +746,21 @@ internal fun ContentScreen( @Composable { modifier: Modifier, extraControls: Array<@Composable () -> Unit>? -> if (debugUiState is DebugUiState.Enabled) { debugController?.let { debugController -> - val layoutReadoutState = with(LocalDensity.current) { + val (windowWidthDp, windowHeightDp) = with(LocalDensity.current) { val containerSize = LocalWindowInfo.current.containerSize - DebugLayoutReadoutState( - widthDp = containerSize.width.toDp().value.roundToInt(), - heightDp = containerSize.height.toDp().value.roundToInt(), - deviceRotation = currentDeviceRotation.value, - windowRotation = currentWindowRotation.value - ) + containerSize.width.toDp().value.roundToInt() to + containerSize.height.toDp().value.roundToInt() } + val layoutReadoutState = DebugLayoutReadoutState( + widthDp = windowWidthDp, + heightDp = windowHeightDp, + formFactor = rememberDebugFormFactor( + widthDp = windowWidthDp, + heightDp = windowHeightDp + ), + deviceRotation = currentDeviceRotation.value, + windowRotation = currentWindowRotation.value + ) DebugOverlay( modifier = modifier, debugUiState = debugUiState, diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index 77174dbf80..bfd05c8fcc 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -15,9 +15,14 @@ */ package com.google.jetpackcamera.ui.debug +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.util.Log import android.view.Surface +import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -54,6 +59,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -66,6 +72,8 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo @@ -79,8 +87,11 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.app.MultiWindowModeChangedInfo +import androidx.core.util.Consumer import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.TestPattern +import kotlin.math.abs import kotlin.math.roundToInt private const val TAG = "DebugOverlayComponents" @@ -123,43 +134,114 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float } /** - * Window form-factor bucket derived from available window container dimensions in `dp`. + * Window form-factor bucket derived from available window container dimensions in `dp`, matching + * the `360dp` / `480dp` / `600dp` adaptive capture layout mode classification. */ enum class DebugFormFactor { - COMPACT_SQUARE, + COMPACT_PORTRAIT, + COMPACT_HORIZONTAL, COMPACT_SLIM, - COMPACT, - FOLDABLE, - TABLET, + COMPACT_SHORT, + COMPACT_SHORT_SLIM, + TWO_SIDE_RAIL, UNKNOWN; companion object { - private const val COMPACT_MIN_WIDTH_DP = 360 - private const val MEDIUM_MIN_DIM_DP = 600 - private const val TABLET_MAX_DIM_DP = 960 - private const val COMPACT_SQUARE_MAX_RATIO = 1.5f - private const val FOLDABLE_MAX_RATIO = 1.45f + internal const val SLIM_BREAKPOINT_DP = 360 + internal const val SHORT_BREAKPOINT_DP = 480 + internal const val MEDIUM_WIDTH_BREAKPOINT_DP = 600 + internal const val MIN_VALID_SMALLEST_SCREEN_WIDTH_DP = 1 + internal const val FULL_SCREEN_DIMENSION_TOLERANCE_DP = 2 /** * Classifies a window container of [widthDp] by [heightDp] into a [DebugFormFactor] - * bucket regardless of portrait or landscape orientation. + * bucket matching `CameraLayoutMode`'s breakpoint rules. + * + * @param widthDp Available capture container width in `dp`. + * @param heightDp Available capture container height in `dp`. + * @param isFullScreenCompactPhone Whether the container occupies a full-screen compact + * phone window (`sw < 600dp`, not in multi-window mode, with long edge `>= 480dp`). */ - fun fromWindowDp(widthDp: Int, heightDp: Int): DebugFormFactor { + fun fromWindowDp( + widthDp: Int, + heightDp: Int, + isFullScreenCompactPhone: Boolean = false + ): DebugFormFactor { if (widthDp <= 0 || heightDp <= 0) return UNKNOWN val minDim = minOf(widthDp, heightDp) - val maxDim = maxOf(widthDp, heightDp) - val ratio = maxDim.toFloat() / minDim.toFloat() + if (isFullScreenCompactPhone) { + return when { + minDim < SLIM_BREAKPOINT_DP -> COMPACT_SLIM + widthDp > heightDp -> COMPACT_HORIZONTAL + else -> COMPACT_PORTRAIT + } + } return when { - maxDim < MEDIUM_MIN_DIM_DP && ratio < COMPACT_SQUARE_MAX_RATIO -> COMPACT_SQUARE - minDim < COMPACT_MIN_WIDTH_DP -> COMPACT_SLIM - minDim < MEDIUM_MIN_DIM_DP -> COMPACT - maxDim < TABLET_MAX_DIM_DP && ratio < FOLDABLE_MAX_RATIO -> FOLDABLE - else -> TABLET + widthDp >= MEDIUM_WIDTH_BREAKPOINT_DP && heightDp >= SHORT_BREAKPOINT_DP -> + TWO_SIDE_RAIL + widthDp < SHORT_BREAKPOINT_DP && heightDp < SHORT_BREAKPOINT_DP -> + if (minDim < SLIM_BREAKPOINT_DP) { + COMPACT_SHORT_SLIM + } else { + COMPACT_SHORT + } + minDim < SLIM_BREAKPOINT_DP -> COMPACT_SLIM + heightDp < SHORT_BREAKPOINT_DP -> COMPACT_HORIZONTAL + else -> COMPACT_PORTRAIT } } } } +/** + * Remembers the live [DebugFormFactor] for [widthDp] and [heightDp], factoring in whether the + * host window is a full-screen compact phone (`sw < 600dp` and not in multi-window mode). + */ +@Composable +fun rememberDebugFormFactor(widthDp: Int, heightDp: Int): DebugFormFactor { + val configuration = LocalConfiguration.current + val context = LocalContext.current + val activity = LocalActivity.current ?: remember(context) { context.findDebugActivity() } + + var isInMultiWindowMode by remember(activity) { + mutableStateOf(activity?.isInMultiWindowMode ?: false) + } + DisposableEffect(activity) { + isInMultiWindowMode = activity?.isInMultiWindowMode ?: false + val componentActivity = activity as? ComponentActivity + val listener = Consumer { info -> + isInMultiWindowMode = info.isInMultiWindowMode + } + componentActivity?.addOnMultiWindowModeChangedListener(listener) + onDispose { + componentActivity?.removeOnMultiWindowModeChangedListener(listener) + } + } + + val isFullScreenCompactPhone = + !(isInMultiWindowMode || activity?.isInMultiWindowMode == true) && + configuration.smallestScreenWidthDp in + DebugFormFactor.MIN_VALID_SMALLEST_SCREEN_WIDTH_DP until + DebugFormFactor.MEDIUM_WIDTH_BREAKPOINT_DP && + maxOf(widthDp, heightDp) >= DebugFormFactor.SHORT_BREAKPOINT_DP && + abs(widthDp - configuration.screenWidthDp) <= + DebugFormFactor.FULL_SCREEN_DIMENSION_TOLERANCE_DP && + abs(heightDp - configuration.screenHeightDp) <= + DebugFormFactor.FULL_SCREEN_DIMENSION_TOLERANCE_DP + + return DebugFormFactor.fromWindowDp( + widthDp = widthDp, + heightDp = heightDp, + isFullScreenCompactPhone = isFullScreenCompactPhone + ) +} + +private tailrec fun Context.findDebugActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findDebugActivity() + else -> null +} + /** * Snapshot of window container dimensions, derived form factor, and rotation signals displayed by * the debug console's layout readout. @@ -191,36 +273,41 @@ data class DebugLayoutReadoutState( /** * Clockwise rotation in degrees that the capture layout is expected to apply to align its * primary axis with the window's dimensions: - * - [DebugFormFactor.FOLDABLE], [DebugFormFactor.TABLET], and [DebugFormFactor.UNKNOWN] always - * remain upright (`0°`) in the window while their side rails adapt structurally. - * - [DebugFormFactor.COMPACT], [DebugFormFactor.COMPACT_SLIM], and - * [DebugFormFactor.COMPACT_SQUARE] preserve the `9:16` portrait layout at `0°` when - * `heightDp >= widthDp`, and rotate `90°` or `270°` when `widthDp > heightDp` so the `9:16` - * layout aligns with the window's horizontal length instead of shrinking to a square. + * - [DebugFormFactor.TWO_SIDE_RAIL], [DebugFormFactor.COMPACT_PORTRAIT], + * [DebugFormFactor.COMPACT_SHORT], [DebugFormFactor.COMPACT_SHORT_SLIM], and + * [DebugFormFactor.UNKNOWN] remain upright (`0°`) in the window. + * - [DebugFormFactor.COMPACT_HORIZONTAL] rotates `90°` or `270°` so the portrait layout aligns + * with the window's horizontal length. + * - [DebugFormFactor.COMPACT_SLIM] stays `0°` when `heightDp >= widthDp` and rotates `90°` or + * `270°` when `widthDp > heightDp`. */ val expectedLayoutRotationDegrees: Int get() = when (formFactor) { - DebugFormFactor.FOLDABLE, - DebugFormFactor.TABLET, + DebugFormFactor.TWO_SIDE_RAIL, + DebugFormFactor.COMPACT_PORTRAIT, + DebugFormFactor.COMPACT_SHORT, + DebugFormFactor.COMPACT_SHORT_SLIM, DebugFormFactor.UNKNOWN -> 0 - DebugFormFactor.COMPACT, - DebugFormFactor.COMPACT_SLIM, - DebugFormFactor.COMPACT_SQUARE -> + DebugFormFactor.COMPACT_HORIZONTAL -> resolveHorizontalLayoutRotationDegrees() + DebugFormFactor.COMPACT_SLIM -> if (heightDp >= widthDp) { 0 } else { - val gravity = iconRotationDegrees - val counter = - ((windowRotation.toWindowCounterRotationDegrees().toInt() % 360) + 360) % - 360 - when { - gravity == 90 || gravity == 270 -> gravity - counter == 90 || counter == 270 -> counter - else -> DEFAULT_WIDE_COMPACT_LAYOUT_ROTATION_DEGREES - } + resolveHorizontalLayoutRotationDegrees() } } + private fun resolveHorizontalLayoutRotationDegrees(): Int { + val gravity = iconRotationDegrees + val counter = + ((windowRotation.toWindowCounterRotationDegrees().toInt() % 360) + 360) % 360 + return when { + gravity == 90 || gravity == 270 -> gravity + counter == 90 || counter == 270 -> counter + else -> DEFAULT_WIDE_COMPACT_LAYOUT_ROTATION_DEGREES + } + } + companion object { private const val DEFAULT_WIDE_COMPACT_LAYOUT_ROTATION_DEGREES = 270 } @@ -242,28 +329,38 @@ internal fun DebugLayoutReadout( state: DebugLayoutReadoutState? = null, modifier: Modifier = Modifier ) { - val resolvedState = state ?: run { + val resolvedState = if (state != null) { + state + } else { val view = LocalView.current val liveWindowRotation = DeviceRotation.fromSurfaceRotation( runCatching { view.display?.rotation }.getOrNull() ?: Surface.ROTATION_0 ) - with(LocalDensity.current) { + val (liveWidthDp, liveHeightDp) = with(LocalDensity.current) { val containerSize = LocalWindowInfo.current.containerSize - DebugLayoutReadoutState( - widthDp = containerSize.width.toDp().value.roundToInt(), - heightDp = containerSize.height.toDp().value.roundToInt(), - deviceRotation = liveWindowRotation, - windowRotation = liveWindowRotation - ) + containerSize.width.toDp().value.roundToInt() to + containerSize.height.toDp().value.roundToInt() } + val liveFormFactor = rememberDebugFormFactor( + widthDp = liveWidthDp, + heightDp = liveHeightDp + ) + DebugLayoutReadoutState( + widthDp = liveWidthDp, + heightDp = liveHeightDp, + formFactor = liveFormFactor, + deviceRotation = liveWindowRotation, + windowRotation = liveWindowRotation + ) } val formFactorText = stringResource( when (resolvedState.formFactor) { - DebugFormFactor.COMPACT_SQUARE -> R.string.debug_text_form_factor_compact_square + DebugFormFactor.COMPACT_PORTRAIT -> R.string.debug_text_form_factor_compact_portrait + DebugFormFactor.COMPACT_HORIZONTAL -> R.string.debug_text_form_factor_compact_horizontal DebugFormFactor.COMPACT_SLIM -> R.string.debug_text_form_factor_compact_slim - DebugFormFactor.COMPACT -> R.string.debug_text_form_factor_compact - DebugFormFactor.FOLDABLE -> R.string.debug_text_form_factor_foldable - DebugFormFactor.TABLET -> R.string.debug_text_form_factor_tablet + DebugFormFactor.COMPACT_SHORT -> R.string.debug_text_form_factor_compact_short + DebugFormFactor.COMPACT_SHORT_SLIM -> R.string.debug_text_form_factor_compact_short_slim + DebugFormFactor.TWO_SIDE_RAIL -> R.string.debug_text_form_factor_two_side_rail DebugFormFactor.UNKNOWN -> R.string.debug_text_form_factor_unknown } ) @@ -328,10 +425,7 @@ internal fun DebugLayoutReadout( * points upward against physical gravity inside the current window. */ @Composable -private fun DebugUpwardTriangleIndicator( - rotationDegrees: Int, - modifier: Modifier = Modifier -) { +private fun DebugUpwardTriangleIndicator(rotationDegrees: Int, modifier: Modifier = Modifier) { val targetAngle = rotationDegrees.toFloat() var cumulativeTargetDegrees by remember { mutableFloatStateOf(targetAngle) } val currentNormalized = ((cumulativeTargetDegrees % 360f) + 360f) % 360f diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index 2f5cfcdaa6..9c89423920 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -32,11 +32,16 @@ %1$dx%2$ddp %1$d° COMPACT_SQUARE + name="debug_text_form_factor_compact_portrait" + translatable="false">COMPACT_PORTRAIT + COMPACT_HORIZONTAL COMPACT_SLIM - COMPACT - FOLDABLE - TABLET + COMPACT_SHORT + COMPACT_SHORT_SLIM + TWO_SIDE_RAIL UNKNOWN diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index c36f65c37c..8b594e93fb 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -51,7 +51,7 @@ class DebugLayoutReadoutTest { state = DebugLayoutReadoutState( widthDp = 673, heightDp = 841, - formFactor = DebugFormFactor.FOLDABLE, + formFactor = DebugFormFactor.TWO_SIDE_RAIL, deviceRotation = DeviceRotation.Rotated270, windowRotation = DeviceRotation.Natural ) @@ -74,7 +74,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("673x841dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("FOLDABLE") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TWO_SIDE_RAIL") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("270°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -98,7 +98,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT_PORTRAIT") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -147,7 +147,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("360x800dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT_PORTRAIT") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -179,7 +179,7 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("800x1280dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TABLET") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TWO_SIDE_RAIL") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("90°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -258,7 +258,33 @@ class DebugLayoutReadoutTest { ).expectedLayoutRotationDegrees ).isEqualTo(270) - // Wide compact_square quadrant (433x346, Win 0°, Dev 0°) -> 270° + // Wide slim pane (883x276, Win 0°, Dev 0°) -> 270°; tall slim pane (276x883) -> 0° + assertThat( + DebugLayoutReadoutState( + widthDp = 883, + heightDp = 276, + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(270) + assertThat( + DebugLayoutReadoutState( + widthDp = 276, + heightDp = 883, + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(0) + + // Compact short & compact short slim stay upright (0°) in WideShortLayout + assertThat( + DebugLayoutReadoutState( + widthDp = 412, + heightDp = 412, + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural + ).expectedLayoutRotationDegrees + ).isEqualTo(0) assertThat( DebugLayoutReadoutState( widthDp = 433, @@ -266,9 +292,9 @@ class DebugLayoutReadoutTest { deviceRotation = DeviceRotation.Natural, windowRotation = DeviceRotation.Natural ).expectedLayoutRotationDegrees - ).isEqualTo(270) + ).isEqualTo(0) - // Wide compact window held at 90° CW with Win 0° -> follows gravity (270°) or 270° CCW (90°) + // Wide compact window held at 90° CW with Win 0° -> follows gravity (90°) assertThat( DebugLayoutReadoutState( widthDp = 840, @@ -278,7 +304,7 @@ class DebugLayoutReadoutTest { ).expectedLayoutRotationDegrees ).isEqualTo(90) - // Foldable and Tablet always remain upright (0°) regardless of widthDp vs heightDp + // TWO_SIDE_RAIL always remains upright (0°) regardless of widthDp vs heightDp assertThat( DebugLayoutReadoutState( widthDp = 841, @@ -298,52 +324,60 @@ class DebugLayoutReadoutTest { } @Test - fun debugFormFactor_fromWindowDp_classifiesAllFormFactorBuckets() { - assertThat(DebugFormFactor.fromWindowDp(412, 412)) - .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + fun debugFormFactor_fromWindowDp_classifiesAllCameraLayoutModeBuckets() { + // COMPACT_PORTRAIT (W < 600, H >= 480, min >= 360) + assertThat(DebugFormFactor.fromWindowDp(412, 915)) + .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) + assertThat(DebugFormFactor.fromWindowDp(480, 760)) + .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) + assertThat(DebugFormFactor.fromWindowDp(585, 602)) + .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) + + // COMPACT_HORIZONTAL (W >= 480, H < 480, min >= 360, or full-screen compact phone W > H) + assertThat(DebugFormFactor.fromWindowDp(840, 360)) + .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) assertThat(DebugFormFactor.fromWindowDp(500, 360)) - .isEqualTo(DebugFormFactor.COMPACT_SQUARE) - assertThat(DebugFormFactor.fromWindowDp(433, 346)) - .isEqualTo(DebugFormFactor.COMPACT_SQUARE) - assertThat(DebugFormFactor.fromWindowDp(346, 433)) - .isEqualTo(DebugFormFactor.COMPACT_SQUARE) + .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) + assertThat(DebugFormFactor.fromWindowDp(800, 400)) + .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) + assertThat(DebugFormFactor.fromWindowDp(900, 500, isFullScreenCompactPhone = true)) + .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) + // COMPACT_SLIM (min < 360, max >= 480) assertThat(DebugFormFactor.fromWindowDp(276, 883)) .isEqualTo(DebugFormFactor.COMPACT_SLIM) assertThat(DebugFormFactor.fromWindowDp(883, 276)) .isEqualTo(DebugFormFactor.COMPACT_SLIM) assertThat(DebugFormFactor.fromWindowDp(250, 602)) .isEqualTo(DebugFormFactor.COMPACT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(320, 800, isFullScreenCompactPhone = true)) + .isEqualTo(DebugFormFactor.COMPACT_SLIM) - assertThat(DebugFormFactor.fromWindowDp(412, 915)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(840, 360)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(512, 650)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(566, 833)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(494, 602)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(383, 602)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(585, 602)) - .isEqualTo(DebugFormFactor.COMPACT) - assertThat(DebugFormFactor.fromWindowDp(480, 760)) - .isEqualTo(DebugFormFactor.COMPACT) + // COMPACT_SHORT (W < 480, H < 480, min >= 360) + assertThat(DebugFormFactor.fromWindowDp(412, 412)) + .isEqualTo(DebugFormFactor.COMPACT_SHORT) + assertThat(DebugFormFactor.fromWindowDp(360, 360)) + .isEqualTo(DebugFormFactor.COMPACT_SHORT) + // COMPACT_SHORT_SLIM (W < 480, H < 480, min < 360) + assertThat(DebugFormFactor.fromWindowDp(433, 346)) + .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(346, 433)) + .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(320, 340)) + .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) + + // TWO_SIDE_RAIL (W >= 600, H >= 480, when not full-screen compact phone) assertThat(DebugFormFactor.fromWindowDp(673, 841)) - .isEqualTo(DebugFormFactor.FOLDABLE) + .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) assertThat(DebugFormFactor.fromWindowDp(841, 673)) - .isEqualTo(DebugFormFactor.FOLDABLE) - assertThat(DebugFormFactor.fromWindowDp(850, 900)) - .isEqualTo(DebugFormFactor.FOLDABLE) - + .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) assertThat(DebugFormFactor.fromWindowDp(800, 1280)) - .isEqualTo(DebugFormFactor.TABLET) + .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) assertThat(DebugFormFactor.fromWindowDp(1280, 800)) - .isEqualTo(DebugFormFactor.TABLET) + .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) + // UNKNOWN (W <= 0 || H <= 0) assertThat(DebugFormFactor.fromWindowDp(0, 800)) .isEqualTo(DebugFormFactor.UNKNOWN) assertThat(DebugFormFactor.fromWindowDp(412, -1)) From c0887937bbf248f04cce29ef61aa0ff21e87962c Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 07:48:27 -0800 Subject: [PATCH 11/17] feat(ui/capture, debug): add compact layout counter-rotation, cutout remapping, and mode readout --- .../ui/components/capture/CaptureLayout.kt | 8 +- .../capture/CompactPortraitLayout.kt | 417 +++++++++------ .../ui/components/capture/CutoutAwareRow.kt | 72 ++- .../components/capture/RotationModifiers.kt | 164 ++++++ .../components/capture/LayoutRotationTest.kt | 492 ++++++++++++++++++ .../capture/RotationModifiersTest.kt | 142 +++++ .../ui/debug/DebugLayoutReadoutTest.kt | 16 + 7 files changed, 1126 insertions(+), 185 deletions(-) create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index c9ac154e02..3226eff11c 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -195,7 +195,9 @@ fun PreviewLayout( onRequiresImmersiveChange = onRequiresImmersiveChange, debugOverlay = debugOverlay, debugVisibilityWrapper = debugVisibilityWrapper, - screenFlashOverlay = screenFlashOverlay + screenFlashOverlay = screenFlashOverlay, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } else { CompactPortraitLayout( @@ -221,7 +223,9 @@ fun PreviewLayout( viewfinderTopEndContent = viewfinderTopEndContent, topBarTrailingContent = topBarTrailingContent, onRequiresImmersiveChange = onRequiresImmersiveChange, - errorDialog = errorDialog + errorDialog = errorDialog, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } } 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 208f8417dc..1a63f8c849 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 @@ -82,6 +82,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max 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.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds @@ -218,6 +219,8 @@ internal fun Modifier.suppressBackgroundForModal(isModalOpen: Boolean): Modifier * hiding is enabled. * @param screenFlashOverlay Full-bleed screen flash composable drawn above all chrome and system * bar areas. + * @param deviceRotation Physical hardware [DeviceRotation] relative to gravity. + * @param windowRotation Current OS window/display [DeviceRotation]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -233,7 +236,9 @@ fun CompactPortraitLayout( onRequiresImmersiveChange: (Boolean) -> Unit = {}, debugOverlay: @Composable (Modifier) -> Unit = {}, debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit = { it() }, - screenFlashOverlay: @Composable (Modifier) -> Unit = {} + screenFlashOverlay: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { CompactPortraitLayout( modifier = modifier, @@ -375,7 +380,9 @@ fun CompactPortraitLayout( ) }, onRequiresImmersiveChange = onRequiresImmersiveChange, - errorDialog = slots.errorDialog + errorDialog = slots.errorDialog, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } @@ -422,6 +429,8 @@ fun CompactPortraitLayout( * @param onRequiresImmersiveChange Callback notified when the portrait solver requires or releases * immersive navigation-bar hiding. * @param errorDialog In-hierarchy camera error dialog composable. + * @param deviceRotation Physical hardware [DeviceRotation] relative to gravity. + * @param windowRotation Current OS window/display [DeviceRotation]. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -450,7 +459,9 @@ fun CompactPortraitLayout( viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, topBarTrailingContent: @Composable (Modifier) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, - errorDialog: @Composable (Modifier) -> Unit = {} + errorDialog: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } val localeDirection = LocalLayoutDirection.current @@ -460,202 +471,258 @@ fun CompactPortraitLayout( scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded val isModalOpen = isSheetVisible || isErrorDialogShowing - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { - Box( - modifier = modifier - .fillMaxSize() - .testTag(COMPACT_CONTROLS_TAG) + SeamlessRotationAnimationEffect() + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .testTag(COMPACT_CONTROLS_TAG) + ) { + val windowWidth = maxWidth + val windowHeight = maxHeight + val shouldCounterRotate = windowWidth > windowHeight + val effectiveDeviceRotation = if ( + deviceRotation == DeviceRotation.Natural && + windowRotation.isLandscape && + shouldCounterRotate ) { - BottomSheetScaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState, - sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, - sheetDragHandle = { - BottomSheetDefaults.DragHandle( - modifier = Modifier - .testTag(QUICK_SETTINGS_DRAG_HANDLE) - .clickable( - role = Role.Button, - onClickLabel = dismissQuickSettingsLabel, - onClick = onDismissQuickSettings - ) - ) - }, - sheetShape = RoundedCornerShape( - topStart = CompactPortraitTokens.SheetTopCornerRadius, - topEnd = CompactPortraitTokens.SheetTopCornerRadius - ), - sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - quickSettingsOverlay(Modifier) - } - }, - snackbarHost = {} + windowRotation + } else { + deviceRotation + } + val windowGravityDeg = effectiveDeviceRotation.toGravityRotationDegrees(windowRotation) + val effectiveWindowRotation = when { + !shouldCounterRotate -> DeviceRotation.Natural + windowGravityDeg == 90 -> DeviceRotation.Rotated90 + windowGravityDeg == 270 -> DeviceRotation.Rotated270 + windowRotation.isLandscape -> windowRotation + else -> DeviceRotation.Rotated270 + } + val portraitWidth = if (shouldCounterRotate) windowHeight else windowWidth + val portraitHeight = if (shouldCounterRotate) windowWidth else windowHeight + val containerCounterDeg = effectiveWindowRotation.toWindowCounterRotationDegrees().toInt() + val containerFrameRotation = DeviceRotation.snapFrom( + ((windowRotation.toClockwiseRotationDegrees() - containerCounterDeg) % 360 + 360) % 360 + ) + val gravityRotationState = rememberGravityRotationState( + deviceRotation = effectiveDeviceRotation, + windowRotation = containerFrameRotation + ) + + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalDeviceRotation provides effectiveDeviceRotation, + LocalWindowRotation provides effectiveWindowRotation, + LocalGravityRotationState provides gravityRotationState + ) { + Box( + modifier = Modifier + .fillMaxSize() + .counterRotateToPortrait(effectiveWindowRotation) ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - val windowAspectRatio = maxHeight.value / maxWidth.value - val isHandheldPortrait = - windowAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD - if (isHandheldPortrait) { - AdaptivePortraitCaptureLayout( - windowWidth = maxWidth, - windowHeight = maxHeight, - includeStatusBarsInTopBar = includeStatusBarsInTopBar, - isSheetVisible = isSheetVisible, - isModalOpen = isModalOpen, - viewfinder = viewfinder, - topStartContent = topStartContent, - viewfinderTopEndContent = viewfinderTopEndContent, - onRequiresImmersiveChange = onRequiresImmersiveChange, - indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, - zoomControls = zoomLevelDisplay, - imageWell = imageWell, - captureButton = captureButton, - flipCameraButton = flipCameraButton, - captureModeCarousel = captureModeCarousel, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - debugVisibilityWrapper = debugVisibilityWrapper, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } - ) - Box( + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, + sheetDragHandle = { + BottomSheetDefaults.DragHandle( modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + .testTag(QUICK_SETTINGS_DRAG_HANDLE) + .clickable( + role = Role.Button, + onClickLabel = dismissQuickSettingsLabel, + onClick = onDismissQuickSettings ) - ) { - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection - ) { - snackBar(Modifier, scaffoldState.snackbarHostState) - } + ) + }, + sheetShape = RoundedCornerShape( + topStart = CompactPortraitTokens.SheetTopCornerRadius, + topEnd = CompactPortraitTokens.SheetTopCornerRadius + ), + sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + sheetContent = { + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + quickSettingsOverlay(Modifier) } - } else { - Column { - val minTouchTarget = LocalMinimumInteractiveComponentSize.current - .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } - val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + }, + snackbarHost = {} + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + val windowAspectRatio = portraitHeight.value / portraitWidth.value + val isHandheldPortrait = + windowAspectRatio >= + CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + if (isHandheldPortrait) { + AdaptivePortraitCaptureLayout( + windowWidth = portraitWidth, + windowHeight = portraitHeight, + windowRotation = effectiveWindowRotation, includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget - ) - CompactTopBarBox( - topBarHeight = topBarHeight, - topBarTopPadding = topBarTopPadding, - modifier = Modifier.suppressBackgroundForModal(isModalOpen), + isSheetVisible = isSheetVisible, + isModalOpen = isModalOpen, + viewfinder = viewfinder, + topStartContent = topStartContent, + viewfinderTopEndContent = viewfinderTopEndContent, + onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent - ) - Box { - Box(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) { - viewfinder(Modifier) + topBarTrailingContent = topBarTrailingContent, + elapsedTimeDisplay = elapsedTimeDisplay, + zoomControls = zoomLevelDisplay, + imageWell = imageWell, + captureButton = captureButton, + flipCameraButton = flipCameraButton, + captureModeCarousel = captureModeCarousel, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + debugVisibilityWrapper = debugVisibilityWrapper, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) } - OverlapAwareStyleProvider( - Modifier - .align(Alignment.TopStart) - .suppressBackgroundForModal(isSheetVisible) - ) { - topStartContent(Modifier) - } - OverlapAwareStyleProvider( - Modifier - .align(Alignment.TopEnd) - .suppressBackgroundForModal(isModalOpen) + ) + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) + ) { + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection ) { - viewfinderTopEndContent(Modifier) + snackBar(Modifier, scaffoldState.snackbarHostState) } } - } - - Box( - modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + } else { + Column { + val minTouchTarget = LocalMinimumInteractiveComponentSize.current + .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } + val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + includeStatusBarsInTopBar = includeStatusBarsInTopBar, + minTouchTarget = minTouchTarget, + windowRotation = effectiveWindowRotation ) - ) { - debugVisibilityWrapper { - VerticalMaterialControls( - captureButton = captureButton, - imageWell = imageWell, - flipCameraButton = flipCameraButton, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - captureModeCarousel = captureModeCarousel, - zoomControls = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + CompactTopBarBox( + topBarHeight = topBarHeight, + topBarTopPadding = topBarTopPadding, + windowRotation = effectiveWindowRotation, + modifier = Modifier.suppressBackgroundForModal(isModalOpen), + indicatorRow = indicatorRow, + topBarTrailingContent = topBarTrailingContent ) + Box { + Box( + modifier = Modifier.suppressBackgroundForModal(isModalOpen) + ) { + viewfinder(Modifier) + } + OverlapAwareStyleProvider( + Modifier + .align(Alignment.TopStart) + .suppressBackgroundForModal(isSheetVisible) + ) { + topStartContent(Modifier) + } + OverlapAwareStyleProvider( + Modifier + .align(Alignment.TopEnd) + .suppressBackgroundForModal(isModalOpen) + ) { + viewfinderTopEndContent(Modifier) + } + } } - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection + + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) ) { - snackBar(Modifier, scaffoldState.snackbarHostState) + debugVisibilityWrapper { + VerticalMaterialControls( + windowRotation = effectiveWindowRotation, + captureButton = captureButton, + imageWell = imageWell, + flipCameraButton = flipCameraButton, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + captureModeCarousel = captureModeCarousel, + zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) + } + ) + } + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + snackBar(Modifier, scaffoldState.snackbarHostState) + } } } - } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - debugOverlay(Modifier) - } + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + debugOverlay(Modifier) + } - if (isSheetVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .testTag(QUICK_SETTINGS_SCRIM) - .clickable( - onClickLabel = dismissQuickSettingsLabel, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismissQuickSettings - ) - ) - } + if (isSheetVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(QUICK_SETTINGS_SCRIM) + .clickable( + onClickLabel = dismissQuickSettingsLabel, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissQuickSettings + ) + ) + } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - errorDialog(Modifier) + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + errorDialog(Modifier) + } } } } - // Drawn last and outside of both the inset-padded controls and the bottom sheet so the - // flash covers the entire window, including system bar and display cutout areas. - screenFlashOverlay(Modifier) } + // Drawn last and outside of both the counter-rotated controls and the bottom sheet so the + // flash covers the entire window, including system bar and display cutout areas. + screenFlashOverlay(Modifier) } } @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, - minTouchTarget: Dp + minTouchTarget: Dp, + windowRotation: DeviceRotation = LocalWindowRotation.current ): Pair { - val cutoutTopInset = WindowInsets.displayCutout.asPaddingValues().calculateTopPadding() + val portraitCutoutInsets = WindowInsets.displayCutout.toPortraitWindowInsets(windowRotation) + val cutoutTopInset = portraitCutoutInsets.asPaddingValues().calculateTopPadding() return if (includeStatusBarsInTopBar) { - val statusBarTopInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val combinedTopInset = WindowInsets.statusBars - .union(WindowInsets.displayCutout) + val portraitStatusBars = WindowInsets.statusBars.toPortraitWindowInsets(windowRotation) + val statusBarTopInset = portraitStatusBars.asPaddingValues().calculateTopPadding() + val combinedTopInset = portraitStatusBars + .union(portraitCutoutInsets) .asPaddingValues() .calculateTopPadding() max(combinedTopInset, statusBarTopInset + minTouchTarget) to statusBarTopInset @@ -670,7 +737,8 @@ private fun CompactTopBarBox( topBarTopPadding: Dp, indicatorRow: @Composable (Modifier) -> Unit, topBarTrailingContent: @Composable (Modifier) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { val localeDirection = LocalLayoutDirection.current CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { @@ -680,7 +748,9 @@ private fun CompactTopBarBox( .height(topBarHeight) .padding(top = topBarTopPadding) .windowInsetsPadding( - WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal) + WindowInsets.displayCutout + .toPortraitWindowInsets(windowRotation) + .only(WindowInsetsSides.Horizontal) ) .padding(horizontal = CompactPortraitTokens.LowerSectionHorizontalPadding), contentAlignment = Alignment.CenterStart @@ -815,6 +885,7 @@ internal fun AdaptivePortraitCaptureLayout( quickSettingsToggleButton: @Composable (Modifier) -> Unit, captureModeToggleSwitch: @Composable (Modifier) -> Unit, debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit, + windowRotation: DeviceRotation = LocalWindowRotation.current, includeStatusBarsInTopBar: Boolean = true, isSheetVisible: Boolean = false, isModalOpen: Boolean = false, @@ -829,9 +900,11 @@ internal fun AdaptivePortraitCaptureLayout( } val navInset = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val rawGestureInset = WindowInsets.systemGestures + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset) @@ -839,7 +912,8 @@ internal fun AdaptivePortraitCaptureLayout( .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget + minTouchTarget = minTouchTarget, + windowRotation = windowRotation ) val window = remember( @@ -908,6 +982,7 @@ internal fun AdaptivePortraitCaptureLayout( CompactTopBarBox( topBarHeight = topBarHeight, topBarTopPadding = topBarTopPadding, + windowRotation = windowRotation, modifier = Modifier .layoutId(CaptureSlotId.TopBar) .suppressBackgroundForModal(isModalOpen), @@ -1099,6 +1174,7 @@ internal fun AdaptivePortraitCaptureLayout( @Composable internal fun VerticalMaterialControls( modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current, captureButton: @Composable (Modifier) -> Unit, zoomControls: @Composable (Modifier) -> Unit, imageWell: @Composable (Modifier) -> Unit, @@ -1122,6 +1198,7 @@ internal fun VerticalMaterialControls( contentAlignment = Alignment.BottomCenter ) { val navBarBottom = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val isShortScreen = maxHeight < CompactPortraitTokens.ShortScreenThreshold diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt index 44864e0fc9..c3d9356048 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import com.google.jetpackcamera.core.common.ignoreResult +import com.google.jetpackcamera.model.DeviceRotation import kotlin.math.max import kotlin.math.roundToInt @@ -82,7 +83,8 @@ import kotlin.math.roundToInt * @param verticalAlignment vertical alignment of children within the row. * @param cutoutRectsOverride optional list of cutout bounding rectangles in the row's local * coordinate space, used for deterministic testing. When `null`, cutout rects are queried from - * the host window's [android.view.DisplayCutout] and mapped into the row's local coordinates. + * the host window's [androidx.core.view.DisplayCutoutCompat] and mapped into the row's local + * coordinates. * @param onPillSegmentsPlaced optional callback invoked with the physical local [IntRect] bounds of * each active pill segment after layout, used for testing. * @param content the row children, scoped to [RowScope]. @@ -102,6 +104,7 @@ fun CutoutAwareRow( content: @Composable RowScope.() -> Unit ) { val view = LocalView.current + val windowRotation = LocalWindowRotation.current val cutoutInsets = WindowInsets.displayCutout var rowBoundsInWindow by remember { mutableStateOf(null) } val lockedMinX = remember { mutableMapOf() } @@ -145,8 +148,10 @@ fun CutoutAwareRow( } } ) { measurables, constraints -> - // Read WindowInsets.displayCutout inside measure to subscribe to inset updates. + // Read WindowInsets.displayCutout inside measure to subscribe to inset updates across + // all physical edges when counter-rotated. cutoutInsets.getTop(this).ignoreResult() + cutoutInsets.getBottom(this).ignoreResult() cutoutInsets.getLeft(this, layoutDirection).ignoreResult() cutoutInsets.getRight(this, layoutDirection).ignoreResult() @@ -187,7 +192,10 @@ fun CutoutAwareRow( ?.displayCutout?.boundingRects.orEmpty(), rowBoundsInWindow = rowBoundsInWindow, isRtl = isRtl, - rtlReferenceWidth = rtlReferenceWidth + rtlReferenceWidth = rtlReferenceWidth, + windowRotation = windowRotation, + fallbackViewWidth = view.width, + fallbackViewHeight = view.height ) // Convert vertically overlapping cutout rects into sorted horizontal keep-out ranges. @@ -356,23 +364,61 @@ internal fun resolveLocalCutoutRects( windowCutoutRects: List, rowBoundsInWindow: IntRect?, isRtl: Boolean, - rtlReferenceWidth: Int + rtlReferenceWidth: Int, + windowRotation: DeviceRotation = DeviceRotation.Natural, + fallbackViewWidth: Int = 0, + fallbackViewHeight: Int = 0 ): List { val ltrRects = cutoutRectsOverride ?: run { - val offsetX = rowBoundsInWindow?.left ?: 0 - val offsetY = rowBoundsInWindow?.top ?: 0 + val bounds = rowBoundsInWindow windowCutoutRects.map { rect -> - IntRect( - left = rect.left - offsetX, - top = rect.top - offsetY, - right = rect.right - offsetX, - bottom = rect.bottom - offsetY - ) + when (windowRotation) { + DeviceRotation.Natural -> { + val offsetX = bounds?.left ?: 0 + val offsetY = bounds?.top ?: 0 + IntRect( + left = rect.left - offsetX, + top = rect.top - offsetY, + right = rect.right - offsetX, + bottom = rect.bottom - offsetY + ) + } + DeviceRotation.Rotated270 -> { + val refBottom = bounds?.bottom ?: fallbackViewHeight + val refLeft = bounds?.left ?: 0 + IntRect( + left = refBottom - rect.bottom, + top = rect.left - refLeft, + right = refBottom - rect.top, + bottom = rect.right - refLeft + ) + } + DeviceRotation.Rotated90 -> { + val refTop = bounds?.top ?: 0 + val refRight = bounds?.right ?: fallbackViewWidth + IntRect( + left = rect.top - refTop, + top = refRight - rect.right, + right = rect.bottom - refTop, + bottom = refRight - rect.left + ) + } + DeviceRotation.Rotated180 -> { + val refRight = bounds?.right ?: fallbackViewWidth + val refBottom = bounds?.bottom ?: fallbackViewHeight + IntRect( + left = refRight - rect.right, + top = refBottom - rect.bottom, + right = refRight - rect.left, + bottom = refBottom - rect.top + ) + } + } } } return if (isRtl) { val startEdgeX = if (cutoutRectsOverride == null && rowBoundsInWindow != null) { - rowBoundsInWindow.width + if (windowRotation.isLandscape) rowBoundsInWindow.height else rowBoundsInWindow.width } else { rtlReferenceWidth } 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 5e13865c29..23262062c8 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 @@ -15,16 +15,24 @@ */ package com.google.jetpackcamera.ui.components.capture +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.hardware.display.DisplayManager +import android.os.Build import android.view.Display import android.view.Surface import android.view.View +import android.view.WindowManager +import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.State @@ -42,6 +50,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleStartEffect import com.google.jetpackcamera.model.DeviceRotation @@ -216,3 +226,157 @@ fun Modifier.rotateLayoutToGravity(rotationDegrees: Int): Modifier = } } } + +/** + * Counter-rotates a compact portrait container back to the device's canonical portrait (`0°`) + * coordinate frame when [windowRotation] is landscape ([DeviceRotation.Rotated90] or + * [DeviceRotation.Rotated270]). + * + * When [windowRotation] is portrait ([DeviceRotation.Natural] or [DeviceRotation.Rotated180]), + * measures with [Constraints] unchanged and places at `(0, 0)` with zero layer rotation. + * + * @param windowRotation Active OS window [DeviceRotation] to counter-rotate from. + */ +fun Modifier.counterRotateToPortrait(windowRotation: DeviceRotation): Modifier = + layout { measurable, constraints -> + if (!windowRotation.isLandscape) { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } else { + val swappedMaxWidth = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + Constraints.Infinity + } + val swappedMaxHeight = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + Constraints.Infinity + } + val childConstraints = Constraints( + minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), + maxWidth = swappedMaxWidth, + minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), + maxHeight = swappedMaxHeight + ) + val placeable = measurable.measure(childConstraints) + val layoutWidth = placeable.height + val layoutHeight = placeable.width + layout(layoutWidth, layoutHeight) { + val offsetX = (layoutWidth - placeable.width) / 2 + val offsetY = (layoutHeight - placeable.height) / 2 + placeable.placeWithLayer(offsetX, offsetY) { + rotationZ = windowRotation.toWindowCounterRotationDegrees() + } + } + } + } + +/** + * [WindowInsets] wrapper that remaps physical window edges from the host OS window's + * [windowRotation] coordinate frame into the canonical portrait (`0°`) coordinate frame used inside + * [counterRotateToPortrait]. + * + * @property baseInsets Underlying host [WindowInsets] in the OS window's coordinate frame. + * @property windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated + * container. + */ +@Immutable +class PortraitWindowInsets( + private val baseInsets: WindowInsets, + private val windowRotation: DeviceRotation +) : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int = + when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated270 -> baseInsets.getBottom(density) + DeviceRotation.Rotated90 -> baseInsets.getTop(density) + DeviceRotation.Rotated180 -> baseInsets.getRight(density, LayoutDirection.Ltr) + } + + override fun getTop(density: Density): Int = when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getTop(density) + DeviceRotation.Rotated270 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated90 -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated180 -> baseInsets.getBottom(density) + } + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int = + when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated270 -> baseInsets.getTop(density) + DeviceRotation.Rotated90 -> baseInsets.getBottom(density) + DeviceRotation.Rotated180 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + } + + override fun getBottom(density: Density): Int = when (windowRotation) { + DeviceRotation.Natural -> baseInsets.getBottom(density) + DeviceRotation.Rotated270 -> baseInsets.getRight(density, LayoutDirection.Ltr) + DeviceRotation.Rotated90 -> baseInsets.getLeft(density, LayoutDirection.Ltr) + DeviceRotation.Rotated180 -> baseInsets.getTop(density) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PortraitWindowInsets) return false + return baseInsets == other.baseInsets && windowRotation == other.windowRotation + } + + override fun hashCode(): Int { + var result = baseInsets.hashCode() + result = 31 * result + windowRotation.hashCode() + return result + } + + override fun toString(): String = + "PortraitWindowInsets(baseInsets=$baseInsets, windowRotation=$windowRotation)" +} + +/** + * Remaps this [WindowInsets] instance from the host OS window's [windowRotation] coordinate frame + * into the canonical portrait (`0°`) coordinate frame. + * + * @param windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated + * container. + * @return A [PortraitWindowInsets] remapping all four edges into portrait coordinates. + */ +fun WindowInsets.toPortraitWindowInsets(windowRotation: DeviceRotation): WindowInsets = + PortraitWindowInsets(this, windowRotation) + +/** + * Requests [WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS] on the host [Activity] window + * on API 26+ while [enabled] is `true`, restoring the previous window rotation animation on + * disposal so counter-rotated compact camera chrome transitions across OS window rotations without + * a crossfade jump. + */ +@Composable +internal fun SeamlessRotationAnimationEffect(enabled: Boolean = true) { + val context = LocalContext.current + val activity = LocalActivity.current ?: remember(context) { context.findActivity() } + DisposableEffect(activity, enabled) { + val window = activity?.window + if (enabled && window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val previousAnimation = window.attributes.rotationAnimation + if (previousAnimation != WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS) { + window.attributes = window.attributes.apply { + rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS + } + } + onDispose { + window.attributes = window.attributes.apply { + rotationAnimation = previousAnimation + } + } + } else { + onDispose {} + } + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} 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 new file mode 100644 index 0000000000..6386cf37eb --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt @@ -0,0 +1,492 @@ +/* + * 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.ui.components.capture + +import android.graphics.Rect as AndroidRect +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Density +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 com.google.common.truth.Truth.assertThat +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private object LayoutRotationTestTokens { + const val INDICATOR_TAG = "RotationTestIndicatorRow" + + val CaptureButtonSize = 80.dp + val ControlSize = 48.dp + val ZoomBarWidth = 160.dp + val IndicatorWidth = 120.dp + val IndicatorHeight = 36.dp + val ModeToggleWidth = 96.dp +} + +/** + * Robolectric Compose UI tests verifying layout-level counter-rotation scoping across full-screen + * compact handhelds, portrait split-screen panes on rotated phones, side-rail layouts, and + * [CutoutAwareRow] cutout remapping. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(RobolectricTestRunner::class) +class LayoutRotationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Composable + private fun RotationPreviewUnderTest( + deviceRotation: DeviceRotation, + windowRotation: DeviceRotation, + modifier: Modifier = Modifier, + slots: CaptureSlots? = null, + onViewfinderObserved: ( + mode: CameraLayoutMode, + effectiveWindowRotation: DeviceRotation, + gravityRotationDegrees: Float, + solution: CameraLayoutSolution?, + viewfinderSize: IntSize + ) -> Unit = { _, _, _, _, _ -> } + ) { + MaterialTheme { + CompositionLocalProvider(LocalDisableAnimations provides true) { + PreviewLayout( + modifier = modifier, + deviceRotation = deviceRotation, + windowRotation = windowRotation, + slots = slots, + viewfinder = { vfModifier -> + val mode = LocalCameraLayoutMode.current + val effectiveWinRot = LocalWindowRotation.current + val gravityDeg = LocalGravityRotationState.current.value + val solution = LocalCameraLayoutSolution.current + Box( + modifier = vfModifier + .fillMaxSize() + .onSizeChanged { vfSize -> + onViewfinderObserved( + mode, + effectiveWinRot, + gravityDeg, + solution, + vfSize + ) + } + .testTag(PREVIEW_DISPLAY) + ) + }, + captureButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.CaptureButtonSize) + .testTag(CAPTURE_BUTTON) + ) + }, + imageWell = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(IMAGE_WELL_TAG) + ) + }, + flipCameraButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(FLIP_CAMERA_BUTTON) + ) + }, + zoomLevelDisplay = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.ZoomBarWidth, + height = LayoutRotationTestTokens.ControlSize + ) + .testTag(ZOOM_BUTTON_ROW_TAG) + ) + }, + elapsedTimeDisplay = {}, + quickSettingsButton = { mod -> + Box( + mod + .size(LayoutRotationTestTokens.ControlSize) + .testTag(QUICK_SETTINGS_DROP_DOWN) + ) + }, + indicatorRow = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.IndicatorWidth, + height = LayoutRotationTestTokens.IndicatorHeight + ) + .testTag(LayoutRotationTestTokens.INDICATOR_TAG) + ) + }, + captureModeToggle = { mod -> + Box( + mod + .size( + width = LayoutRotationTestTokens.ModeToggleWidth, + height = LayoutRotationTestTokens.ControlSize + ) + .testTag(CAPTURE_MODE_TOGGLE_BUTTON) + ) + }, + quickSettingsOverlay = {}, + debugOverlay = {}, + debugVisibilityWrapper = { content -> content() }, + screenFlashOverlay = {}, + snackBar = { _, _ -> } + ) + } + } + } + + @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi") + @Test + fun compactPortraitLayout_fullScreenLandscapePhone_counterRotatesToPortrait() { + var windowRotation by mutableStateOf(DeviceRotation.Rotated90) + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + deviceRotation = windowRotation, + windowRotation = windowRotation, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // Rotated90 (+90° CW counter-rotation): solves portrait 360x740dp, top bar on right edge, + // shutter button on left side of the 740x360dp window. + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated90) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) + // Inside +90° container, gravity compensation is -90° (270°) so icons net to 0° in window. + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f) + + val captureBand90 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterX90 = + 740f - (captureBand90.top.value + captureBand90.bottom.value) / 2f + val indicatorRotated90 = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureRotated90 = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorRotated90.left).isGreaterThan(680f) + assertThat(captureRotated90.center.x).isWithin(1f).of(expectedCaptureCenterX90) + assertThat(captureRotated90.center.y).isWithin(1f).of(180f) + + // Rotated270 (-90° CCW counter-rotation): solves portrait 360x740dp, top bar on left edge, + // shutter button on right side of the 740x360dp window. + windowRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + + val captureBand270 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterX270 = + (captureBand270.top.value + captureBand270.bottom.value) / 2f + val indicatorRotated270 = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureRotated270 = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorRotated270.right).isLessThan(60f) + assertThat(captureRotated270.center.x).isWithin(1f).of(expectedCaptureCenterX270) + assertThat(captureRotated270.center.y).isWithin(1f).of(180f) + } + + @Config(qualifiers = "sw360dp-w800dp-h800dp-mdpi") + @Test + fun compactPortraitLayout_portraitSplitScreenOnRotatedPhone_doesNotCounterRotate() { + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(width = 380.dp, height = 760.dp), + deviceRotation = DeviceRotation.Rotated90, + windowRotation = DeviceRotation.Rotated90, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // 380x760dp split-screen pane has windowWidth <= windowHeight, so it stays upright (0°) + // without swapping constraints even though the host display is at Rotated90. + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Natural) + assertThat(observedGravityDeg).isEqualTo(0f) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(380, 760)) + + val captureBand = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) + val expectedCaptureCenterY = (captureBand.top.value + captureBand.bottom.value) / 2f + val indicatorBounds = composeTestRule + .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) + .fetchSemanticsNode() + .boundsInRoot + val captureBounds = composeTestRule + .onNodeWithTag(CAPTURE_BUTTON) + .fetchSemanticsNode() + .boundsInRoot + assertThat(indicatorBounds.top).isLessThan(60f) + assertThat(captureBounds.center.x).isWithin(1f).of(190f) + assertThat(captureBounds.center.y).isWithin(1f).of(expectedCaptureCenterY) + assertThat(captureBounds.top).isGreaterThan(indicatorBounds.bottom) + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun compactHorizontalSplitScreen_uprightWindow_rotatesCompactLayout90DegreesToFillSpace() { + var observedMode: CameraLayoutMode? = null + var observedEffectiveWinRot: DeviceRotation? = null + var observedGravityDeg: Float? = null + var observedSolution: CameraLayoutSolution? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural, + onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + observedMode = mode + observedEffectiveWinRot = effWinRot + observedGravityDeg = gravDeg + observedSolution = solution + observedViewfinderSize = vfSize + } + ) + } + composeTestRule.waitForIdle() + + // 800x400dp horizontal split-screen resolves to COMPACT_HORIZONTAL, rotating + // CompactPortraitLayout -90° CCW (Rotated270) so it solves 400x800dp horizontally and + // rotates interior icons +90° CW to remain 0° upright in the Natural window! + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(observedSolution).isNotNull() + assertThat(observedViewfinderSize).isEqualTo(IntSize(400, 800)) + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun twoSideRailLayout_andCompactShortLayout_neverCounterRotate() { + var containerSize by mutableStateOf(DpSize(1280.dp, 800.dp)) + var windowRotation by mutableStateOf(DeviceRotation.Rotated90) + var observedMode: CameraLayoutMode? = null + var observedViewfinderSize = IntSize.Zero + + composeTestRule.setContent { + RotationPreviewUnderTest( + modifier = Modifier.size(containerSize), + deviceRotation = windowRotation, + windowRotation = windowRotation, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true), + onViewfinderObserved = { mode, _, _, _, vfSize -> + observedMode = mode + observedViewfinderSize = vfSize + } + ) + } + + val testPostures = listOf( + Triple(DpSize(1280.dp, 800.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(1280, 800)), + Triple(DpSize(883.dp, 852.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(883, 852)), + Triple(DpSize(440.dp, 412.dp), CameraLayoutMode.COMPACT_SHORT, IntSize(440, 412)) + ) + + for ((size, expectedMode, expectedVfSize) in testPostures) { + for (rotation in listOf(DeviceRotation.Rotated90, DeviceRotation.Rotated270)) { + containerSize = size + windowRotation = rotation + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(expectedMode) + assertThat(observedViewfinderSize).isEqualTo(expectedVfSize) + + composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertIsDisplayed() + + val startRailBounds = composeTestRule + .onNodeWithTag(START_SIDE_RAIL_TAG) + .fetchSemanticsNode() + .boundsInRoot + val endRailBounds = composeTestRule + .onNodeWithTag(END_SIDE_RAIL_TAG) + .fetchSemanticsNode() + .boundsInRoot + + // Side rails remain upright on left and right edges (never counter-rotated 90°). + assertThat(startRailBounds.left).isLessThan(endRailBounds.left) + assertThat(startRailBounds.height).isGreaterThan(startRailBounds.width) + assertThat(endRailBounds.height).isGreaterThan(endRailBounds.width) + } + } + } + + @Test + fun cutoutAwareRow_remapsCutoutsUnderRotated90AndRotated270() { + // Portrait top bar (width=360, height=48) counter-rotated inside a 740x360 landscape window: + // - Under Rotated90 (+90° CW): rowBoundsInWindow is [692, 0, 740, 360]. + // Window right-edge cutout [692, 20, 740, 60] maps to portrait [20, 0, 60, 48] in LTR, + // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). + val rotated90RowBounds = IntRect(left = 692, top = 0, right = 740, bottom = 360) + val rotated90WindowCutout = listOf(AndroidRect(692, 20, 740, 60)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated90WindowCutout, + rowBoundsInWindow = rotated90RowBounds, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated90, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated90WindowCutout, + rowBoundsInWindow = rotated90RowBounds, + isRtl = true, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated90, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) + + // - Under Rotated270 (-90° CCW): rowBoundsInWindow is [0, 0, 48, 360]. + // Window left-edge cutout [0, 300, 48, 340] maps to portrait [20, 0, 60, 48] in LTR, + // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). + val rotated270RowBounds = IntRect(left = 0, top = 0, right = 48, bottom = 360) + val rotated270WindowCutout = listOf(AndroidRect(0, 300, 48, 340)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated270WindowCutout, + rowBoundsInWindow = rotated270RowBounds, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated270, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) + + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = rotated270WindowCutout, + rowBoundsInWindow = rotated270RowBounds, + isRtl = true, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated270, + fallbackViewWidth = 740, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) + + // Also verify CutoutAwareRow composes cleanly when LocalWindowRotation is landscape. + var placedSegments = emptyList() + composeTestRule.setContent { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalWindowRotation provides DeviceRotation.Rotated90 + ) { + CutoutAwareRow( + pillHorizontalPadding = 8.dp, + pillMinHeight = 32.dp, + cutoutRectsOverride = listOf(IntRect(20, 0, 60, 48)), + onPillSegmentsPlaced = { placedSegments = it } + ) { + Box(Modifier.size(24.dp)) + } + } + } + composeTestRule.waitForIdle() + assertThat(placedSegments).hasSize(1) + // Item + 2*8dp pill padding = 40px, shifted past cutout [20..60] + 8px clearance = 68px. + assertThat(placedSegments.single().left).isAtLeast(68) + } +} diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index 658896dc9e..ee45984cba 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -16,17 +16,27 @@ package com.google.jetpackcamera.ui.components.capture import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth 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.geometry.Rect import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.model.DeviceRotation @@ -34,8 +44,10 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1000dp-h1000dp-mdpi") class RotationModifiersTest { @get:Rule @@ -108,4 +120,134 @@ class RotationModifiersTest { composeTestRule.waitForIdle() assertThat(outerSize).isEqualTo(IntSize(60, 200)) } + + @Test + fun counterRotateToPortrait_swapsConstraintsAndRotatesForLandscape_andIsNoOpForPortrait() { + val topStripTag = "CounterRotateTopStrip" + var windowRotation by mutableStateOf(DeviceRotation.Natural) + var outerSize = IntSize.Zero + var innerMeasuredSize = IntSize.Zero + + composeTestRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + Box( + modifier = Modifier + .size(width = 740.dp, height = 360.dp) + .onSizeChanged { outerSize = it } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .counterRotateToPortrait(windowRotation) + .onSizeChanged { innerMeasuredSize = it } + ) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .height(48.dp) + .testTag(topStripTag) + ) + // Also exercise unbounded width constraint swapping without crashing. + Box( + modifier = Modifier + .wrapContentWidth(unbounded = true) + .counterRotateToPortrait(windowRotation) + .size(width = 24.dp, height = 12.dp) + ) + } + } + } + } + + // Natural: no constraint swap, placed at (0, 0) with 0° rotation. + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 740f, 48f)) + + // Rotated180: still portrait (!isLandscape), complete no-op with 0° rotation. + windowRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 740f, 48f)) + + // Rotated90: swaps constraints to 360x740 and counter-rotates +90° CW -> top strip on right + windowRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(692f, 0f, 740f, 360f)) + + // Rotated270: swaps constraints to 360x740 and counter-rotates -90° CCW -> top strip on left + windowRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + assertThat(outerSize).isEqualTo(IntSize(740, 360)) + assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740)) + assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) + .isEqualTo(Rect(0f, 0f, 48f, 360f)) + } + + @Test + fun toPortraitWindowInsets_remapsAllEdges_acrossAllRotationsInLtrAndRtl() { + val density = Density(1f) + val observedDirections = mutableListOf() + val baseInsets = object : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int { + observedDirections.add(layoutDirection) + return 10 + } + + override fun getTop(density: Density): Int = 20 + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int { + observedDirections.add(layoutDirection) + return 30 + } + + override fun getBottom(density: Density): Int = 40 + } + + for (direction in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) { + observedDirections.clear() + val natural = baseInsets.toPortraitWindowInsets(DeviceRotation.Natural) + assertThat(natural.getLeft(density, direction)).isEqualTo(10) + assertThat(natural.getTop(density)).isEqualTo(20) + assertThat(natural.getRight(density, direction)).isEqualTo(30) + assertThat(natural.getBottom(density)).isEqualTo(40) + + val rotated270 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270) + assertThat(rotated270.getLeft(density, direction)).isEqualTo(40) + assertThat(rotated270.getTop(density)).isEqualTo(10) + assertThat(rotated270.getRight(density, direction)).isEqualTo(20) + assertThat(rotated270.getBottom(density)).isEqualTo(30) + + val rotated90 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + assertThat(rotated90.getLeft(density, direction)).isEqualTo(20) + assertThat(rotated90.getTop(density)).isEqualTo(30) + assertThat(rotated90.getRight(density, direction)).isEqualTo(40) + assertThat(rotated90.getBottom(density)).isEqualTo(10) + + val rotated180 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated180) + assertThat(rotated180.getLeft(density, direction)).isEqualTo(30) + assertThat(rotated180.getTop(density)).isEqualTo(40) + assertThat(rotated180.getRight(density, direction)).isEqualTo(10) + assertThat(rotated180.getBottom(density)).isEqualTo(20) + + // Physical window edges must always query baseInsets with LayoutDirection.Ltr. + assertThat(observedDirections).isNotEmpty() + assertThat(observedDirections.all { it == LayoutDirection.Ltr }).isTrue() + } + + // Verify value equality and hashCode contract on PortraitWindowInsets. + val first = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + val second = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90) + val different = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270) + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + assertThat(first).isNotEqualTo(different) + } } diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 8b594e93fb..ca63d678ab 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -384,6 +384,22 @@ class DebugLayoutReadoutTest { .isEqualTo(DebugFormFactor.UNKNOWN) } + @Test + fun debugLayoutReadout_whenCameraLayoutModeProvided_displaysLayoutModeRow() { + composeTestRule.setContent { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 800, + heightDp = 400, + cameraLayoutMode = "COMPACT_HORIZONTAL" + ) + ) + } + composeTestRule.waitForIdle() + + assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("COMPACT_HORIZONTAL") + } + private fun readNodeText(tag: String): String = composeTestRule .onNodeWithTag(tag) .fetchSemanticsNode() From 39675efc4956d7b0a1ae33fcebc7d3aaaf3136a1 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 9 Nov 2026 19:08:38 -0800 Subject: [PATCH 12/17] feat(ui/debug, capture): add 7-mode DebugFormFactor, window orientation, active layout readout, and recording orientation lock --- .../jetpackcamera/model/DeviceRotationTest.kt | 14 +- .../feature/preview/PreviewScreen.kt | 51 ++- .../components/capture/CameraSessionEffect.kt | 71 +++- .../components/capture/RotationModifiers.kt | 24 +- .../components/capture/CaptureScaffoldTest.kt | 38 +++ .../ui/debug/DebugOverlayComponents.kt | 312 +++++++++++++----- .../google/jetpackcamera/ui/debug/TestTags.kt | 3 + ui/debug/src/main/res/values/strings.xml | 22 +- .../ui/debug/DebugLayoutReadoutTest.kt | 202 +++++------- 9 files changed, 504 insertions(+), 233 deletions(-) diff --git a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt index 212bd3d2f0..cbb3a9b05c 100644 --- a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt +++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt @@ -22,20 +22,20 @@ import org.junit.Test class DeviceRotationTest { @Test - fun fromSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() { - assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_0)) + fun fromUiSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() { + assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_0)) .isEqualTo(DeviceRotation.Natural) - assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_90)) + assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_90)) .isEqualTo(DeviceRotation.Rotated270) - assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_180)) + assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_180)) .isEqualTo(DeviceRotation.Rotated180) - assertThat(DeviceRotation.fromSurfaceRotation(Surface.ROTATION_270)) + assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_270)) .isEqualTo(DeviceRotation.Rotated90) - assertThat(DeviceRotation.fromSurfaceRotation(-1)) + assertThat(DeviceRotation.fromUiSurfaceRotation(-1)) .isEqualTo(DeviceRotation.Natural) for (rotation in DeviceRotation.entries) { - assertThat(DeviceRotation.fromSurfaceRotation(rotation.toUiSurfaceRotation())) + assertThat(DeviceRotation.fromUiSurfaceRotation(rotation.toUiSurfaceRotation())) .isEqualTo(rotation) } } 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 49f69f9d33..d61521aa2a 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 @@ -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 @@ -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" @@ -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 ) @@ -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 = @@ -133,9 +153,6 @@ fun PreviewScreen( val audioState = remember { derivedStateOf { readyStateProvider().audioUiState } } - val videoRecordingState = remember { - derivedStateOf { readyStateProvider().videoRecordingState } - } CaptureScaffold( captureUiState = readyStateProvider, @@ -172,6 +189,19 @@ 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, + deviceRotation = deviceRotation, + windowRotation = windowRotation + ) DebugOverlay( modifier = slotModifier, onChangeZoomRatio = { ratio -> @@ -195,7 +225,8 @@ fun PreviewScreen( currentRecordingStateProvider = { videoRecordingState.value } ) }, - debugController = debugController + debugController = debugController, + layoutReadoutState = layoutReadoutState ) } } 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 ac1a404be7..e2c5a63f40 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 @@ -15,19 +15,30 @@ */ 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: @@ -35,7 +46,11 @@ import com.google.jetpackcamera.ui.controller.CameraController * 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. @@ -43,6 +58,9 @@ import com.google.jetpackcamera.ui.controller.CameraController * @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]. */ @@ -50,6 +68,8 @@ import com.google.jetpackcamera.ui.controller.CameraController fun CameraSessionEffect( cameraController: CameraController, enabled: Boolean, + isRecording: Boolean = false, + onDeviceRotationChanged: (DeviceRotation) -> Unit = {}, onSessionStarted: () -> Unit = {}, onSessionStopped: () -> Unit = {} ) { @@ -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(null) } + var lastDeliveredRotation by remember(cameraController) { + mutableStateOf(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 +} 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 5e13865c29..a3aa48ac93 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 @@ -43,7 +43,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Constraints import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.jetpackcamera.model.DeviceRotation private const val UI_ROTATION_DURATION_MS = 250 @@ -118,7 +121,26 @@ private fun readCurrentDisplayRotation(view: View, context: Context): DeviceRota val surfaceRotation = runCatching { (view.display ?: ContextCompat.getDisplayOrDefault(context)).rotation }.getOrDefault(Surface.ROTATION_0) - return DeviceRotation.fromSurfaceRotation(surfaceRotation) + return DeviceRotation.fromUiSurfaceRotation(surfaceRotation) +} + +/** + * Collects [debouncedOrientationFlow] while the current [LocalLifecycleOwner] is at least + * [Lifecycle.State.STARTED], returning the latest physical [DeviceRotation] relative to gravity. + */ +@Composable +fun rememberDeviceRotation(): DeviceRotation { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var deviceRotation by remember { mutableStateOf(currentDeviceRotation(context)) } + LaunchedEffect(context, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + debouncedOrientationFlow(context).collect { rotation -> + deviceRotation = rotation + } + } + } + return deviceRotation } /** 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 5c36434273..204dfe61cd 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 @@ -15,10 +15,13 @@ */ package com.google.jetpackcamera.ui.components.capture +import android.app.Activity +import android.content.pm.ActivityInfo import android.graphics.Bitmap import android.net.Uri import android.util.Range import android.view.Surface +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material3.Text @@ -642,6 +645,41 @@ class CaptureScaffoldTest { } } + @Test + fun cameraSessionEffect_whileRecording_locksActivityOrientationAndRestoresOnStop() { + var isRecording by mutableStateOf(false) + var hostActivity: Activity? = null + val cameraController = FakeCameraController() + + composeTestRule.setContent { + val activity = LocalActivity.current + if (hostActivity == null && activity != null) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + hostActivity = activity + } + CameraSessionEffect( + cameraController = cameraController, + enabled = true, + isRecording = isRecording + ) + } + composeTestRule.waitForIdle() + + val activity = checkNotNull(hostActivity) + assertThat(activity.requestedOrientation) + .isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + + composeTestRule.runOnIdle { isRecording = true } + composeTestRule.waitForIdle() + assertThat(activity.requestedOrientation) + .isEqualTo(ActivityInfo.SCREEN_ORIENTATION_LOCKED) + + composeTestRule.runOnIdle { isRecording = false } + composeTestRule.waitForIdle() + assertThat(activity.requestedOrientation) + .isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + @Test fun cameraControlIconButton_rendersAndInvokesOnClick() { var clicked = false diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index bfd05c8fcc..0857c217e3 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -61,6 +61,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -134,68 +135,175 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float } /** - * Window form-factor bucket derived from available window container dimensions in `dp`, matching - * the `360dp` / `480dp` / `600dp` adaptive capture layout mode classification. + * Window container orientation derived from `widthDp` vs. `heightDp`. + */ +enum class DebugWindowOrientation { + PORTRAIT, + HORIZONTAL, + UNKNOWN; + + companion object { + fun fromWindowDp(widthDp: Int, heightDp: Int): DebugWindowOrientation = when { + widthDp <= 0 || heightDp <= 0 -> UNKNOWN + heightDp >= widthDp -> PORTRAIT + else -> HORIZONTAL + } + } +} + +/** + * 7-mode structural window form-factor classification matching `CameraLayoutMode` breakpoints and + * `±7dp` hysteresis deadbands. */ enum class DebugFormFactor { - COMPACT_PORTRAIT, - COMPACT_HORIZONTAL, + COMPACT, COMPACT_SLIM, COMPACT_SHORT, COMPACT_SHORT_SLIM, - TWO_SIDE_RAIL, + FOLDABLE, + TABLET_PORTRAIT, + TABLET_LANDSCAPE, UNKNOWN; + /** + * Default active capture layout composable name corresponding to this [DebugFormFactor]. + */ + val defaultLayoutComposableName: String + get() = when (this) { + COMPACT, + COMPACT_SLIM -> "CompactPortraitLayout" + COMPACT_SHORT, + COMPACT_SHORT_SLIM -> "CompactShortLayout" + FOLDABLE -> "FoldableLayout" + TABLET_PORTRAIT -> "TabletPortraitLayout" + TABLET_LANDSCAPE -> "TabletLandscapeLayout" + UNKNOWN -> "---" + } + + private val isSideRailLargeScreen: Boolean + get() = this == FOLDABLE || this == TABLET_PORTRAIT || this == TABLET_LANDSCAPE + companion object { internal const val SLIM_BREAKPOINT_DP = 360 internal const val SHORT_BREAKPOINT_DP = 480 internal const val MEDIUM_WIDTH_BREAKPOINT_DP = 600 + internal const val FOLDABLE_MAX_SPAN_DP = 960 + internal const val FOLDABLE_MAX_ASPECT_RATIO = 1.45f + internal const val HYSTERESIS_HALF_BAND_DP = 7 internal const val MIN_VALID_SMALLEST_SCREEN_WIDTH_DP = 1 internal const val FULL_SCREEN_DIMENSION_TOLERANCE_DP = 2 /** * Classifies a window container of [widthDp] by [heightDp] into a [DebugFormFactor] - * bucket matching `CameraLayoutMode`'s breakpoint rules. - * - * @param widthDp Available capture container width in `dp`. - * @param heightDp Available capture container height in `dp`. - * @param isFullScreenCompactPhone Whether the container occupies a full-screen compact - * phone window (`sw < 600dp`, not in multi-window mode, with long edge `>= 480dp`). + * bucket matching `CameraLayoutMode`'s 7-mode breakpoint and `±7dp` hysteresis rules. */ fun fromWindowDp( widthDp: Int, heightDp: Int, - isFullScreenCompactPhone: Boolean = false + isFullScreenCompactPhone: Boolean = false, + previousFormFactor: DebugFormFactor? = null ): DebugFormFactor { if (widthDp <= 0 || heightDp <= 0) return UNKNOWN + + val prior = previousFormFactor?.takeIf { it != UNKNOWN } val minDim = minOf(widthDp, heightDp) + val maxDim = maxOf(widthDp, heightDp) + + val isSlim = when (prior) { + null -> minDim < SLIM_BREAKPOINT_DP + COMPACT_SLIM, + COMPACT_SHORT_SLIM -> + minDim < SLIM_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP + else -> minDim < SLIM_BREAKPOINT_DP - HYSTERESIS_HALF_BAND_DP + } + if (isFullScreenCompactPhone) { - return when { - minDim < SLIM_BREAKPOINT_DP -> COMPACT_SLIM - widthDp > heightDp -> COMPACT_HORIZONTAL - else -> COMPACT_PORTRAIT - } + return if (isSlim) COMPACT_SLIM else COMPACT } - return when { - widthDp >= MEDIUM_WIDTH_BREAKPOINT_DP && heightDp >= SHORT_BREAKPOINT_DP -> - TWO_SIDE_RAIL - widthDp < SHORT_BREAKPOINT_DP && heightDp < SHORT_BREAKPOINT_DP -> - if (minDim < SLIM_BREAKPOINT_DP) { - COMPACT_SHORT_SLIM + + val isWide600 = when { + prior == null -> widthDp >= MEDIUM_WIDTH_BREAKPOINT_DP + prior.isSideRailLargeScreen -> + widthDp >= MEDIUM_WIDTH_BREAKPOINT_DP - HYSTERESIS_HALF_BAND_DP + else -> widthDp >= MEDIUM_WIDTH_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP + } + + val isMaxAtLeast480 = when (prior) { + null -> maxDim >= SHORT_BREAKPOINT_DP + COMPACT_SHORT, + COMPACT_SHORT_SLIM -> maxDim >= SHORT_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP + else -> maxDim >= SHORT_BREAKPOINT_DP - HYSTERESIS_HALF_BAND_DP + } + + val isHeightAtLeast480 = when { + prior == null -> heightDp >= SHORT_BREAKPOINT_DP + prior.isSideRailLargeScreen -> + heightDp >= SHORT_BREAKPOINT_DP - HYSTERESIS_HALF_BAND_DP + prior == COMPACT_SHORT || prior == COMPACT_SHORT_SLIM -> + heightDp >= SHORT_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP + prior == COMPACT || prior == COMPACT_SLIM -> + if (heightDp >= widthDp) { + heightDp >= SHORT_BREAKPOINT_DP - HYSTERESIS_HALF_BAND_DP } else { - COMPACT_SHORT + heightDp >= SHORT_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP } - minDim < SLIM_BREAKPOINT_DP -> COMPACT_SLIM - heightDp < SHORT_BREAKPOINT_DP -> COMPACT_HORIZONTAL - else -> COMPACT_PORTRAIT + else -> heightDp >= SHORT_BREAKPOINT_DP + } + + if (isWide600 && isHeightAtLeast480) { + val isUnderFoldableSpan = when (prior) { + FOLDABLE -> maxDim < FOLDABLE_MAX_SPAN_DP + HYSTERESIS_HALF_BAND_DP + TABLET_PORTRAIT, + TABLET_LANDSCAPE -> + maxDim < FOLDABLE_MAX_SPAN_DP - HYSTERESIS_HALF_BAND_DP + else -> maxDim < FOLDABLE_MAX_SPAN_DP + } + + val aspectLimit = minDim * FOLDABLE_MAX_ASPECT_RATIO + val isSquarerFoldableAspect = when (prior) { + FOLDABLE -> maxDim < aspectLimit + HYSTERESIS_HALF_BAND_DP + TABLET_PORTRAIT, + TABLET_LANDSCAPE -> + maxDim < aspectLimit - HYSTERESIS_HALF_BAND_DP + else -> maxDim < aspectLimit + } + + if (isUnderFoldableSpan && isSquarerFoldableAspect) { + return FOLDABLE + } + + return when (prior) { + TABLET_PORTRAIT -> + if (widthDp - heightDp >= HYSTERESIS_HALF_BAND_DP) { + TABLET_LANDSCAPE + } else { + TABLET_PORTRAIT + } + TABLET_LANDSCAPE -> + if (heightDp - widthDp >= HYSTERESIS_HALF_BAND_DP) { + TABLET_PORTRAIT + } else { + TABLET_LANDSCAPE + } + else -> if (heightDp > widthDp) TABLET_PORTRAIT else TABLET_LANDSCAPE + } + } + + return when { + !isMaxAtLeast480 -> if (isSlim) COMPACT_SHORT_SLIM else COMPACT_SHORT + isSlim -> COMPACT_SLIM + else -> COMPACT } } } } +private class PreviousDebugFormFactorHolder(var formFactor: DebugFormFactor? = null) + /** - * Remembers the live [DebugFormFactor] for [widthDp] and [heightDp], factoring in whether the - * host window is a full-screen compact phone (`sw < 600dp` and not in multi-window mode). + * Remembers the live hysteresis-stabilized [DebugFormFactor] for [widthDp] and [heightDp], + * factoring in whether the host window is a full-screen compact phone (`sw < 600dp` and not in + * multi-window mode). */ @Composable fun rememberDebugFormFactor(widthDp: Int, heightDp: Int): DebugFormFactor { @@ -229,11 +337,17 @@ fun rememberDebugFormFactor(widthDp: Int, heightDp: Int): DebugFormFactor { abs(heightDp - configuration.screenHeightDp) <= DebugFormFactor.FULL_SCREEN_DIMENSION_TOLERANCE_DP - return DebugFormFactor.fromWindowDp( + val previousHolder = remember { PreviousDebugFormFactorHolder() } + val resolved = DebugFormFactor.fromWindowDp( widthDp = widthDp, heightDp = heightDp, - isFullScreenCompactPhone = isFullScreenCompactPhone + isFullScreenCompactPhone = isFullScreenCompactPhone, + previousFormFactor = previousHolder.formFactor ) + SideEffect { + previousHolder.formFactor = resolved + } + return resolved } private tailrec fun Context.findDebugActivity(): Activity? = when (this) { @@ -243,15 +357,19 @@ private tailrec fun Context.findDebugActivity(): Activity? = when (this) { } /** - * Snapshot of window container dimensions, derived form factor, and rotation signals displayed by - * the debug console's layout readout. - * - * When passed to [DebugOverlay], these explicit values override the live window-container fallback - * used when layout readout state is omitted. + * Snapshot of window container dimensions, orientation, derived form factor, active posture layout, + * and rotation signals displayed by the debug console's layout readout. * * @property widthDp Current window container width in density-independent pixels. * @property heightDp Current window container height in density-independent pixels. * @property formFactor Calculated [DebugFormFactor] for the window container dimensions. + * @property windowOrientation Derived [DebugWindowOrientation] (`PORTRAIT` when `heightDp >= + * widthDp`; `HORIZONTAL` when `widthDp > heightDp`). + * @property cameraLayoutMode Optional resolved `CameraLayoutMode` enum name when hosted inside + * `CaptureScaffoldLayout`. + * @property activeLayoutName Exact active posture layout composable name being rendered + * (`CompactPortraitLayout`, `CompactShortLayout`, `FoldableLayout`, `TabletPortraitLayout`, or + * `TabletLandscapeLayout`). * @property deviceRotation Physical hardware orientation relative to gravity. * @property windowRotation OS window/display orientation relative to natural posture. */ @@ -260,6 +378,10 @@ data class DebugLayoutReadoutState( val widthDp: Int, val heightDp: Int, val formFactor: DebugFormFactor = DebugFormFactor.fromWindowDp(widthDp, heightDp), + val windowOrientation: DebugWindowOrientation = + DebugWindowOrientation.fromWindowDp(widthDp, heightDp), + val cameraLayoutMode: String? = null, + val activeLayoutName: String = formFactor.defaultLayoutComposableName, val deviceRotation: DeviceRotation = DeviceRotation.Natural, val windowRotation: DeviceRotation = DeviceRotation.Natural ) { @@ -273,22 +395,21 @@ data class DebugLayoutReadoutState( /** * Clockwise rotation in degrees that the capture layout is expected to apply to align its * primary axis with the window's dimensions: - * - [DebugFormFactor.TWO_SIDE_RAIL], [DebugFormFactor.COMPACT_PORTRAIT], - * [DebugFormFactor.COMPACT_SHORT], [DebugFormFactor.COMPACT_SHORT_SLIM], and - * [DebugFormFactor.UNKNOWN] remain upright (`0°`) in the window. - * - [DebugFormFactor.COMPACT_HORIZONTAL] rotates `90°` or `270°` so the portrait layout aligns - * with the window's horizontal length. - * - [DebugFormFactor.COMPACT_SLIM] stays `0°` when `heightDp >= widthDp` and rotates `90°` or - * `270°` when `widthDp > heightDp`. + * - [DebugFormFactor.FOLDABLE], [DebugFormFactor.TABLET_PORTRAIT], + * [DebugFormFactor.TABLET_LANDSCAPE], [DebugFormFactor.COMPACT_SHORT], + * [DebugFormFactor.COMPACT_SHORT_SLIM], and [DebugFormFactor.UNKNOWN] remain upright (`0°`). + * - [DebugFormFactor.COMPACT] and [DebugFormFactor.COMPACT_SLIM] stay `0°` when + * `heightDp >= widthDp` and counter-rotate (`90°` or `270°`) when `widthDp > heightDp`. */ val expectedLayoutRotationDegrees: Int get() = when (formFactor) { - DebugFormFactor.TWO_SIDE_RAIL, - DebugFormFactor.COMPACT_PORTRAIT, + DebugFormFactor.FOLDABLE, + DebugFormFactor.TABLET_PORTRAIT, + DebugFormFactor.TABLET_LANDSCAPE, DebugFormFactor.COMPACT_SHORT, DebugFormFactor.COMPACT_SHORT_SLIM, DebugFormFactor.UNKNOWN -> 0 - DebugFormFactor.COMPACT_HORIZONTAL -> resolveHorizontalLayoutRotationDegrees() + DebugFormFactor.COMPACT, DebugFormFactor.COMPACT_SLIM -> if (heightDp >= widthDp) { 0 @@ -315,14 +436,9 @@ data class DebugLayoutReadoutState( /** * Displays the debug layout and rotation readout as individually tagged rows for window container - * size, [DebugFormFactor], physical device rotation, OS window rotation, expected layout rotation, + * size, [DebugWindowOrientation], [DebugFormFactor], optional `CameraLayoutMode`, exact active + * layout composable name, physical device rotation, OS window rotation, expected layout rotation, * and derived in-window icon rotation. - * - * When [state] is `null`, window dimensions, form factor, and window rotation are derived live from - * [LocalWindowInfo], [LocalDensity], and [LocalView]. - * - * @param state Explicit layout readout values to display, or `null` to use live window values. - * @param modifier Modifier applied to the outer readout container. */ @Composable internal fun DebugLayoutReadout( @@ -333,7 +449,7 @@ internal fun DebugLayoutReadout( state } else { val view = LocalView.current - val liveWindowRotation = DeviceRotation.fromSurfaceRotation( + val liveWindowRotation = DeviceRotation.fromUiSurfaceRotation( runCatching { view.display?.rotation }.getOrNull() ?: Surface.ROTATION_0 ) val (liveWidthDp, liveHeightDp) = with(LocalDensity.current) { @@ -353,17 +469,27 @@ internal fun DebugLayoutReadout( windowRotation = liveWindowRotation ) } + + val orientationText = stringResource( + when (resolvedState.windowOrientation) { + DebugWindowOrientation.PORTRAIT -> R.string.debug_text_window_orientation_portrait + DebugWindowOrientation.HORIZONTAL -> R.string.debug_text_window_orientation_horizontal + DebugWindowOrientation.UNKNOWN -> R.string.debug_text_form_factor_unknown + } + ) val formFactorText = stringResource( when (resolvedState.formFactor) { - DebugFormFactor.COMPACT_PORTRAIT -> R.string.debug_text_form_factor_compact_portrait - DebugFormFactor.COMPACT_HORIZONTAL -> R.string.debug_text_form_factor_compact_horizontal + DebugFormFactor.COMPACT -> R.string.debug_text_form_factor_compact DebugFormFactor.COMPACT_SLIM -> R.string.debug_text_form_factor_compact_slim DebugFormFactor.COMPACT_SHORT -> R.string.debug_text_form_factor_compact_short DebugFormFactor.COMPACT_SHORT_SLIM -> R.string.debug_text_form_factor_compact_short_slim - DebugFormFactor.TWO_SIDE_RAIL -> R.string.debug_text_form_factor_two_side_rail + DebugFormFactor.FOLDABLE -> R.string.debug_text_form_factor_foldable + DebugFormFactor.TABLET_PORTRAIT -> R.string.debug_text_form_factor_tablet_portrait + DebugFormFactor.TABLET_LANDSCAPE -> R.string.debug_text_form_factor_tablet_landscape DebugFormFactor.UNKNOWN -> R.string.debug_text_form_factor_unknown } ) + Column(modifier = modifier.testTag(DEBUG_LAYOUT_READOUT_TAG)) { DebugTextBar( title = stringResource(R.string.debug_text_layout_prefix), @@ -374,11 +500,28 @@ internal fun DebugLayoutReadout( ), tag = DEBUG_WINDOW_SIZE_TAG ) + DebugTextBar( + title = stringResource(R.string.debug_text_window_orientation_prefix), + value = orientationText, + tag = DEBUG_WINDOW_ORIENTATION_TAG + ) DebugTextBar( title = stringResource(R.string.debug_text_form_factor_prefix), value = formFactorText, tag = DEBUG_FORM_FACTOR_TAG ) + resolvedState.cameraLayoutMode?.let { mode -> + DebugTextBar( + title = stringResource(R.string.debug_text_layout_mode_prefix), + value = mode, + tag = DEBUG_LAYOUT_MODE_TAG + ) + } + DebugTextBar( + title = stringResource(R.string.debug_text_active_layout_prefix), + value = resolvedState.activeLayoutName, + tag = DEBUG_ACTIVE_LAYOUT_TAG + ) DebugTextBar( title = stringResource(R.string.debug_text_device_rotation_prefix), value = stringResource( @@ -493,7 +636,7 @@ private fun ToggleVisibilityButton( val stateDescption = if (isHidingComponents) { stringResource(id = R.string.debug_hide_components_desc) } else { - stringResource(R.string.debug_show_components_desc) + stringResource(id = R.string.debug_show_components_desc) } IconButton( @@ -522,16 +665,6 @@ private fun ToggleVisibilityButton( * * When [DebugUiState.Enabled.debugHidingComponents] is `true`, only the visibility toggle button is * shown so the camera feed can be inspected unobstructed. - * - * @param modifier Modifier applied to the root overlay container. - * @param onChangeZoomRatio Callback invoked when the user sets an explicit zoom ratio in the debug - * dialog. - * @param debugUiState Current enabled debug UI state. - * @param extraControls Additional debug action composables rendered alongside the debug menu - * button. - * @param debugController Controller handling visibility, dialog, and test-pattern interactions. - * @param layoutReadoutState Explicit adaptive layout posture readout state, or `null` to derive - * window dimensions from the current window container. */ @Composable fun DebugOverlay( @@ -571,19 +704,6 @@ fun DebugOverlay( } } -/** - * A row of components visible at the top of the debug screen. - * The first button will always be the [DebugDialogContainerToggle], followed by any components passed - * into [extraControls]. - * - * @param debugUiState the current [DebugUiState.Enabled] - * @param onToggleDebugOverlay a callback to open and hide the [DebugDialogContainer] - * @param extraControls additional composable functions to be displayed in the debug top row. - * These should NOT include components intended to be exclusive to the debug screen. - * @param modifier modifier applied to the root console column. - * @param layoutReadoutState explicit layout posture values for [DebugLayoutReadout], or `null` to - * derive window dimensions from the current window container. - */ @Composable private fun DebugConsole( debugUiState: DebugUiState.Enabled, @@ -597,7 +717,6 @@ private fun DebugConsole( verticalArrangement = Arrangement.Center, itemVerticalAlignment = Alignment.CenterVertically ) { - // debug menu button DebugDialogContainerToggle(toggleIsOpen = onToggleDebugOverlay) extraControls.forEach { it() } } @@ -680,7 +799,6 @@ private fun DebugDialogOptionsMenuDialog( onMoveToComponent: (SelectedDialog) -> Unit, onClose: () -> Unit ) { - // Buttons Column( modifier = Modifier .fillMaxSize() @@ -705,7 +823,6 @@ private fun DebugDialogOptionsMenuDialog( ) } - // show camera properties json button Button( modifier = Modifier.testTag( DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON @@ -717,7 +834,6 @@ private fun DebugDialogOptionsMenuDialog( Text(text = "Show Camera Properties JSON") } - // set zoom ratio Button( modifier = Modifier.testTag( DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON @@ -729,7 +845,6 @@ private fun DebugDialogOptionsMenuDialog( Text(text = "Set Zoom Ratio") } - // set test pattern Button( enabled = debugUiState.availableTestPatterns.size > 1, onClick = { @@ -783,7 +898,6 @@ private fun SetZoomRatioDialog(onChangeZoomRatio: (Float) -> Unit, onClose: () - ), onClick = { try { - // no-op if confirmed with empty entry if (zoomRatioText.value.isEmpty()) { onClose() } else { @@ -861,14 +975,15 @@ private enum class SelectedDialog { SetTestPattern } -@Preview(name = "Compact", showBackground = true) +@Preview(name = "Compact Portrait", showBackground = true) @Composable private fun DebugLayoutReadoutCompactPreview() { MaterialTheme(colorScheme = darkColorScheme()) { DebugLayoutReadout( state = DebugLayoutReadoutState( widthDp = 412, - heightDp = 915 + heightDp = 915, + cameraLayoutMode = "COMPACT" ) ) } @@ -880,8 +995,23 @@ private fun DebugLayoutReadoutFoldablePreview() { MaterialTheme(colorScheme = darkColorScheme()) { DebugLayoutReadout( state = DebugLayoutReadoutState( - widthDp = 673, - heightDp = 841 + widthDp = 852, + heightDp = 883, + cameraLayoutMode = "FOLDABLE" + ) + ) + } +} + +@Preview(name = "Tablet Portrait", showBackground = true) +@Composable +private fun DebugLayoutReadoutTabletPortraitPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + DebugLayoutReadout( + state = DebugLayoutReadoutState( + widthDp = 800, + heightDp = 1280, + cameraLayoutMode = "TABLET_PORTRAIT" ) ) } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index a34f9c5880..5d61c7b991 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -23,7 +23,10 @@ const val DEBUG_OVERLAY_BUTTON = "DebugOverlayButton" const val BTN_DEBUG_HIDE_COMPONENTS_TAG = "btn_debug_hide_components" const val DEBUG_LAYOUT_READOUT_TAG = "row_debug_layout_readout" const val DEBUG_WINDOW_SIZE_TAG = "text_debug_window_size" +const val DEBUG_WINDOW_ORIENTATION_TAG = "text_debug_window_orientation" const val DEBUG_FORM_FACTOR_TAG = "text_debug_form_factor" +const val DEBUG_LAYOUT_MODE_TAG = "text_debug_layout_mode" +const val DEBUG_ACTIVE_LAYOUT_TAG = "text_debug_active_layout" const val DEBUG_DEVICE_ROTATION_TAG = "text_debug_device_rotation" const val DEBUG_WINDOW_ROTATION_TAG = "text_debug_window_rotation" const val DEBUG_EXPECTED_LAYOUT_ROTATION_TAG = "text_debug_expected_layout_rotation" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index 9c89423920..109a674610 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -24,24 +24,32 @@ Displaying components %1$.2fx "Layout: " + "Window Orientation: " "Form Factor: " + "Layout Mode: " + "Active Layout: " "Device Rotation: " "Window Rotation: " "Expected Layout Rotation: " "Icon Rotation: " %1$dx%2$ddp %1$d° - COMPACT_PORTRAIT - COMPACT_HORIZONTAL + PORTRAIT + HORIZONTAL + COMPACT COMPACT_SLIM COMPACT_SHORT COMPACT_SHORT_SLIM - TWO_SIDE_RAIL + FOLDABLE + TABLET_PORTRAIT + TABLET_LANDSCAPE UNKNOWN diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 8b594e93fb..116e0ebec6 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -34,8 +34,9 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Robolectric Compose unit tests for [DebugLayoutReadout], [DebugFormFactor], [DebugTextBar] - * modifier scoping, and [DebugOverlay] integration. + * Robolectric Compose unit tests for [DebugLayoutReadout], 7-mode [DebugFormFactor], + * [DebugWindowOrientation], active layout composable reporting, [DebugTextBar] modifier scoping, + * and [DebugOverlay] integration. */ @RunWith(RobolectricTestRunner::class) class DebugLayoutReadoutTest { @@ -44,14 +45,16 @@ class DebugLayoutReadoutTest { val composeTestRule = createComposeRule() @Test - fun debugLayoutReadout_whenExplicitFormFactorProvided_displaysEachTaggedBlock() { + fun debugLayoutReadout_whenExplicitStateProvided_displaysAllTaggedRowsAndActiveLayout() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( state = DebugLayoutReadoutState( widthDp = 673, heightDp = 841, - formFactor = DebugFormFactor.TWO_SIDE_RAIL, + formFactor = DebugFormFactor.FOLDABLE, + cameraLayoutMode = "FOLDABLE", + activeLayoutName = "FoldableLayout", deviceRotation = DeviceRotation.Rotated270, windowRotation = DeviceRotation.Natural ) @@ -60,21 +63,32 @@ class DebugLayoutReadoutTest { } composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Window Orientation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Form Factor: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Layout Mode: ").assertIsDisplayed() + composeTestRule.onNodeWithText("Active Layout: ").assertIsDisplayed() composeTestRule.onNodeWithText("Device Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Window Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Expected Layout Rotation: ").assertIsDisplayed() composeTestRule.onNodeWithText("Icon Rotation: ").assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_WINDOW_ORIENTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_LAYOUT_MODE_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(DEBUG_ACTIVE_LAYOUT_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertIsDisplayed() + assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("673x841dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TWO_SIDE_RAIL") + assertThat(readNodeText(DEBUG_WINDOW_ORIENTATION_TAG)).isEqualTo("PORTRAIT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("FOLDABLE") + assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("FOLDABLE") + assertThat(readNodeText(DEBUG_ACTIVE_LAYOUT_TAG)).isEqualTo("FoldableLayout") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("270°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -83,7 +97,7 @@ class DebugLayoutReadoutTest { } @Test - fun debugLayoutReadout_whenDefaultFormFactorUsed_derivesFormFactorAndDefaultRotations() { + fun debugLayoutReadout_whenDefaultsUsed_derivesOrientationFormFactorAndActiveLayout() { composeTestRule.setContent { MaterialTheme { DebugLayoutReadout( @@ -95,10 +109,10 @@ class DebugLayoutReadoutTest { } } - composeTestRule.onNodeWithText("Layout: ").assertIsDisplayed() - composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT_PORTRAIT") + assertThat(readNodeText(DEBUG_WINDOW_ORIENTATION_TAG)).isEqualTo("PORTRAIT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_ACTIVE_LAYOUT_TAG)).isEqualTo("CompactPortraitLayout") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -147,15 +161,14 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("360x800dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT_PORTRAIT") - assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("0°") - assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") + assertThat(readNodeText(DEBUG_WINDOW_ORIENTATION_TAG)).isEqualTo("PORTRAIT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("COMPACT") + assertThat(readNodeText(DEBUG_ACTIVE_LAYOUT_TAG)).isEqualTo("CompactPortraitLayout") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") - assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°") } @Test - fun debugOverlay_whenExplicitLayoutReadoutStateProvided_displaysExplicitState() { + fun debugOverlay_whenExplicitTabletPortraitStateProvided_displaysTabletPortraitReadout() { composeTestRule.setContent { MaterialTheme { DebugOverlay( @@ -179,7 +192,9 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed() assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("800x1280dp") - assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TWO_SIDE_RAIL") + assertThat(readNodeText(DEBUG_WINDOW_ORIENTATION_TAG)).isEqualTo("PORTRAIT") + assertThat(readNodeText(DEBUG_FORM_FACTOR_TAG)).isEqualTo("TABLET_PORTRAIT") + assertThat(readNodeText(DEBUG_ACTIVE_LAYOUT_TAG)).isEqualTo("TabletPortraitLayout") assertThat(readNodeText(DEBUG_DEVICE_ROTATION_TAG)).isEqualTo("90°") assertThat(readNodeText(DEBUG_WINDOW_ROTATION_TAG)).isEqualTo("0°") assertThat(readNodeText(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG)).isEqualTo("0°") @@ -208,17 +223,14 @@ class DebugLayoutReadoutTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).assertIsDisplayed() composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_WINDOW_ORIENTATION_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_DEVICE_ROTATION_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_WINDOW_ROTATION_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_EXPECTED_LAYOUT_ROTATION_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(DEBUG_ICON_ROTATION_INDICATOR_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(DEBUG_ACTIVE_LAYOUT_TAG).assertDoesNotExist() } @Test - fun debugLayoutReadoutState_expectedLayoutRotationDegrees_handlesFormFactorsAndAspectRatios() { - // Tall compact phone (heightDp >= widthDp) -> 0° + fun debugLayoutReadoutState_expectedLayoutRotationDegrees_handlesAllSevenModesAnd550x500() { + // Portrait compact phone (heightDp >= widthDp) -> 0° assertThat( DebugLayoutReadoutState( widthDp = 412, @@ -228,15 +240,17 @@ class DebugLayoutReadoutTest { ).expectedLayoutRotationDegrees ).isEqualTo(0) - // Upside-down compact phone -> 0° (both icon and layout clamp 180° to 0°) - val upsideDownPhone = DebugLayoutReadoutState( - widthDp = 412, - heightDp = 915, - deviceRotation = DeviceRotation.Rotated180, + // Horizontal compact pane at 550x500dp (W > H even though H >= 480dp!) -> counter-rotates! + val horizontalCompact550x500 = DebugLayoutReadoutState( + widthDp = 550, + heightDp = 500, + deviceRotation = DeviceRotation.Natural, windowRotation = DeviceRotation.Natural ) - assertThat(upsideDownPhone.iconRotationDegrees).isEqualTo(0) - assertThat(upsideDownPhone.expectedLayoutRotationDegrees).isEqualTo(0) + assertThat(horizontalCompact550x500.formFactor).isEqualTo(DebugFormFactor.COMPACT) + assertThat(horizontalCompact550x500.windowOrientation) + .isEqualTo(DebugWindowOrientation.HORIZONTAL) + assertThat(horizontalCompact550x500.expectedLayoutRotationDegrees).isEqualTo(270) // Wide compact window with OS-rotated landscape window (Win 270°, Dev 270°) -> 270° assertThat( @@ -248,16 +262,6 @@ class DebugLayoutReadoutTest { ).expectedLayoutRotationDegrees ).isEqualTo(270) - // Wide compact split-screen (50/50 top/bottom on landscape tablet: Win 0°, Dev 0°) -> 270° - assertThat( - DebugLayoutReadoutState( - widthDp = 1280, - heightDp = 390, - deviceRotation = DeviceRotation.Natural, - windowRotation = DeviceRotation.Natural - ).expectedLayoutRotationDegrees - ).isEqualTo(270) - // Wide slim pane (883x276, Win 0°, Dev 0°) -> 270°; tall slim pane (276x883) -> 0° assertThat( DebugLayoutReadoutState( @@ -276,41 +280,29 @@ class DebugLayoutReadoutTest { ).expectedLayoutRotationDegrees ).isEqualTo(0) - // Compact short & compact short slim stay upright (0°) in WideShortLayout + // Compact short & compact short slim stay upright (0°) in CompactShortLayout assertThat( - DebugLayoutReadoutState( - widthDp = 412, - heightDp = 412, - deviceRotation = DeviceRotation.Natural, - windowRotation = DeviceRotation.Natural - ).expectedLayoutRotationDegrees + DebugLayoutReadoutState(widthDp = 412, heightDp = 412).expectedLayoutRotationDegrees ).isEqualTo(0) assertThat( - DebugLayoutReadoutState( - widthDp = 433, - heightDp = 346, - deviceRotation = DeviceRotation.Natural, - windowRotation = DeviceRotation.Natural - ).expectedLayoutRotationDegrees + DebugLayoutReadoutState(widthDp = 433, heightDp = 346).expectedLayoutRotationDegrees ).isEqualTo(0) - // Wide compact window held at 90° CW with Win 0° -> follows gravity (90°) + // Foldable, TabletPortrait, and TabletLandscape always remain upright (0°) assertThat( DebugLayoutReadoutState( - widthDp = 840, - heightDp = 360, + widthDp = 841, + heightDp = 673, deviceRotation = DeviceRotation.Rotated270, - windowRotation = DeviceRotation.Natural + windowRotation = DeviceRotation.Rotated270 ).expectedLayoutRotationDegrees - ).isEqualTo(90) - - // TWO_SIDE_RAIL always remains upright (0°) regardless of widthDp vs heightDp + ).isEqualTo(0) assertThat( DebugLayoutReadoutState( - widthDp = 841, - heightDp = 673, + widthDp = 800, + heightDp = 1280, deviceRotation = DeviceRotation.Rotated270, - windowRotation = DeviceRotation.Rotated270 + windowRotation = DeviceRotation.Natural ).expectedLayoutRotationDegrees ).isEqualTo(0) assertThat( @@ -324,64 +316,48 @@ class DebugLayoutReadoutTest { } @Test - fun debugFormFactor_fromWindowDp_classifiesAllCameraLayoutModeBuckets() { - // COMPACT_PORTRAIT (W < 600, H >= 480, min >= 360) - assertThat(DebugFormFactor.fromWindowDp(412, 915)) - .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) - assertThat(DebugFormFactor.fromWindowDp(480, 760)) - .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) - assertThat(DebugFormFactor.fromWindowDp(585, 602)) - .isEqualTo(DebugFormFactor.COMPACT_PORTRAIT) - - // COMPACT_HORIZONTAL (W >= 480, H < 480, min >= 360, or full-screen compact phone W > H) - assertThat(DebugFormFactor.fromWindowDp(840, 360)) - .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) - assertThat(DebugFormFactor.fromWindowDp(500, 360)) - .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) - assertThat(DebugFormFactor.fromWindowDp(800, 400)) - .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) + fun debugFormFactor_fromWindowDp_classifiesAllSevenBucketsAndHysteresis() { + // COMPACT (portrait & horizontal) + assertThat(DebugFormFactor.fromWindowDp(412, 915)).isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(840, 360)).isEqualTo(DebugFormFactor.COMPACT) + assertThat(DebugFormFactor.fromWindowDp(550, 500)).isEqualTo(DebugFormFactor.COMPACT) assertThat(DebugFormFactor.fromWindowDp(900, 500, isFullScreenCompactPhone = true)) - .isEqualTo(DebugFormFactor.COMPACT_HORIZONTAL) - - // COMPACT_SLIM (min < 360, max >= 480) - assertThat(DebugFormFactor.fromWindowDp(276, 883)) - .isEqualTo(DebugFormFactor.COMPACT_SLIM) - assertThat(DebugFormFactor.fromWindowDp(883, 276)) - .isEqualTo(DebugFormFactor.COMPACT_SLIM) - assertThat(DebugFormFactor.fromWindowDp(250, 602)) - .isEqualTo(DebugFormFactor.COMPACT_SLIM) - assertThat(DebugFormFactor.fromWindowDp(320, 800, isFullScreenCompactPhone = true)) - .isEqualTo(DebugFormFactor.COMPACT_SLIM) - - // COMPACT_SHORT (W < 480, H < 480, min >= 360) - assertThat(DebugFormFactor.fromWindowDp(412, 412)) - .isEqualTo(DebugFormFactor.COMPACT_SHORT) - assertThat(DebugFormFactor.fromWindowDp(360, 360)) - .isEqualTo(DebugFormFactor.COMPACT_SHORT) - - // COMPACT_SHORT_SLIM (W < 480, H < 480, min < 360) - assertThat(DebugFormFactor.fromWindowDp(433, 346)) - .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) - assertThat(DebugFormFactor.fromWindowDp(346, 433)) - .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) + .isEqualTo(DebugFormFactor.COMPACT) + + // COMPACT_SLIM + assertThat(DebugFormFactor.fromWindowDp(276, 883)).isEqualTo(DebugFormFactor.COMPACT_SLIM) + assertThat(DebugFormFactor.fromWindowDp(883, 276)).isEqualTo(DebugFormFactor.COMPACT_SLIM) + + // COMPACT_SHORT & COMPACT_SHORT_SLIM + assertThat(DebugFormFactor.fromWindowDp(360, 360)).isEqualTo(DebugFormFactor.COMPACT_SHORT) assertThat(DebugFormFactor.fromWindowDp(320, 340)) .isEqualTo(DebugFormFactor.COMPACT_SHORT_SLIM) - // TWO_SIDE_RAIL (W >= 600, H >= 480, when not full-screen compact phone) - assertThat(DebugFormFactor.fromWindowDp(673, 841)) - .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) - assertThat(DebugFormFactor.fromWindowDp(841, 673)) - .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) + // FOLDABLE vs TABLET_PORTRAIT vs TABLET_LANDSCAPE + assertThat(DebugFormFactor.fromWindowDp(673, 841)).isEqualTo(DebugFormFactor.FOLDABLE) + assertThat(DebugFormFactor.fromWindowDp(852, 883)).isEqualTo(DebugFormFactor.FOLDABLE) assertThat(DebugFormFactor.fromWindowDp(800, 1280)) - .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) + .isEqualTo(DebugFormFactor.TABLET_PORTRAIT) assertThat(DebugFormFactor.fromWindowDp(1280, 800)) - .isEqualTo(DebugFormFactor.TWO_SIDE_RAIL) + .isEqualTo(DebugFormFactor.TABLET_LANDSCAPE) + + // ±7dp hysteresis parity around 600dp width + val at604FromCompact = DebugFormFactor.fromWindowDp( + widthDp = 604, + heightDp = 800, + previousFormFactor = DebugFormFactor.COMPACT + ) + val at595FromFoldable = DebugFormFactor.fromWindowDp( + widthDp = 595, + heightDp = 800, + previousFormFactor = DebugFormFactor.FOLDABLE + ) + assertThat(at604FromCompact).isEqualTo(DebugFormFactor.COMPACT) + assertThat(at595FromFoldable).isEqualTo(DebugFormFactor.FOLDABLE) - // UNKNOWN (W <= 0 || H <= 0) - assertThat(DebugFormFactor.fromWindowDp(0, 800)) - .isEqualTo(DebugFormFactor.UNKNOWN) - assertThat(DebugFormFactor.fromWindowDp(412, -1)) - .isEqualTo(DebugFormFactor.UNKNOWN) + // UNKNOWN + assertThat(DebugFormFactor.fromWindowDp(0, 800)).isEqualTo(DebugFormFactor.UNKNOWN) + assertThat(DebugFormFactor.fromWindowDp(412, -1)).isEqualTo(DebugFormFactor.UNKNOWN) } private fun readNodeText(tag: String): String = composeTestRule From 3931a321e47ad296cf3fd4d32e99ff48c31231a7 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 11 Nov 2026 12:37:50 -0800 Subject: [PATCH 13/17] Reuse camera viewfinder string, add JUnit4 runner, and strip redundant SuppressLint --- .../google/jetpackcamera/MainActivityOrientationTest.kt | 3 +++ .../ui/components/capture/CaptureScreenComponentsTest.kt | 3 +-- .../ui/components/capture/CaptureScreenComponents.kt | 2 +- ui/components/capture/src/main/res/values/strings.xml | 2 -- .../ui/components/capture/PreviewDisplayTest.kt | 8 +------- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt index 8dd26b1d2a..022ac02504 100644 --- a/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt +++ b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt @@ -18,10 +18,13 @@ package com.google.jetpackcamera import android.content.pm.ActivityInfo import com.google.common.truth.Truth.assertThat import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 /** * Unit tests for [resolveRequestedOrientation]. */ +@RunWith(JUnit4::class) class MainActivityOrientationTest { @Test diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt index 014ba1629d..438c610818 100644 --- a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt @@ -438,12 +438,11 @@ class CaptureScreenComponentsTest { composeTestRule.onRoot().tryPerformAccessibilityChecks() } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_withSurfaceRequest_passesAccessibilityChecksAndFocusesCenter() { val context = ApplicationProvider.getApplicationContext() val viewfinderDescription = - context.getString(R.string.preview_viewfinder_content_description) + context.getString(R.string.camera_viewfinder_content_description) val density = context.resources.displayMetrics.density val widthPx = (300 * density).roundToInt() val heightPx = (400 * density).roundToInt() 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 1f1c787f64..4d8c6b40d3 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 @@ -1040,7 +1040,7 @@ fun PreviewDisplay( val hasInteractiveBounds = viewfinderSize.width >= minTouchTargetPx && viewfinderSize.height >= minTouchTargetPx val viewfinderDescription = - stringResource(R.string.preview_viewfinder_content_description) + stringResource(R.string.camera_viewfinder_content_description) val focusActionLabel = stringResource(R.string.preview_tap_to_focus_action_label) val focusAtViewfinderOffset: (Offset) -> Unit = { offset -> diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index 61dd5a5cdc..c08726e0de 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -139,8 +139,6 @@ Camera viewfinder - - Camera viewfinder Focus center 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 7e8d653f1e..acd1e1c720 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 @@ -335,7 +335,6 @@ class PreviewDisplayTest { assertThat(targetBoundsState.value).isEqualTo(Rect(0f, 40f, 1280f, 760f)) } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_ltrAndRtl_produceIdenticalTapToFocusCoordinatesAndIndicatorBounds() { val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800) @@ -424,7 +423,6 @@ class PreviewDisplayTest { assertThat(flipCount).isEqualTo(1) } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_updatedCallbacksAndPinchZoom_dispatchLatestHandlers() { val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800) @@ -502,12 +500,11 @@ class PreviewDisplayTest { .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate) } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_viewfinder_exposesCenterFocusSemanticsAndKeyboardActivation() { val context = ApplicationProvider.getApplicationContext() val viewfinderDescription = - context.getString(R.string.preview_viewfinder_content_description) + context.getString(R.string.camera_viewfinder_content_description) val focusActionLabel = context.getString(R.string.preview_tap_to_focus_action_label) assertThat(viewfinderDescription).isEqualTo("Camera viewfinder") assertThat(focusActionLabel).isEqualTo("Focus center") @@ -585,7 +582,6 @@ class PreviewDisplayTest { assertThat(recordedTaps).isEmpty() } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_viewfinderBelowMinimumTouchTarget_disablesFocusAndClickSemantics() { val surfaceRequest = createFakeSurfaceRequest(width = 32, height = 32) @@ -613,7 +609,6 @@ class PreviewDisplayTest { .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick)) } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_captureBlink_skipsZeroAlphaStrobeWhenAnimationsDisabled() { val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400) @@ -659,7 +654,6 @@ class PreviewDisplayTest { } } - @SuppressLint("RestrictedApi") @Test fun previewDisplay_captureBlink_animatesToZeroAndRecoversWhenAnimationsEnabled() { val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400) From 9bdc303528c4cc1ae8610b04aa2b72dbf0f07b8f Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 11 Nov 2026 12:42:30 -0800 Subject: [PATCH 14/17] Wire rotation passthrough in CaptureScaffold, restore debug comments, and prune dead helper Co-authored-by: davidjiagoogle <125502967+davidjiagoogle@users.noreply.github.com> --- .../google/jetpackcamera/model/DeviceRotation.kt | 14 -------------- .../jetpackcamera/model/DeviceRotationTest.kt | 13 +++++++++++++ .../jetpackcamera/feature/preview/PreviewScreen.kt | 3 +++ .../ui/components/capture/CaptureLayout.kt | 2 +- .../ui/components/capture/CaptureScaffold.kt | 9 ++++++++- .../ui/debug/DebugOverlayComponents.kt | 5 +++++ 6 files changed, 30 insertions(+), 16 deletions(-) diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index 61c3ac1591..1b55c13d07 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -65,20 +65,6 @@ enum class DeviceRotation { return if (rawDelta == 180) 0 else rawDelta } - /** - * Returns the clockwise rotation in degrees needed to orient UI elements upright within a - * natural-portrait coordinate frame. - * - * [Rotated180] (reverse portrait) is mapped to `0f` so camera controls do not flip upside-down. - */ - fun toUiRotationDegrees(): Float { - return when (this) { - Natural, Rotated180 -> 0f - Rotated90 -> -90f - Rotated270 -> 90f - } - } - /** * 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` diff --git a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt index cbb3a9b05c..2f6b686e5e 100644 --- a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt +++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt @@ -89,4 +89,17 @@ class DeviceRotationTest { ) ).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() + } } 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 d61521aa2a..46761e8e1d 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 @@ -158,6 +158,8 @@ fun PreviewScreen( captureUiState = readyStateProvider, surfaceRequest = surfaceRequest, modifier = modifier, + deviceRotation = deviceRotation, + windowRotation = windowRotation, onNavigateToSettings = onNavigateToSettings, onRequestWindowColorMode = onRequestWindowColorMode, onRequiresImmersiveChange = onRequiresImmersiveChange, @@ -199,6 +201,7 @@ fun PreviewScreen( widthDp = widthDp, heightDp = heightDp, formFactor = formFactor, + activeLayoutName = "CaptureScaffoldLayout", deviceRotation = deviceRotation, windowRotation = windowRotation ) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index 1473254401..7556a590b4 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -233,7 +233,7 @@ fun CaptureScaffoldLayout( topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, errorDialog: @Composable (Modifier) -> Unit = {}, - deviceRotation: DeviceRotation = DeviceRotation.Natural, + deviceRotation: DeviceRotation = rememberDeviceRotation(), windowRotation: DeviceRotation = rememberWindowRotation() ) { val bottomSheetState = rememberStandardBottomSheetState( diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt index 3b3b2489b1..97d115a785 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt @@ -57,6 +57,7 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CameraError import com.google.jetpackcamera.model.CameraZoomRatio import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.LensToZoom @@ -768,6 +769,8 @@ private fun rememberZoomStateManager( * viewfinder. * @param topBarTrailingContent optional composable slot aligned to the end of the top bar. * @param overlay optional overlay composable slot drawn above the viewfinder and controls. + * @param deviceRotation physical gravity orientation of the device. + * @param windowRotation display/window rotation reported by Android. */ @Composable fun CaptureScaffold( @@ -775,6 +778,8 @@ fun CaptureScaffold( surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, state: CaptureScaffoldState = rememberCaptureScaffoldState(), + deviceRotation: DeviceRotation = rememberDeviceRotation(), + windowRotation: DeviceRotation = rememberWindowRotation(), onNavigateToSettings: () -> Unit = {}, onRequestWindowColorMode: (Int) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, @@ -1315,7 +1320,9 @@ fun CaptureScaffold( { cameraController?.dismissCameraError() } ) } - } + }, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index 0857c217e3..fdb41b0d19 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -799,6 +799,7 @@ private fun DebugDialogOptionsMenuDialog( onMoveToComponent: (SelectedDialog) -> Unit, onClose: () -> Unit ) { + // Buttons Column( modifier = Modifier .fillMaxSize() @@ -823,6 +824,7 @@ private fun DebugDialogOptionsMenuDialog( ) } + // show camera properties json button Button( modifier = Modifier.testTag( DEBUG_OVERLAY_SHOW_CAMERA_PROPERTIES_BUTTON @@ -834,6 +836,7 @@ private fun DebugDialogOptionsMenuDialog( Text(text = "Show Camera Properties JSON") } + // set zoom ratio Button( modifier = Modifier.testTag( DEBUG_OVERLAY_SET_ZOOM_RATIO_BUTTON @@ -845,6 +848,7 @@ private fun DebugDialogOptionsMenuDialog( Text(text = "Set Zoom Ratio") } + // set test pattern Button( enabled = debugUiState.availableTestPatterns.size > 1, onClick = { @@ -898,6 +902,7 @@ private fun SetZoomRatioDialog(onChangeZoomRatio: (Float) -> Unit, onClose: () - ), onClick = { try { + // no-op if confirmed with empty entry if (zoomRatioText.value.isEmpty()) { onClose() } else { From e8d47220c19bf71a4eeb6929e2611437b83b0f5e Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 11 Nov 2026 13:16:03 -0800 Subject: [PATCH 15/17] Integrate layout counter-rotation with 7-mode CaptureScaffold and debug readout Co-authored-by: davidjiagoogle <125502967+davidjiagoogle@users.noreply.github.com> --- .../feature/preview/PreviewScreen.kt | 5 +- .../ui/components/capture/CaptureLayout.kt | 13 +- .../capture/CaptureScreenComponents.kt | 6 +- .../capture/CompactPortraitLayout.kt | 449 +++++++++++------- .../components/capture/RotationModifiers.kt | 2 +- .../capture/SideRailCaptureLayouts.kt | 5 +- .../quicksettings/QuickSettingsScreen.kt | 6 +- .../components/capture/CutoutAwareRowTest.kt | 30 ++ .../components/capture/LayoutRotationTest.kt | 95 ++-- .../capture/RotationModifiersTest.kt | 2 +- .../ui/debug/DebugLayoutReadoutTest.kt | 4 +- 11 files changed, 358 insertions(+), 259 deletions(-) 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 46761e8e1d..5a44ccda7f 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 @@ -44,7 +44,9 @@ import com.google.jetpackcamera.ui.components.capture.CaptureScaffold 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.LocalActiveCaptureLayout import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton +import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode import com.google.jetpackcamera.ui.components.capture.rememberWindowRotation import com.google.jetpackcamera.ui.debug.DebugLayoutReadoutState import com.google.jetpackcamera.ui.debug.DebugOverlay @@ -201,7 +203,8 @@ fun PreviewScreen( widthDp = widthDp, heightDp = heightDp, formFactor = formFactor, - activeLayoutName = "CaptureScaffoldLayout", + cameraLayoutMode = LocalCameraLayoutMode.current.name, + activeLayoutName = LocalActiveCaptureLayout.current, deviceRotation = deviceRotation, windowRotation = windowRotation ) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index 8f1158fe74..c80144cc2f 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.layout.HorizontalAlignmentLine import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode import com.google.jetpackcamera.ui.components.capture.layout.rememberCameraLayoutMode @@ -96,6 +97,8 @@ internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) } * @param onRequiresImmersiveChange invoked with `true` when the window is too short for the * tallest viewfinder to clear a persistent navigation bar, and `false` otherwise or on dispose. * @param errorDialog the camera error alert dialog composable. + * @param deviceRotation physical gravity orientation of the device. + * @param windowRotation display/window rotation reported by Android. */ @Composable fun CaptureScaffoldLayout( @@ -121,11 +124,15 @@ fun CaptureScaffoldLayout( viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, - errorDialog: @Composable (Modifier) -> Unit = {} + errorDialog: @Composable (Modifier) -> Unit = {}, + deviceRotation: DeviceRotation = rememberDeviceRotation(), + windowRotation: DeviceRotation = rememberWindowRotation() ) { CaptureScaffoldLayout( modifier = modifier, state = state, + deviceRotation = deviceRotation, + windowRotation = windowRotation, onDismissQuickSettings = onDismissQuickSettings, viewfinder = viewfinder, captureButton = captureButton, @@ -155,6 +162,8 @@ fun CaptureScaffoldLayout( internal fun CaptureScaffoldLayout( modifier: Modifier = Modifier, state: CaptureScaffoldState = rememberCaptureScaffoldState(), + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current, onDismissQuickSettings: () -> Unit = {}, viewfinder: @Composable (Modifier) -> Unit, captureButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, @@ -224,6 +233,8 @@ internal fun CaptureScaffoldLayout( slots = compactSlots, viewfinder = movableViewfinder, state = state, + deviceRotation = deviceRotation, + windowRotation = windowRotation, controlsVisible = controlsVisible, isErrorDialogShowing = isErrorDialogShowing, includeStatusBarsInTopBar = !isFullScreenCompactPhone, 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 4d8c6b40d3..4e01397bae 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 @@ -1021,9 +1021,11 @@ fun PreviewDisplay( } ) { if (surfaceRequest != null) { + val isCounterRotatedLandscape = LocalWindowRotation.current.isLandscape val implementationMode = when { - Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL - else -> ImplementationMode.EMBEDDED + Build.VERSION.SDK_INT <= 24 || isCounterRotatedLandscape -> + ImplementationMode.EMBEDDED + else -> ImplementationMode.EXTERNAL } 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 690dd713c8..41f24f2008 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 @@ -90,6 +90,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max 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.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds @@ -196,15 +197,19 @@ internal fun Modifier.suppressBackgroundForModal(isModalOpen: Boolean): Modifier * Hosts the camera viewfinder, top status/indicator bar, bottom control stack, and an in-hierarchy * [BottomSheetScaffold] for Quick Settings controlled by [state] without spawning secondary * windows. Tags its root container with [COMPACT_CONTROLS_TAG], provides - * [LocalActiveCaptureLayout] = `"CompactPortraitLayout"`, and scopes [LocalCaptureRtlPolicy] - * (`mirrorCompactBottomBar`) exclusively to the horizontal capture and bottom-toolbar rows while - * restoring the active locale's `LayoutDirection` inside every child slot. + * [LocalActiveCaptureLayout] = `"CompactPortraitLayout"`, counter-rotates into canonical portrait + * coordinates when `windowWidth > windowHeight` (`COMPACT` / `COMPACT_SLIM` horizontal postures), + * and scopes [LocalCaptureRtlPolicy] (`mirrorCompactBottomBar`) exclusively to the horizontal + * capture and bottom-toolbar rows while restoring the active locale's `LayoutDirection` inside + * every child slot. * * @param slots [CaptureSlots] supplying the capture screen UI controls and overlays. * @param viewfinder Composable rendering the camera preview frame. * @param modifier [Modifier] applied to the outer layout container. * @param state Mode-agnostic [CaptureScaffoldState] controlling the Quick Settings bottom sheet and * snackbar host. + * @param deviceRotation Physical gravity orientation of the device. + * @param windowRotation Display/window rotation reported by Android. * @param controlsVisible Whether the camera control slots should be composed and visible. * @param isErrorDialogShowing Whether a modal camera error dialog is currently visible above the * capture chrome. @@ -225,6 +230,8 @@ internal fun CompactPortraitLayout( viewfinder: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, state: CaptureScaffoldState = rememberCaptureScaffoldState(), + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current, controlsVisible: Boolean = true, isErrorDialogShowing: Boolean = false, includeStatusBarsInTopBar: Boolean = true, @@ -283,205 +290,251 @@ internal fun CompactPortraitLayout( onDismissQuickSettings() } - CompositionLocalProvider( - LocalOverlapTargetBounds provides overlapTargetBounds, - LocalActiveCaptureLayout provides "CompactPortraitLayout" + SeamlessRotationAnimationEffect() + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .testTag(COMPACT_CONTROLS_TAG) ) { - Box( - modifier = modifier - .fillMaxSize() - .testTag(COMPACT_CONTROLS_TAG) + val windowWidth = maxWidth + val windowHeight = maxHeight + val shouldCounterRotate = windowWidth > windowHeight + val effectiveDeviceRotation = if ( + deviceRotation == DeviceRotation.Natural && + windowRotation.isLandscape && + shouldCounterRotate ) { - BottomSheetScaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState, - sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, - sheetDragHandle = { - BottomSheetDefaults.DragHandle( - modifier = Modifier - .testTag(QUICK_SETTINGS_DRAG_HANDLE) - .minimumInteractiveComponentSize() - .clickable( - role = Role.Button, - onClickLabel = dismissQuickSettingsLabel, - onClick = handleDismissQuickSettings - ) - ) - }, - sheetShape = RoundedCornerShape( - topStart = CompactPortraitTokens.SheetTopCornerRadius, - topEnd = CompactPortraitTokens.SheetTopCornerRadius - ), - sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - OverlapAwareCaptureSlot( - orientation = Orientation.Vertical, - isCompact = false, - content = slots.quickSettingsOverlay - ) - } - }, - snackbarHost = {} + windowRotation + } else { + deviceRotation + } + val windowGravityDeg = effectiveDeviceRotation.toGravityRotationDegrees(windowRotation) + val effectiveWindowRotation = when { + !shouldCounterRotate -> DeviceRotation.Natural + windowGravityDeg == 90 -> DeviceRotation.Rotated90 + windowGravityDeg == 270 -> DeviceRotation.Rotated270 + windowRotation.isLandscape -> windowRotation + else -> DeviceRotation.Rotated270 + } + val containerCounterDeg = effectiveWindowRotation.toWindowCounterRotationDegrees().toInt() + val containerFrameRotation = DeviceRotation.snapFrom( + ((windowRotation.toClockwiseRotationDegrees() - containerCounterDeg) % 360 + 360) % 360 + ) + val gravityRotationState = rememberGravityRotationState( + deviceRotation = effectiveDeviceRotation, + windowRotation = containerFrameRotation + ) + + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalActiveCaptureLayout provides "CompactPortraitLayout", + LocalDeviceRotation provides effectiveDeviceRotation, + LocalWindowRotation provides effectiveWindowRotation, + LocalGravityRotationState provides gravityRotationState + ) { + Box( + modifier = Modifier + .fillMaxSize() + .counterRotateToPortrait(effectiveWindowRotation) ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { - 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 - if (isHandheldPortrait) { - AdaptivePortraitCaptureLayout( - windowWidth = maxWidth, - windowHeight = maxHeight, - includeStatusBarsInTopBar = includeStatusBarsInTopBar, - controlsVisible = controlsVisible, - modifier = Modifier.suppressBackgroundForModal(isModalOpen), - viewfinder = viewfinder, - topStartContent = topStartContent, - viewfinderTopEndContent = viewfinderTopEndContent, - onRequiresImmersiveChange = onRequiresImmersiveChange, - indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, - zoomControls = zoomLevelDisplay, - imageWell = imageWell, - captureButton = captureButton, - flipCameraButton = flipCameraButton, - captureModeCarousel = captureModeCarousel, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = state.snackbarHostState, - modifier = hostModifier - ) - } - ) - Box( + BottomSheetScaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight, + sheetDragHandle = { + BottomSheetDefaults.DragHandle( modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + .testTag(QUICK_SETTINGS_DRAG_HANDLE) + .minimumInteractiveComponentSize() + .clickable( + role = Role.Button, + onClickLabel = dismissQuickSettingsLabel, + onClick = handleDismissQuickSettings ) - ) { - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection - ) { - snackBar(Modifier, state.snackbarHostState) - } + ) + }, + sheetShape = RoundedCornerShape( + topStart = CompactPortraitTokens.SheetTopCornerRadius, + topEnd = CompactPortraitTokens.SheetTopCornerRadius + ), + sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + sheetContent = { + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = false, + content = slots.quickSettingsOverlay + ) } - } else { - Column(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) { - val minTouchTarget = LocalMinimumInteractiveComponentSize.current - .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } - val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + }, + snackbarHost = {} + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + 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 + if (isHandheldPortrait) { + AdaptivePortraitCaptureLayout( + windowWidth = maxWidth, + windowHeight = maxHeight, + windowRotation = effectiveWindowRotation, includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget - ) - CompactTopBarBox( - topBarHeight = topBarHeight, - topBarTopPadding = topBarTopPadding, + controlsVisible = controlsVisible, + modifier = Modifier.suppressBackgroundForModal(isModalOpen), + viewfinder = viewfinder, + topStartContent = topStartContent, + viewfinderTopEndContent = viewfinderTopEndContent, + onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, - topBarTrailingContent = topBarTrailingContent - ) - Box { - viewfinder(Modifier) - OverlapAwareStyleProvider(Modifier.align(Alignment.TopStart)) { - topStartContent(Modifier) + topBarTrailingContent = topBarTrailingContent, + elapsedTimeDisplay = elapsedTimeDisplay, + zoomControls = zoomLevelDisplay, + imageWell = imageWell, + captureButton = captureButton, + flipCameraButton = flipCameraButton, + captureModeCarousel = captureModeCarousel, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = state.snackbarHostState, + modifier = hostModifier + ) } - OverlapAwareStyleProvider(Modifier.align(Alignment.TopEnd)) { - viewfinderTopEndContent(Modifier) + ) + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) + ) { + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + snackBar(Modifier, state.snackbarHostState) } } - } - - Box( - modifier = Modifier - .fillMaxSize() - .suppressBackgroundForModal(isModalOpen) - .windowInsetsPadding( - WindowInsets.systemBarsIgnoringVisibility - .union(WindowInsets.displayCutout) + } else { + Column(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) { + val minTouchTarget = LocalMinimumInteractiveComponentSize.current + .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } + val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( + includeStatusBarsInTopBar = includeStatusBarsInTopBar, + minTouchTarget = minTouchTarget, + windowRotation = effectiveWindowRotation ) - ) { - if (controlsVisible) { - VerticalMaterialControls( - captureButton = captureButton, - imageWell = imageWell, - flipCameraButton = flipCameraButton, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - captureModeCarousel = captureModeCarousel, - zoomControls = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = state.snackbarHostState, - modifier = hostModifier - ) - } + CompactTopBarBox( + topBarHeight = topBarHeight, + topBarTopPadding = topBarTopPadding, + windowRotation = effectiveWindowRotation, + indicatorRow = indicatorRow, + topBarTrailingContent = topBarTrailingContent ) + Box { + viewfinder(Modifier) + OverlapAwareStyleProvider(Modifier.align(Alignment.TopStart)) { + topStartContent(Modifier) + } + OverlapAwareStyleProvider(Modifier.align(Alignment.TopEnd)) { + viewfinderTopEndContent(Modifier) + } + } } - CompositionLocalProvider( - LocalLayoutDirection provides localeDirection + + Box( + modifier = Modifier + .fillMaxSize() + .suppressBackgroundForModal(isModalOpen) + .windowInsetsPadding( + WindowInsets.systemBarsIgnoringVisibility + .union(WindowInsets.displayCutout) + .toPortraitWindowInsets(effectiveWindowRotation) + ) ) { - snackBar(Modifier, state.snackbarHostState) + if (controlsVisible) { + VerticalMaterialControls( + windowRotation = effectiveWindowRotation, + captureButton = captureButton, + imageWell = imageWell, + flipCameraButton = flipCameraButton, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + captureModeCarousel = captureModeCarousel, + zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay, + snackbarHost = { hostModifier -> + PillSnackbarHost( + snackbarHostState = state.snackbarHostState, + modifier = hostModifier + ) + } + ) + } + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + snackBar(Modifier, state.snackbarHostState) + } } } - } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - OverlapAwareStyleProvider( - modifier = Modifier.suppressBackgroundForModal(isModalOpen) - ) { - overlay(Modifier) + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + OverlapAwareStyleProvider( + modifier = Modifier.suppressBackgroundForModal(isModalOpen) + ) { + overlay(Modifier) + } } - } - if (isSheetVisible && !isErrorModalActive) { - Box( - modifier = Modifier - .fillMaxSize() - .testTag(QUICK_SETTINGS_SCRIM) - .semantics { - contentDescription = dismissQuickSettingsLabel - traversalIndex = 1f - } - .clickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = handleDismissQuickSettings - ) - ) - } + if (isSheetVisible && !isErrorModalActive) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(QUICK_SETTINGS_SCRIM) + .semantics { + contentDescription = dismissQuickSettingsLabel + traversalIndex = 1f + } + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = handleDismissQuickSettings + ) + ) + } - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - Box( - modifier = Modifier.onSizeChanged { size -> - isErrorDialogPlaced = size.width > 0 && size.height > 0 + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + Box( + modifier = Modifier.onSizeChanged { size -> + isErrorDialogPlaced = size.width > 0 && size.height > 0 + } + ) { + errorDialog(Modifier) } - ) { - errorDialog(Modifier) } } } + // Drawn last and outside of both the inset-padded controls and the bottom sheet so + // the flash covers the entire window, including system bar and cutout areas. + screenFlashOverlay(Modifier) } - // Drawn last and outside of both the inset-padded controls and the bottom sheet so the - // flash covers the entire window, including system bar and display cutout areas. - screenFlashOverlay(Modifier) } } } @@ -489,13 +542,16 @@ internal fun CompactPortraitLayout( @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, - minTouchTarget: Dp + minTouchTarget: Dp, + windowRotation: DeviceRotation = LocalWindowRotation.current ): Pair { - val cutoutTopInset = WindowInsets.displayCutout.asPaddingValues().calculateTopPadding() + val portraitCutoutInsets = WindowInsets.displayCutout.toPortraitWindowInsets(windowRotation) + val cutoutTopInset = portraitCutoutInsets.asPaddingValues().calculateTopPadding() return if (includeStatusBarsInTopBar) { - val statusBarTopInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val combinedTopInset = WindowInsets.statusBars - .union(WindowInsets.displayCutout) + val portraitStatusBars = WindowInsets.statusBars.toPortraitWindowInsets(windowRotation) + val statusBarTopInset = portraitStatusBars.asPaddingValues().calculateTopPadding() + val combinedTopInset = portraitStatusBars + .union(portraitCutoutInsets) .asPaddingValues() .calculateTopPadding() max(combinedTopInset, statusBarTopInset + minTouchTarget) to statusBarTopInset @@ -510,7 +566,8 @@ private fun CompactTopBarBox( topBarTopPadding: Dp, indicatorRow: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current ) { val localeDirection = LocalLayoutDirection.current CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { @@ -520,7 +577,9 @@ private fun CompactTopBarBox( .height(topBarHeight) .padding(top = topBarTopPadding) .windowInsetsPadding( - WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal) + WindowInsets.displayCutout + .toPortraitWindowInsets(windowRotation) + .only(WindowInsetsSides.Horizontal) ) .padding(horizontal = CompactPortraitTokens.LowerSectionHorizontalPadding), contentAlignment = Alignment.CenterStart @@ -680,6 +739,7 @@ internal fun AdaptivePortraitCaptureLayout( captureModeToggleSwitch: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, controlsVisible: Boolean, modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current, includeStatusBarsInTopBar: Boolean = true, snackbarHost: @Composable (Modifier) -> Unit = {} ) { @@ -692,9 +752,11 @@ internal fun AdaptivePortraitCaptureLayout( } val navInset = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val rawGestureInset = WindowInsets.systemGestures + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset) @@ -702,7 +764,8 @@ internal fun AdaptivePortraitCaptureLayout( .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget } val (topBarHeight, topBarTopPadding) = computeTopBarMetrics( includeStatusBarsInTopBar = includeStatusBarsInTopBar, - minTouchTarget = minTouchTarget + minTouchTarget = minTouchTarget, + windowRotation = windowRotation ) val window = remember( @@ -759,6 +822,7 @@ internal fun AdaptivePortraitCaptureLayout( CompactTopBarBox( topBarHeight = topBarHeight, topBarTopPadding = topBarTopPadding, + windowRotation = windowRotation, modifier = Modifier.layoutId(CaptureSlotId.TopBar), indicatorRow = indicatorRow, topBarTrailingContent = topBarTrailingContent @@ -943,6 +1007,7 @@ internal fun AdaptivePortraitCaptureLayout( @Composable internal fun VerticalMaterialControls( modifier: Modifier = Modifier, + windowRotation: DeviceRotation = LocalWindowRotation.current, captureButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, zoomControls: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, imageWell: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit, @@ -966,6 +1031,7 @@ internal fun VerticalMaterialControls( contentAlignment = Alignment.BottomCenter ) { val navBarBottom = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(windowRotation) .asPaddingValues() .calculateBottomPadding() val isShortScreen = maxHeight < CompactPortraitTokens.ShortScreenThreshold @@ -1080,6 +1146,21 @@ private fun CompactPortraitShortPhonePreview() { } } +@Preview( + name = "CompactPortrait - Phone Horizontal (800x400dp)", + device = "spec:width=800dp,height=400dp", + showBackground = true +) +@Composable +private fun CompactPortraitHorizontalPhonePreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CompactPortraitLayout( + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true), + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) } + ) + } +} + @Preview( name = "CompactPortrait - RTL Default Policy (Unmirrored Bar)", device = "spec:width=412dp,height=915dp", 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 b5a4aeaf8f..5c8478ceed 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 @@ -60,7 +60,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.google.jetpackcamera.model.DeviceRotation private const val UI_ROTATION_DURATION_MS = 250 -private val DefaultGravityRotationState: State = mutableFloatStateOf(0f) +internal val DefaultGravityRotationState: State = mutableFloatStateOf(0f) /** * Provides the current physical gravity [DeviceRotation] to descendant composables in the capture diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt index 21e1a0481a..3875f776de 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max +import com.google.jetpackcamera.model.DeviceRotation // TODO(#648): Replace with QUICK_SETTINGS_OVERLAY_CARD_TAG from QuickSettingsOverlayCard once #648 // merges into main. @@ -292,7 +293,9 @@ internal fun SideRailCaptureScaffold( CompositionLocalProvider( LocalOverlapTargetBounds provides overlapTargetBounds, - LocalActiveCaptureLayout provides config.activeLayoutName + LocalActiveCaptureLayout provides config.activeLayoutName, + LocalWindowRotation provides DeviceRotation.Natural, + LocalGravityRotationState provides DefaultGravityRotationState ) { Box( modifier = modifier diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt index 75447f5daa..35e4f3066a 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt @@ -37,6 +37,7 @@ import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.ui.components.capture.LocalWindowRotation import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_BOTTOM_SHEET import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.AspectRatioRow @@ -44,6 +45,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.CaptureMo import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickNavSettings +import com.google.jetpackcamera.ui.components.capture.toPortraitWindowInsets import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState @@ -72,10 +74,12 @@ fun QuickSettingsScaffoldContent( showMoreSettingsButton: Boolean = true ) { if (quickSettingsUiState is QuickSettingsUiState.Available) { + val portraitNavBars = WindowInsets.navigationBarsIgnoringVisibility + .toPortraitWindowInsets(LocalWindowRotation.current) Column( modifier = modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) + .windowInsetsPadding(portraitNavBars) .padding(bottom = 24.dp) .testTag(QUICK_SETTINGS_BOTTOM_SHEET) ) { diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt index 2146ed02f3..7b8a50f65c 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt @@ -43,6 +43,7 @@ import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import com.google.jetpackcamera.model.DEFAULT_HDR_DYNAMIC_RANGE import com.google.jetpackcamera.model.DEFAULT_HDR_IMAGE_OUTPUT +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality @@ -315,6 +316,35 @@ class CutoutAwareRowTest { rtlReferenceWidth = 360 ) ).containsExactly(IntRect(left = 310, top = 0, right = 350, bottom = 40)) + + // Verify fallbackViewWidth / fallbackViewHeight when rowBoundsInWindow is null on pass 1. + val landscapeLeftCutout = listOf(android.graphics.Rect(0, 300, 48, 340)) + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = landscapeLeftCutout, + rowBoundsInWindow = null, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated270, + fallbackViewWidth = 800, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) + + val landscapeRightCutout = listOf(android.graphics.Rect(752, 20, 800, 60)) + assertThat( + resolveLocalCutoutRects( + cutoutRectsOverride = null, + windowCutoutRects = landscapeRightCutout, + rowBoundsInWindow = null, + isRtl = false, + rtlReferenceWidth = 360, + windowRotation = DeviceRotation.Rotated90, + fallbackViewWidth = 800, + fallbackViewHeight = 360 + ) + ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48)) } @Test 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 6386cf37eb..57e5cf5c72 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 @@ -19,7 +19,6 @@ import android.graphics.Rect as AndroidRect import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -64,10 +63,10 @@ private object LayoutRotationTestTokens { /** * Robolectric Compose UI tests verifying layout-level counter-rotation scoping across full-screen - * compact handhelds, portrait split-screen panes on rotated phones, side-rail layouts, and - * [CutoutAwareRow] cutout remapping. + * 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. */ -@OptIn(ExperimentalMaterial3Api::class) @RunWith(RobolectricTestRunner::class) class LayoutRotationTest { @@ -75,7 +74,7 @@ class LayoutRotationTest { val composeTestRule = createComposeRule() @Composable - private fun RotationPreviewUnderTest( + private fun RotationLayoutUnderTest( deviceRotation: DeviceRotation, windowRotation: DeviceRotation, modifier: Modifier = Modifier, @@ -90,7 +89,7 @@ class LayoutRotationTest { ) { MaterialTheme { CompositionLocalProvider(LocalDisableAnimations provides true) { - PreviewLayout( + CaptureScaffoldLayout( modifier = modifier, deviceRotation = deviceRotation, windowRotation = windowRotation, @@ -173,12 +172,7 @@ class LayoutRotationTest { ) .testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) - }, - quickSettingsOverlay = {}, - debugOverlay = {}, - debugVisibilityWrapper = { content -> content() }, - screenFlashOverlay = {}, - snackBar = { _, _ -> } + } ) } } @@ -195,7 +189,7 @@ class LayoutRotationTest { var observedViewfinderSize = IntSize.Zero composeTestRule.setContent { - RotationPreviewUnderTest( + RotationLayoutUnderTest( deviceRotation = windowRotation, windowRotation = windowRotation, onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> @@ -209,13 +203,10 @@ class LayoutRotationTest { } composeTestRule.waitForIdle() - // Rotated90 (+90° CW counter-rotation): solves portrait 360x740dp, top bar on right edge, - // shutter button on left side of the 740x360dp window. - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated90) assertThat(observedSolution).isNotNull() assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) - // Inside +90° container, gravity compensation is -90° (270°) so icons net to 0° in window. assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f) val captureBand90 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) @@ -233,12 +224,10 @@ class LayoutRotationTest { assertThat(captureRotated90.center.x).isWithin(1f).of(expectedCaptureCenterX90) assertThat(captureRotated90.center.y).isWithin(1f).of(180f) - // Rotated270 (-90° CCW counter-rotation): solves portrait 360x740dp, top bar on left edge, - // shutter button on right side of the 740x360dp window. windowRotation = DeviceRotation.Rotated270 composeTestRule.waitForIdle() - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) assertThat(observedSolution).isNotNull() assertThat(observedViewfinderSize).isEqualTo(IntSize(360, 740)) @@ -270,7 +259,7 @@ class LayoutRotationTest { var observedViewfinderSize = IntSize.Zero composeTestRule.setContent { - RotationPreviewUnderTest( + RotationLayoutUnderTest( modifier = Modifier.size(width = 380.dp, height = 760.dp), deviceRotation = DeviceRotation.Rotated90, windowRotation = DeviceRotation.Rotated90, @@ -285,75 +274,60 @@ class LayoutRotationTest { } composeTestRule.waitForIdle() - // 380x760dp split-screen pane has windowWidth <= windowHeight, so it stays upright (0°) - // without swapping constraints even though the host display is at Rotated90. - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Natural) assertThat(observedGravityDeg).isEqualTo(0f) assertThat(observedSolution).isNotNull() assertThat(observedViewfinderSize).isEqualTo(IntSize(380, 760)) - - val captureBand = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW) - val expectedCaptureCenterY = (captureBand.top.value + captureBand.bottom.value) / 2f - val indicatorBounds = composeTestRule - .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG) - .fetchSemanticsNode() - .boundsInRoot - val captureBounds = composeTestRule - .onNodeWithTag(CAPTURE_BUTTON) - .fetchSemanticsNode() - .boundsInRoot - assertThat(indicatorBounds.top).isLessThan(60f) - assertThat(captureBounds.center.x).isWithin(1f).of(190f) - assertThat(captureBounds.center.y).isWithin(1f).of(expectedCaptureCenterY) - assertThat(captureBounds.top).isGreaterThan(indicatorBounds.bottom) } @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") @Test - fun compactHorizontalSplitScreen_uprightWindow_rotatesCompactLayout90DegreesToFillSpace() { + fun compactHorizontalPanes_800x400And550x500_rotateCompactLayout90DegreesToFillSpace() { + var paneSize by mutableStateOf(DpSize(800.dp, 400.dp)) var observedMode: CameraLayoutMode? = null var observedEffectiveWinRot: DeviceRotation? = null var observedGravityDeg: Float? = null - var observedSolution: CameraLayoutSolution? = null var observedViewfinderSize = IntSize.Zero composeTestRule.setContent { - RotationPreviewUnderTest( - modifier = Modifier.size(width = 800.dp, height = 400.dp), + RotationLayoutUnderTest( + modifier = Modifier.size(paneSize), deviceRotation = DeviceRotation.Natural, windowRotation = DeviceRotation.Natural, - onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize -> + onViewfinderObserved = { mode, effWinRot, gravDeg, _, vfSize -> observedMode = mode observedEffectiveWinRot = effWinRot observedGravityDeg = gravDeg - observedSolution = solution observedViewfinderSize = vfSize } ) } composeTestRule.waitForIdle() - // 800x400dp horizontal split-screen resolves to COMPACT_HORIZONTAL, rotating - // CompactPortraitLayout -90° CCW (Rotated270) so it solves 400x800dp horizontally and - // rotates interior icons +90° CW to remain 0° upright in the Natural window! - assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) - assertThat(observedSolution).isNotNull() assertThat(observedViewfinderSize).isEqualTo(IntSize(400, 800)) assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) + + // 550x500dp (W > H with H >= 480dp) also resolves to COMPACT and counter-rotates cleanly! + paneSize = DpSize(550.dp, 500.dp) + composeTestRule.waitForIdle() + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Rotated270) + assertThat(((observedGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f) } - @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Config(qualifiers = "sw800dp-w1400dp-h1400dp-mdpi") @Test - fun twoSideRailLayout_andCompactShortLayout_neverCounterRotate() { + fun foldableTabletAndCompactShortLayouts_neverCounterRotate() { var containerSize by mutableStateOf(DpSize(1280.dp, 800.dp)) var windowRotation by mutableStateOf(DeviceRotation.Rotated90) var observedMode: CameraLayoutMode? = null var observedViewfinderSize = IntSize.Zero composeTestRule.setContent { - RotationPreviewUnderTest( + RotationLayoutUnderTest( modifier = Modifier.size(containerSize), deviceRotation = windowRotation, windowRotation = windowRotation, @@ -366,8 +340,9 @@ class LayoutRotationTest { } val testPostures = listOf( - Triple(DpSize(1280.dp, 800.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(1280, 800)), - Triple(DpSize(883.dp, 852.dp), CameraLayoutMode.TWO_SIDE_RAIL, IntSize(883, 852)), + Triple(DpSize(1280.dp, 800.dp), CameraLayoutMode.TABLET_LANDSCAPE, IntSize(1280, 800)), + Triple(DpSize(800.dp, 1280.dp), CameraLayoutMode.TABLET_PORTRAIT, IntSize(800, 1280)), + Triple(DpSize(883.dp, 852.dp), CameraLayoutMode.FOLDABLE, IntSize(883, 852)), Triple(DpSize(440.dp, 412.dp), CameraLayoutMode.COMPACT_SHORT, IntSize(440, 412)) ) @@ -392,7 +367,6 @@ class LayoutRotationTest { .fetchSemanticsNode() .boundsInRoot - // Side rails remain upright on left and right edges (never counter-rotated 90°). assertThat(startRailBounds.left).isLessThan(endRailBounds.left) assertThat(startRailBounds.height).isGreaterThan(startRailBounds.width) assertThat(endRailBounds.height).isGreaterThan(endRailBounds.width) @@ -402,10 +376,6 @@ class LayoutRotationTest { @Test fun cutoutAwareRow_remapsCutoutsUnderRotated90AndRotated270() { - // Portrait top bar (width=360, height=48) counter-rotated inside a 740x360 landscape window: - // - Under Rotated90 (+90° CW): rowBoundsInWindow is [692, 0, 740, 360]. - // Window right-edge cutout [692, 20, 740, 60] maps to portrait [20, 0, 60, 48] in LTR, - // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). val rotated90RowBounds = IntRect(left = 692, top = 0, right = 740, bottom = 360) val rotated90WindowCutout = listOf(AndroidRect(692, 20, 740, 60)) @@ -435,9 +405,6 @@ class LayoutRotationTest { ) ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) - // - Under Rotated270 (-90° CCW): rowBoundsInWindow is [0, 0, 48, 360]. - // Window left-edge cutout [0, 300, 48, 340] maps to portrait [20, 0, 60, 48] in LTR, - // and [300, 0, 340, 48] in RTL (startEdgeX = rowBoundsInWindow.height = 360). val rotated270RowBounds = IntRect(left = 0, top = 0, right = 48, bottom = 360) val rotated270WindowCutout = listOf(AndroidRect(0, 300, 48, 340)) @@ -467,7 +434,6 @@ class LayoutRotationTest { ) ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48)) - // Also verify CutoutAwareRow composes cleanly when LocalWindowRotation is landscape. var placedSegments = emptyList() composeTestRule.setContent { CompositionLocalProvider( @@ -486,7 +452,6 @@ class LayoutRotationTest { } composeTestRule.waitForIdle() assertThat(placedSegments).hasSize(1) - // Item + 2*8dp pill padding = 40px, shifted past cutout [20..60] + 8px clearance = 68px. assertThat(placedSegments.single().left).isAtLeast(68) } } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index ee45984cba..3cc5ee965b 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -182,7 +182,7 @@ class RotationModifiersTest { assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot) .isEqualTo(Rect(692f, 0f, 740f, 360f)) - // Rotated270: swaps constraints to 360x740 and counter-rotates -90° CCW -> top strip on left + // Rotated270: swaps constraints to 360x740 and counter-rotates -90° CCW -> top on left. windowRotation = DeviceRotation.Rotated270 composeTestRule.waitForIdle() assertThat(outerSize).isEqualTo(IntSize(740, 360)) diff --git a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt index 27178547ca..c07c41dd74 100644 --- a/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -367,13 +367,13 @@ class DebugLayoutReadoutTest { state = DebugLayoutReadoutState( widthDp = 800, heightDp = 400, - cameraLayoutMode = "COMPACT_HORIZONTAL" + cameraLayoutMode = "COMPACT" ) ) } composeTestRule.waitForIdle() - assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("COMPACT_HORIZONTAL") + assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("COMPACT") } private fun readNodeText(tag: String): String = composeTestRule From 5796d45096ae98bc54d50ebd809a18eb6b40c56c Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 13 Nov 2026 08:45:53 -0800 Subject: [PATCH 16/17] refactor(ui/capture): snap gravity rotation by default and keep 90-degree animation toggle --- .../components/capture/RotationModifiers.kt | 20 +++++-- .../capture/RotationModifiersTest.kt | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) 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 a3aa48ac93..cc2c4a6004 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 @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -148,13 +149,16 @@ fun rememberDeviceRotation(): DeviceRotation { * oriented upright against gravity inside the current window: * - Uses shortest-path angle interpolation (`±90°`) so transitions across `0° <-> 270°` never spin * `270°` the long way around. - * - Snaps immediately when [windowRotation] changes (since the OS is already animating the window - * rotation) or when [LocalDisableAnimations] is `true`. + * - Snaps immediately by default ([animateQuarterTurns] = `false`), or when [windowRotation] + * changes (since the OS is already animating the window rotation), when [LocalDisableAnimations] + * is `true`, or on a `180°` half-turn flip (`90° <-> 270°`). When [animateQuarterTurns] is + * `true`, `90°` quarter-turns animate smoothly while `180°` flips still snap immediately. */ @Composable fun rememberGravityRotationState( deviceRotation: DeviceRotation = LocalDeviceRotation.current, - windowRotation: DeviceRotation = LocalWindowRotation.current + windowRotation: DeviceRotation = LocalWindowRotation.current, + animateQuarterTurns: Boolean = false ): State { val targetClockwiseDegrees = deviceRotation.toGravityRotationDegrees(windowRotation) var continuousTarget by remember { mutableFloatStateOf(targetClockwiseDegrees.toFloat()) } @@ -167,15 +171,21 @@ fun rememberGravityRotationState( val currentMod = ((continuousTarget % 360f) + 360f) % 360f val rawDelta = ((targetClockwiseDegrees - currentMod + 540f) % 360f) - 180f + val isHalfTurnFlip = rawDelta == -180f || rawDelta == 180f val delta = if (rawDelta == -180f) 180f else rawDelta - if (delta != 0f) { + var snapCurrentTransition by remember { mutableStateOf(false) } + if (delta != 0f || windowRotationChanged) { + snapCurrentTransition = windowRotationChanged || isHalfTurnFlip continuousTarget += delta } val disableAnimations = LocalDisableAnimations.current + if (!animateQuarterTurns || disableAnimations) { + return rememberUpdatedState(continuousTarget) + } return animateFloatAsState( targetValue = continuousTarget, - animationSpec = if (disableAnimations || windowRotationChanged) { + animationSpec = if (snapCurrentTransition) { snap() } else { tween( diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index 658896dc9e..00cb334052 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -74,6 +75,60 @@ class RotationModifiersTest { assertThat(currentDegrees).isEqualTo(90f) } + @Test + fun rememberGravityRotationState_snapsByDefaultAndSupportsQuarterTurnAnimationWith180Snap() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + var animateQuarterTurns by mutableStateOf(false) + var currentDegrees = 0f + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides false) { + val state = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural, + animateQuarterTurns = animateQuarterTurns + ) + currentDegrees = state.value + } + } + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = false + try { + // Default (animateQuarterTurns = false): 90° turn snaps immediately without waiting 250ms + deviceRotation = DeviceRotation.Rotated270 + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeByFrame() + assertThat(currentDegrees).isEqualTo(90f) + + // Enable quarter-turn animation: 90° -> 0° animates across frames over 250ms + animateQuarterTurns = true + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeByFrame() + + deviceRotation = DeviceRotation.Natural + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(125L) + assertThat(currentDegrees).isGreaterThan(0f) + assertThat(currentDegrees).isLessThan(90f) + composeTestRule.mainClock.advanceTimeBy(250L) + assertThat(currentDegrees).isEqualTo(0f) + + // Quarter-turn to 90° CCW (gravity +90°), then 180° flip to 90° CW (gravity -90°/270°): + // the 180° flip must snap immediately even when animateQuarterTurns = true + deviceRotation = DeviceRotation.Rotated270 + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(350L) + assertThat(currentDegrees).isEqualTo(90f) + + deviceRotation = DeviceRotation.Rotated90 + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(32L) + assertThat(((currentDegrees % 360f) + 360f) % 360f).isEqualTo(270f) + } finally { + composeTestRule.mainClock.autoAdvance = true + } + } + @Test fun rotateLayoutToGravity_swapsOuterDimensionsAt90And270Degrees() { var outerSize = IntSize.Zero From f0f63d1fc81a291fdc4c8915d44f3d58d5d81ec9 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 13 Nov 2026 08:45:53 -0800 Subject: [PATCH 17/17] refactor(ui/capture): animate 90-degree gravity turns by default and snap 180-degree flips --- .../components/capture/RotationModifiers.kt | 20 +++++-- .../capture/RotationModifiersTest.kt | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) 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 a3aa48ac93..d289f6628c 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 @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed @@ -148,13 +149,16 @@ fun rememberDeviceRotation(): DeviceRotation { * oriented upright against gravity inside the current window: * - Uses shortest-path angle interpolation (`±90°`) so transitions across `0° <-> 270°` never spin * `270°` the long way around. - * - Snaps immediately when [windowRotation] changes (since the OS is already animating the window - * rotation) or when [LocalDisableAnimations] is `true`. + * - Animates `90°` quarter-turns smoothly by default ([animateQuarterTurns] = `true`), while + * snapping immediately on a `180°` half-turn flip (`90° <-> 270°`), when [windowRotation] changes + * (since the OS is already animating the window rotation), when [LocalDisableAnimations] is + * `true`, or when [animateQuarterTurns] is `false`. */ @Composable fun rememberGravityRotationState( deviceRotation: DeviceRotation = LocalDeviceRotation.current, - windowRotation: DeviceRotation = LocalWindowRotation.current + windowRotation: DeviceRotation = LocalWindowRotation.current, + animateQuarterTurns: Boolean = true ): State { val targetClockwiseDegrees = deviceRotation.toGravityRotationDegrees(windowRotation) var continuousTarget by remember { mutableFloatStateOf(targetClockwiseDegrees.toFloat()) } @@ -167,15 +171,21 @@ fun rememberGravityRotationState( val currentMod = ((continuousTarget % 360f) + 360f) % 360f val rawDelta = ((targetClockwiseDegrees - currentMod + 540f) % 360f) - 180f + val isHalfTurnFlip = rawDelta == -180f || rawDelta == 180f val delta = if (rawDelta == -180f) 180f else rawDelta - if (delta != 0f) { + var snapCurrentTransition by remember { mutableStateOf(false) } + if (delta != 0f || windowRotationChanged) { + snapCurrentTransition = windowRotationChanged || isHalfTurnFlip continuousTarget += delta } val disableAnimations = LocalDisableAnimations.current + if (!animateQuarterTurns || disableAnimations) { + return rememberUpdatedState(continuousTarget) + } return animateFloatAsState( targetValue = continuousTarget, - animationSpec = if (disableAnimations || windowRotationChanged) { + animationSpec = if (snapCurrentTransition) { snap() } else { tween( diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index 658896dc9e..14dfc69034 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -74,6 +75,61 @@ class RotationModifiersTest { assertThat(currentDegrees).isEqualTo(90f) } + @Test + fun rememberGravityRotationState_animatesQuarterTurnsByDefaultAndSnapsOn180Flip() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + var animateQuarterTurns by mutableStateOf(true) + var currentDegrees = 0f + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides false) { + val state = if (animateQuarterTurns) { + rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + } else { + rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural, + animateQuarterTurns = false + ) + } + currentDegrees = state.value + } + } + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = false + try { + // Default (animateQuarterTurns = true): 0° -> 90° animates across frames over 250ms + deviceRotation = DeviceRotation.Rotated270 + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(125L) + assertThat(currentDegrees).isGreaterThan(0f) + assertThat(currentDegrees).isLessThan(90f) + composeTestRule.mainClock.advanceTimeBy(250L) + assertThat(currentDegrees).isEqualTo(90f) + + // 180° flip from 90° CCW (gravity +90°) to 90° CW (gravity -90°/270°) snaps immediately + deviceRotation = DeviceRotation.Rotated90 + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(32L) + assertThat(((currentDegrees % 360f) + 360f) % 360f).isEqualTo(270f) + + // With animateQuarterTurns = false: 90° turn back to 0° snaps immediately + animateQuarterTurns = false + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeByFrame() + + deviceRotation = DeviceRotation.Natural + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeByFrame() + assertThat(((currentDegrees % 360f) + 360f) % 360f).isEqualTo(0f) + } finally { + composeTestRule.mainClock.autoAdvance = true + } + } + @Test fun rotateLayoutToGravity_swapsOuterDimensionsAt90And270Degrees() { var outerSize = IntSize.Zero