From ef62983406fecdd37c4ddab2d5a6f2d7c057b2ef Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 2 Nov 2026 19:38:29 -0800 Subject: [PATCH 1/9] 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 56cf9da27c044ad83eb0c1a05563b05bcb4b9149 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 3 Nov 2026 16:20:13 -0800 Subject: [PATCH 2/9] 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 3/9] 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 4/9] 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 5/9] 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 8784031e552fb834bc465cf21e2cfa740c6be25b Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Sat, 7 Nov 2026 06:34:42 -0800 Subject: [PATCH 6/9] 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 39675efc4956d7b0a1ae33fcebc7d3aaaf3136a1 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 9 Nov 2026 19:08:38 -0800 Subject: [PATCH 7/9] 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 9bdc303528c4cc1ae8610b04aa2b72dbf0f07b8f Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 11 Nov 2026 12:42:30 -0800 Subject: [PATCH 8/9] 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 f0f63d1fc81a291fdc4c8915d44f3d58d5d81ec9 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 13 Nov 2026 08:45:53 -0800 Subject: [PATCH 9/9] 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