diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5d50ebb448..2400b7b561 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -70,9 +70,9 @@
diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
index d230426a61..0d903ad148 100644
--- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
+++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt
@@ -18,6 +18,7 @@ package com.google.jetpackcamera
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
+import android.content.res.Configuration
import android.hardware.Camera
import android.net.Uri
import android.os.Build
@@ -31,6 +32,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -81,6 +83,26 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
private const val TAG = "MainActivity"
+private const val LARGE_SCREEN_MIN_WIDTH_DP = 600
+
+/**
+ * Resolves the Activity's [ActivityInfo] screen orientation policy for the current window posture.
+ *
+ * Full-screen compact handhelds (`!isInMultiWindowMode && smallestScreenWidthDp < 600`) lock the
+ * window to [ActivityInfo.SCREEN_ORIENTATION_NOSENSOR] so the UI counter-rotates via sensor
+ * gravity without triggering a system window rotation. Multi-window panes and large screens
+ * (`smallestScreenWidthDp >= 600`) use [ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED] so the OS
+ * does not apply 1:1 portrait compatibility letterboxing.
+ */
+@VisibleForTesting
+internal fun resolveRequestedOrientation(
+ isInMultiWindowMode: Boolean,
+ smallestScreenWidthDp: Int
+): Int = if (!isInMultiWindowMode && smallestScreenWidthDp < LARGE_SCREEN_MIN_WIDTH_DP) {
+ ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
+} else {
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+}
/**
* Activity for the JetpackCameraApp.
@@ -103,6 +125,10 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
super.onCreate(savedInstanceState)
+ updateRequestedOrientation(resources.configuration)
+ addOnMultiWindowModeChangedListener { info ->
+ updateRequestedOrientation(resources.configuration, info.isInMultiWindowMode)
+ }
cameraLaunchConfigProvider.setIntent(intent)
var uiState: MainActivityUiState by mutableStateOf(Loading)
@@ -194,6 +220,24 @@ class MainActivity : ComponentActivity() {
}
}
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ updateRequestedOrientation(newConfig)
+ }
+
+ private fun updateRequestedOrientation(
+ configuration: Configuration,
+ multiWindowMode: Boolean = isInMultiWindowMode
+ ) {
+ val targetOrientation = resolveRequestedOrientation(
+ isInMultiWindowMode = multiWindowMode,
+ smallestScreenWidthDp = configuration.smallestScreenWidthDp
+ )
+ if (requestedOrientation != targetOrientation) {
+ requestedOrientation = targetOrientation
+ }
+ }
+
private val debugSettings: DebugSettings
get() = cameraLaunchConfigProvider.config.value.debugSettings
diff --git a/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt
new file mode 100644
index 0000000000..022ac02504
--- /dev/null
+++ b/app/src/test/java/com/google/jetpackcamera/MainActivityOrientationTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera
+
+import android.content.pm.ActivityInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Unit tests for [resolveRequestedOrientation].
+ */
+@RunWith(JUnit4::class)
+class MainActivityOrientationTest {
+
+ @Test
+ fun resolveRequestedOrientation_fullScreenCompactPhone_locksToNoSensor() {
+ assertThat(
+ resolveRequestedOrientation(
+ isInMultiWindowMode = false,
+ smallestScreenWidthDp = 411
+ )
+ ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR)
+
+ assertThat(
+ resolveRequestedOrientation(
+ isInMultiWindowMode = false,
+ smallestScreenWidthDp = 599
+ )
+ ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR)
+ }
+
+ @Test
+ fun resolveRequestedOrientation_multiWindowCompact_allowsUnspecifiedOrientation() {
+ assertThat(
+ resolveRequestedOrientation(
+ isInMultiWindowMode = true,
+ smallestScreenWidthDp = 411
+ )
+ ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+ }
+
+ @Test
+ fun resolveRequestedOrientation_largeScreenOrFoldable_allowsUnspecifiedOrientation() {
+ assertThat(
+ resolveRequestedOrientation(
+ isInMultiWindowMode = false,
+ smallestScreenWidthDp = 600
+ )
+ ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+
+ assertThat(
+ resolveRequestedOrientation(
+ isInMultiWindowMode = true,
+ smallestScreenWidthDp = 673
+ )
+ ).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+ }
+}
diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt
index 319f663749..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
@@ -49,6 +49,39 @@ enum class DeviceRotation {
}
}
+ /**
+ * Returns the clockwise rotation in degrees needed inside a window at [windowRotation] so that
+ * UI elements remain oriented upright against physical gravity (`this` device rotation).
+ *
+ * An upside-down (`180°`) delta is mapped to `0` so camera controls and layouts never flip
+ * upside-down.
+ */
+ fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int {
+ val rawDelta =
+ (
+ (windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) %
+ 360 + 360
+ ) % 360
+ return if (rawDelta == 180) 0 else rawDelta
+ }
+
+ /**
+ * Returns the clockwise rotation in degrees needed to counter-rotate a root container back to
+ * the device's natural portrait coordinate frame when the OS window is rotated to `this`
+ * orientation.
+ */
+ fun toWindowCounterRotationDegrees(): Float {
+ return when (this) {
+ Natural -> 0f
+ Rotated90 -> 90f
+ Rotated180 -> 180f
+ Rotated270 -> -90f
+ }
+ }
+
+ val isLandscape: Boolean
+ get() = this == Rotated90 || this == Rotated270
+
companion object {
/**
* Returns the [DeviceRotation] corresponding to a UI [Surface] rotation constant.
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..2f6b686e5e
--- /dev/null
+++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.model
+
+import android.view.Surface
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class DeviceRotationTest {
+
+ @Test
+ fun fromUiSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() {
+ assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_0))
+ .isEqualTo(DeviceRotation.Natural)
+ assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_90))
+ .isEqualTo(DeviceRotation.Rotated270)
+ assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_180))
+ .isEqualTo(DeviceRotation.Rotated180)
+ assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_270))
+ .isEqualTo(DeviceRotation.Rotated90)
+ assertThat(DeviceRotation.fromUiSurfaceRotation(-1))
+ .isEqualTo(DeviceRotation.Natural)
+
+ for (rotation in DeviceRotation.entries) {
+ assertThat(DeviceRotation.fromUiSurfaceRotation(rotation.toUiSurfaceRotation()))
+ .isEqualTo(rotation)
+ }
+ }
+
+ @Test
+ fun toGravityRotationDegrees_computesInWindowGravityCompensationAngle() {
+ // Phone upright (Dev 0°, Win 0°) -> Icons 0°
+ assertThat(
+ DeviceRotation.Natural.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Natural
+ )
+ ).isEqualTo(0)
+
+ // Phone turned 90° CCW with locked portrait window (Dev 270°, Win 0°) -> Icons 90°
+ assertThat(
+ DeviceRotation.Rotated270.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Natural
+ )
+ ).isEqualTo(90)
+
+ // Phone turned 90° CW with locked portrait window (Dev 90°, Win 0°) -> Icons 270°
+ assertThat(
+ DeviceRotation.Rotated90.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Natural
+ )
+ ).isEqualTo(270)
+
+ // Foldable/Tablet turned 90° CCW with OS auto-rotate ON (Dev 270°, Win 270°) -> Icons 0°
+ assertThat(
+ DeviceRotation.Rotated270.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Rotated270
+ )
+ ).isEqualTo(0)
+
+ // Foldable/Tablet turned 90° CCW with OS auto-rotate OFF (Dev 270°, Win 0°) -> Icons 90°
+ assertThat(
+ DeviceRotation.Rotated270.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Natural
+ )
+ ).isEqualTo(90)
+
+ // Upside-down delta (180°) clamps to 0° so UI never flips upside-down
+ assertThat(
+ DeviceRotation.Rotated180.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Natural
+ )
+ ).isEqualTo(0)
+ assertThat(
+ DeviceRotation.Rotated90.toGravityRotationDegrees(
+ windowRotation = DeviceRotation.Rotated270
+ )
+ ).isEqualTo(0)
+ }
+
+ @Test
+ fun toWindowCounterRotationDegrees_andIsLandscape_matchAllQuadrants() {
+ assertThat(DeviceRotation.Natural.toWindowCounterRotationDegrees()).isEqualTo(0f)
+ assertThat(DeviceRotation.Rotated90.toWindowCounterRotationDegrees()).isEqualTo(90f)
+ assertThat(DeviceRotation.Rotated180.toWindowCounterRotationDegrees()).isEqualTo(180f)
+ assertThat(DeviceRotation.Rotated270.toWindowCounterRotationDegrees()).isEqualTo(-90f)
+
+ assertThat(DeviceRotation.Natural.isLandscape).isFalse()
+ assertThat(DeviceRotation.Rotated180.isLandscape).isFalse()
+ assertThat(DeviceRotation.Rotated90.isLandscape).isTrue()
+ assertThat(DeviceRotation.Rotated270.isLandscape).isTrue()
+ }
+}
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..5a44ccda7f 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
@@ -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
@@ -39,12 +44,18 @@ import com.google.jetpackcamera.ui.components.capture.CaptureScaffold
import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldDefaults
import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldLayout
import com.google.jetpackcamera.ui.components.capture.LOADING_CAPTURE_UI_STATE_SKELETON
+import com.google.jetpackcamera.ui.components.capture.LocalActiveCaptureLayout
import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton
+import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode
+import com.google.jetpackcamera.ui.components.capture.rememberWindowRotation
+import com.google.jetpackcamera.ui.debug.DebugLayoutReadoutState
import com.google.jetpackcamera.ui.debug.DebugOverlay
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 +87,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 +141,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,14 +155,13 @@ fun PreviewScreen(
val audioState = remember {
derivedStateOf { readyStateProvider().audioUiState }
}
- val videoRecordingState = remember {
- derivedStateOf { readyStateProvider().videoRecordingState }
- }
CaptureScaffold(
captureUiState = readyStateProvider,
surfaceRequest = surfaceRequest,
modifier = modifier,
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation,
onNavigateToSettings = onNavigateToSettings,
onRequestWindowColorMode = onRequestWindowColorMode,
onRequiresImmersiveChange = onRequiresImmersiveChange,
@@ -172,6 +193,21 @@ 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,
+ cameraLayoutMode = LocalCameraLayoutMode.current.name,
+ activeLayoutName = LocalActiveCaptureLayout.current,
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation
+ )
DebugOverlay(
modifier = slotModifier,
onChangeZoomRatio = { ratio ->
@@ -195,7 +231,8 @@ fun PreviewScreen(
currentRecordingStateProvider = { videoRecordingState.value }
)
},
- debugController = debugController
+ debugController = debugController,
+ layoutReadoutState = layoutReadoutState
)
}
}
diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt
index 0e68aa73a3..438c610818 100644
--- a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt
+++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt
@@ -15,21 +15,34 @@
*/
package com.google.jetpackcamera.ui.components.capture
+import android.annotation.SuppressLint
import android.content.Context
+import android.graphics.Matrix
+import android.graphics.Rect as AndroidRect
+import android.util.Size
+import android.view.Surface
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.impl.CameraInternal
import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertRangeInfoEquals
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.createComposeRule
@@ -38,15 +51,24 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.tryPerformAccessibilityChecks
+import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType
import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator
import com.google.common.truth.Truth.assertThat
+import com.google.jetpackcamera.model.AspectRatio
import com.google.jetpackcamera.ui.uistate.CustomSnackbarVisuals
+import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState
+import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState
import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState
import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState
+import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState
+import java.lang.reflect.Proxy
+import kotlin.math.roundToInt
+import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -59,6 +81,8 @@ class CaptureScreenComponentsTest {
@get:Rule
val composeTestRule = createComposeRule()
+ private val createdSurfaceRequests = mutableListOf()
+
@Before
fun setUp() {
composeTestRule.enableAccessibilityChecks(
@@ -68,6 +92,16 @@ class CaptureScreenComponentsTest {
)
}
+ @SuppressLint("RestrictedApi")
+ @After
+ fun tearDown() {
+ createdSurfaceRequests.forEach { request ->
+ request.willNotProvideSurface()
+ request.deferrableSurface.close()
+ }
+ createdSurfaceRequests.clear()
+ }
+
@Test
fun elapsedTimeText_unavailableState_doesNotRender() {
composeTestRule.setContent {
@@ -341,4 +375,159 @@ class CaptureScreenComponentsTest {
useUnmergedTree = true
).assertCountEquals(0)
}
+
+ @Test
+ fun previewDisplay_loadingStateDarkTheme_passesAccessibilityChecks() {
+ val loadingDescription =
+ ApplicationProvider.getApplicationContext().getString(
+ R.string.camera_not_ready
+ )
+
+ composeTestRule.setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createPreviewDisplayUiState(),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+ }
+
+ composeTestRule.onNodeWithContentDescription(loadingDescription)
+ .assertIsDisplayed()
+ .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
+ composeTestRule.onRoot().tryPerformAccessibilityChecks()
+ }
+
+ @Test
+ fun previewDisplay_loadingStateLightTheme_passesAccessibilityChecks() {
+ val loadingDescription =
+ ApplicationProvider.getApplicationContext().getString(
+ R.string.camera_not_ready
+ )
+
+ composeTestRule.setContent {
+ MaterialTheme(colorScheme = lightColorScheme()) {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createPreviewDisplayUiState(),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+ }
+
+ composeTestRule.onNodeWithContentDescription(loadingDescription)
+ .assertIsDisplayed()
+ .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
+ composeTestRule.onRoot().tryPerformAccessibilityChecks()
+ }
+
+ @Test
+ fun previewDisplay_withSurfaceRequest_passesAccessibilityChecksAndFocusesCenter() {
+ val context = ApplicationProvider.getApplicationContext()
+ val viewfinderDescription =
+ context.getString(R.string.camera_viewfinder_content_description)
+ val density = context.resources.displayMetrics.density
+ val widthPx = (300 * density).roundToInt()
+ val heightPx = (400 * density).roundToInt()
+ val surfaceRequest = createFakeSurfaceRequest(width = widthPx, height = heightPx)
+ val recordedTaps = mutableListOf()
+
+ composeTestRule.setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createPreviewDisplayUiState(),
+ onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ val viewfinderNode = composeTestRule.onNodeWithContentDescription(viewfinderDescription)
+ viewfinderNode.assertIsDisplayed()
+ composeTestRule.onRoot().tryPerformAccessibilityChecks()
+
+ viewfinderNode.performSemanticsAction(SemanticsActions.OnClick)
+ composeTestRule.waitForIdle()
+ assertThat(recordedTaps).hasSize(1)
+ assertThat(recordedTaps.single().x).isWithin(1f).of(widthPx / 2f)
+ assertThat(recordedTaps.single().y).isWithin(1f).of(heightPx / 2f)
+ }
+
+ private fun createPreviewDisplayUiState(
+ aspectRatio: AspectRatio = AspectRatio.THREE_FOUR
+ ): PreviewDisplayUiState = PreviewDisplayUiState(
+ aspectRatioUiState = AspectRatioUiState.Available(
+ selectedAspectRatio = aspectRatio,
+ availableAspectRatios = listOf(
+ SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR),
+ SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN),
+ SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE)
+ )
+ )
+ )
+
+ @SuppressLint("RestrictedApi")
+ private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest {
+ val fakeCamera = Proxy.newProxyInstance(
+ CameraInternal::class.java.classLoader,
+ arrayOf(CameraInternal::class.java)
+ ) { proxy, method, args ->
+ when (method.name) {
+ "equals" -> proxy === args?.firstOrNull()
+ "hashCode" -> System.identityHashCode(proxy)
+ "toString" -> "FakeCameraInternal"
+ "getHasTransform" -> true
+ "isFrontFacing" -> false
+ "isRemoved" -> false
+ else -> when (method.returnType) {
+ Boolean::class.javaPrimitiveType -> false
+ Int::class.javaPrimitiveType -> 0
+ Long::class.javaPrimitiveType -> 0L
+ Float::class.javaPrimitiveType -> 0f
+ Double::class.javaPrimitiveType -> 0.0
+ else -> null
+ }
+ }
+ } as CameraInternal
+
+ return SurfaceRequest(Size(width, height), fakeCamera) {}.also { request ->
+ request.updateTransformationInfo(
+ SurfaceRequest.TransformationInfo.of(
+ AndroidRect(0, 0, width, height),
+ 0,
+ Surface.ROTATION_0,
+ true,
+ Matrix(),
+ false
+ )
+ )
+ createdSurfaceRequests.add(request)
+ }
+ }
}
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/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/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt
index e8295f9298..b58cc9162a 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
@@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.HorizontalAlignmentLine
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import com.google.jetpackcamera.model.DeviceRotation
import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode
import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode
import com.google.jetpackcamera.ui.components.capture.layout.rememberCameraLayoutMode
@@ -93,6 +94,8 @@ internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) }
* @param onRequiresImmersiveChange invoked with `true` when the window is too short for the
* tallest viewfinder to clear a persistent navigation bar, and `false` otherwise or on dispose.
* @param errorDialog the camera error alert dialog composable.
+ * @param deviceRotation physical gravity orientation of the device.
+ * @param windowRotation display/window rotation reported by Android.
*/
@Composable
fun CaptureScaffoldLayout(
@@ -118,11 +121,15 @@ fun CaptureScaffoldLayout(
viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {},
topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {},
onRequiresImmersiveChange: (Boolean) -> Unit = {},
- errorDialog: @Composable (Modifier) -> Unit = {}
+ errorDialog: @Composable (Modifier) -> Unit = {},
+ deviceRotation: DeviceRotation = rememberDeviceRotation(),
+ windowRotation: DeviceRotation = rememberWindowRotation()
) {
CaptureScaffoldLayout(
modifier = modifier,
state = state,
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation,
onDismissQuickSettings = onDismissQuickSettings,
viewfinder = viewfinder,
captureButton = captureButton,
@@ -152,6 +159,8 @@ fun CaptureScaffoldLayout(
internal fun CaptureScaffoldLayout(
modifier: Modifier = Modifier,
state: CaptureScaffoldState = rememberCaptureScaffoldState(),
+ deviceRotation: DeviceRotation = LocalDeviceRotation.current,
+ windowRotation: DeviceRotation = LocalWindowRotation.current,
onDismissQuickSettings: () -> Unit = {},
viewfinder: @Composable (Modifier) -> Unit,
captureButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {},
@@ -222,6 +231,8 @@ internal fun CaptureScaffoldLayout(
slots = resolvedSlots,
viewfinder = movableViewfinder,
state = state,
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation,
controlsVisible = controlsVisible,
isErrorDialogShowing = isErrorDialogShowing,
includeStatusBarsInTopBar = !isFullScreenCompactPhone,
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt
index b79068df11..84ee023ea9 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
@@ -62,6 +62,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
@@ -893,6 +894,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(
@@ -900,6 +903,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 = {},
@@ -1181,7 +1186,8 @@ fun CaptureScaffold(
onScaleZoom = { zoomRatio -> onScaleZoomLambda(zoomRatio) },
surfaceRequest = surfaceRequest,
onRequestWindowColorMode = onRequestWindowColorMode,
- focusMeteringUiState = focusMeteringState.value
+ focusMeteringUiState = focusMeteringState.value,
+ useLandscapeAspectRatio = true
)
}
}
@@ -1448,7 +1454,9 @@ fun CaptureScaffold(
{ cameraController?.dismissCameraError() }
)
}
- }
+ },
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation
)
}
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
index 613649ac5b..5a3ef99069 100644
--- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
@@ -52,23 +52,21 @@ import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
@@ -103,38 +101,55 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.SemanticsPropertyKey
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.sp
import com.google.jetpackcamera.core.camera.VideoRecordingState
import com.google.jetpackcamera.model.AspectRatio
import com.google.jetpackcamera.model.CaptureMode
+import com.google.jetpackcamera.model.DeviceRotation
import com.google.jetpackcamera.model.LensFacing
import com.google.jetpackcamera.model.StabilizationMode
import com.google.jetpackcamera.model.VideoQuality
@@ -165,7 +180,13 @@ import kotlinx.coroutines.flow.onCompletion
private const val TAG = "CaptureScreenComponents"
private val LOADING_SPINNER_SIZE = 40.dp
+private const val LOADING_SPINNER_STATIC_PROGRESS = 0.75f
private val ViewfinderCornerRadius = 16.dp
+private val ViewfinderMinTouchTargetSize = 48.dp
+
+@VisibleForTesting
+internal val PreviewBlinkAlphaProviderKey =
+ SemanticsPropertyKey<() -> Float>("PreviewBlinkAlphaProvider")
private val SnackbarMinHeight = 48.dp
private val SnackbarHorizontalMargin = 16.dp
@@ -811,6 +832,44 @@ private fun DetectWindowColorModeChanges(
}
}
+/**
+ * Calculates the fitted viewfinder dimensions inside the available container bounds while
+ * preserving the target [aspectRatio] without cropping.
+ *
+ * When [useLandscapeAspectRatio] is `true` and [maxWidth] strictly exceeds [maxHeight], the
+ * landscape orientation of [aspectRatio] (e.g., 4:3 instead of 3:4) is used so wide containers
+ * frame horizontal camera output naturally. Otherwise, the portrait [AspectRatio.toFloat] ratio is
+ * preserved.
+ *
+ * @param maxWidth the maximum available container width.
+ * @param maxHeight the maximum available container height.
+ * @param aspectRatio the target [AspectRatio] for the preview.
+ * @param useLandscapeAspectRatio whether to orient [aspectRatio] horizontally when [maxWidth]
+ * exceeds [maxHeight].
+ * @return the fitted [DpSize], or [DpSize.Zero] if either bound is non-positive.
+ */
+@VisibleForTesting
+internal fun calculateViewfinderSize(
+ maxWidth: Dp,
+ maxHeight: Dp,
+ aspectRatio: AspectRatio,
+ useLandscapeAspectRatio: Boolean
+): DpSize {
+ if (maxWidth <= 0.dp || maxHeight <= 0.dp) {
+ return DpSize.Zero
+ }
+ val maxAspectRatio: Float = maxWidth / maxHeight
+ val aspectRatioFloat: Float = if (useLandscapeAspectRatio && maxWidth > maxHeight) {
+ aspectRatio.toLandscapeFloat()
+ } else {
+ aspectRatio.toFloat()
+ }
+ val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
+ val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
+ val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
+ return DpSize(width, height)
+}
+
/**
* A composable that displays the camera preview and handles user input gestures.
*
@@ -820,6 +879,9 @@ private fun DetectWindowColorModeChanges(
* - **Double Tap:** Flips the camera between front and back lenses.
* - **Pinch Gesture:** Scales the camera's zoom level.
*
+ * Assistive technologies and hardware keyboards can also focus the center of the viewfinder via
+ * its click semantics action or activation keys (`Enter`, `Space`, `DirectionCenter`).
+ *
* @param previewDisplayUiState the [PreviewDisplayUiState] for this component.
* @param onTapToFocus the callback for tapping to focus.
* @param onFlipCamera the callback for flipping the camera.
@@ -828,6 +890,9 @@ private fun DetectWindowColorModeChanges(
* @param surfaceRequest the [SurfaceRequest] for the preview.
* @param focusMeteringUiState the [FocusMeteringUiState] for this component.
* @param modifier the modifier for this component.
+ * @param contentAlignment the alignment of the fitted viewfinder within the available bounds.
+ * @param useLandscapeAspectRatio whether to orient the target [AspectRatio] horizontally
+ * (e.g., 4:3 instead of 3:4) when the available container width exceeds its height.
*/
@Composable
fun PreviewDisplay(
@@ -838,152 +903,336 @@ fun PreviewDisplay(
onRequestWindowColorMode: (Int) -> Unit,
surfaceRequest: SurfaceRequest?,
focusMeteringUiState: FocusMeteringUiState,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ contentAlignment: Alignment = Alignment.TopCenter,
+ useLandscapeAspectRatio: Boolean = false
) {
val aspectRatio =
(previewDisplayUiState.aspectRatioUiState as? AspectRatioUiState.Available)
?.selectedAspectRatio ?: AspectRatio.THREE_FOUR
- @Suppress("DEPRECATION")
+ val currentOnTapToFocus by rememberUpdatedState(onTapToFocus)
+ val currentOnFlipCamera by rememberUpdatedState(onFlipCamera)
+ val currentOnScaleZoom by rememberUpdatedState(onScaleZoom)
+
val transformableState = rememberTransformableState(
- onTransformation = { pinchZoomChange, _, _ ->
- onScaleZoom(pinchZoomChange)
+ onTransformation = { _, pinchZoomChange, _, _ ->
+ currentOnScaleZoom(pinchZoomChange)
}
)
- BoxWithConstraints(
- modifier
- .fillMaxSize()
- .background(Color.Black),
- contentAlignment = Alignment.TopCenter
- ) {
- val maxAspectRatio: Float = maxWidth / maxHeight
- val aspectRatioFloat: Float = aspectRatio.toFloat()
- val layoutSolution = LocalCameraLayoutSolution.current
- val solvedBand = if (maxHeight > maxWidth) {
- layoutSolution?.viewfinderBandFor(aspectRatioFloat)
- } else {
- null
- }
- val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
- val width = if (solvedBand != null || shouldUseMaxWidth) {
- maxWidth
+ val layoutSolution = LocalCameraLayoutSolution.current
+ val compactCounterRotation = LocalCompactCounterRotation.current
+ var imageVisible by remember { mutableStateOf(true) }
+ val targetBoundsState = LocalOverlapTargetBounds.current
+
+ val disableAnimations = LocalDisableAnimations.current
+ val currentDisableAnimations by rememberUpdatedState(disableAnimations)
+ val imageAlphaState = animateFloatAsState(
+ targetValue = if (imageVisible || disableAnimations) 1f else 0f,
+ animationSpec = if (disableAnimations) {
+ snap()
} else {
- maxHeight * aspectRatioFloat
- }
- val height = solvedBand?.height
- ?: if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
- val topOffset = solvedBand?.top ?: 0.dp
- var imageVisible by remember { mutableStateOf(true) }
- val targetBoundsState = LocalOverlapTargetBounds.current
+ tween(
+ durationMillis = (BLINK_TIME / 2).toInt(),
+ easing = LinearEasing
+ )
+ },
+ label = "PreviewBlinkAlpha"
+ )
+ val blinkAlphaProvider = remember { { imageAlphaState.value } }
+ val viewfinderShape = remember { RoundedCornerShape(ViewfinderCornerRadius) }
- val disableAnimations = LocalDisableAnimations.current
- val imageAlpha: Float by animateFloatAsState(
- targetValue = if (imageVisible) 1f else 0f,
- animationSpec = if (disableAnimations) {
- snap()
- } else {
- tween(
- durationMillis = (BLINK_TIME / 2).toInt(),
- easing = LinearEasing
- )
- },
- label = ""
- )
+ LaunchedEffect(disableAnimations) {
+ if (disableAnimations) {
+ imageVisible = true
+ }
+ }
- LaunchedEffect(previewDisplayUiState.lastBlinkTimeStamp) {
- if (previewDisplayUiState.lastBlinkTimeStamp != 0L) {
- imageVisible = false
+ LaunchedEffect(previewDisplayUiState.lastBlinkTimeStamp) {
+ if (previewDisplayUiState.lastBlinkTimeStamp != 0L && !currentDisableAnimations) {
+ imageVisible = false
+ try {
delay(BLINK_TIME)
+ } finally {
imageVisible = true
}
}
+ }
- Box(
- modifier = Modifier
- .padding(top = topOffset)
- // Reports the top edge of the visible frame to CaptureScaffoldLayout, which anchors
- // viewfinder-relative content to it.
- .layout { measurable, frameConstraints ->
- val placeable = measurable.measure(frameConstraints)
- layout(
- placeable.width,
- placeable.height,
- alignmentLines = mapOf(ViewfinderTopLine to 0)
- ) {
- placeable.place(0, 0)
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ contentAlignment = contentAlignment
+ ) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Box(
+ modifier = Modifier
+ // Reports the top edge of the visible frame to CaptureScaffoldLayout, which
+ // anchors viewfinder-relative content to it.
+ .layout { measurable, constraints ->
+ val maxWidth = constraints.maxWidth.toDp()
+ val maxHeight = constraints.maxHeight.toDp()
+ val isPortraitBounds = maxHeight >= maxWidth
+ val solvedBand = layoutSolution?.viewfinderBandFor(aspectRatio.toFloat())
+ if (solvedBand != null && isPortraitBounds) {
+ val topOffsetPx = solvedBand.top.roundToPx()
+ .coerceIn(0, constraints.maxHeight)
+ val widthPx = maxWidth.roundToPx().coerceIn(0, constraints.maxWidth)
+ val heightPx = solvedBand.height.roundToPx()
+ .coerceIn(0, (constraints.maxHeight - topOffsetPx).coerceAtLeast(0))
+ val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx))
+ val totalHeight =
+ (topOffsetPx + placeable.height).coerceAtMost(constraints.maxHeight)
+ layout(
+ width = placeable.width,
+ height = totalHeight,
+ alignmentLines = mapOf(ViewfinderTopLine to topOffsetPx)
+ ) {
+ placeable.place(0, topOffsetPx)
+ }
+ } else if (solvedBand != null) {
+ val portraitTopOffsetPx = solvedBand.top.roundToPx()
+ .coerceIn(0, constraints.maxWidth)
+ val widthPx = solvedBand.height.roundToPx()
+ .coerceIn(
+ 0,
+ (constraints.maxWidth - portraitTopOffsetPx).coerceAtLeast(0)
+ )
+ val heightPx = maxHeight.roundToPx().coerceIn(0, constraints.maxHeight)
+ val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx))
+ val leftOffsetPx = if (
+ compactCounterRotation == DeviceRotation.Rotated90
+ ) {
+ (constraints.maxWidth - portraitTopOffsetPx - placeable.width)
+ .coerceAtLeast(0)
+ } else {
+ portraitTopOffsetPx
+ }
+ val verticalOffsetPx = (constraints.maxHeight - placeable.height) / 2
+ layout(
+ width = constraints.maxWidth,
+ height = placeable.height,
+ alignmentLines = mapOf(ViewfinderTopLine to portraitTopOffsetPx)
+ ) {
+ placeable.place(leftOffsetPx, verticalOffsetPx)
+ }
+ } else {
+ val size = calculateViewfinderSize(
+ maxWidth = maxWidth,
+ maxHeight = maxHeight,
+ aspectRatio = aspectRatio,
+ useLandscapeAspectRatio = useLandscapeAspectRatio
+ )
+ val widthPx = size.width.roundToPx().coerceIn(0, constraints.maxWidth)
+ val heightPx = size.height.roundToPx()
+ .coerceIn(0, constraints.maxHeight)
+ val placeable = measurable.measure(Constraints.fixed(widthPx, heightPx))
+ layout(
+ width = placeable.width,
+ height = placeable.height,
+ alignmentLines = mapOf(ViewfinderTopLine to 0)
+ ) {
+ placeable.place(0, 0)
+ }
+ }
}
- }
- .testTag(PREVIEW_DISPLAY)
- .onGloballyPositioned { coordinates ->
- val bounds = coordinates.boundsInWindow()
- if (targetBoundsState.value != bounds) {
- targetBoundsState.value = bounds
+ .testTag(PREVIEW_DISPLAY)
+ .semantics {
+ this[PreviewBlinkAlphaProviderKey] = blinkAlphaProvider
+ }
+ .onGloballyPositioned { coordinates ->
+ val bounds = coordinates.boundsInWindow()
+ if (targetBoundsState.value != bounds) {
+ targetBoundsState.value = bounds
+ }
+ }
+ .transformable(state = transformableState)
+ .graphicsLayer {
+ alpha = imageAlphaState.value
+ shape = viewfinderShape
+ clip = true
+ }
+ ) {
+ if (surfaceRequest != null) {
+ val implementationMode = when {
+ Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
+ else -> ImplementationMode.EMBEDDED
}
- }
- .width(width)
- .height(height)
- .transformable(state = transformableState)
- .alpha(imageAlpha)
- .clip(RoundedCornerShape(ViewfinderCornerRadius))
- ) {
- if (surfaceRequest != null) {
- val implementationMode = when {
- Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
- else -> ImplementationMode.EMBEDDED
- }
- DetectWindowColorModeChanges(
- surfaceRequest = surfaceRequest,
- implementationMode = implementationMode,
- onRequestWindowColorMode = onRequestWindowColorMode
- )
+ DetectWindowColorModeChanges(
+ surfaceRequest = surfaceRequest,
+ implementationMode = implementationMode,
+ onRequestWindowColorMode = onRequestWindowColorMode
+ )
- val coordinateTransformer = remember { MutableCoordinateTransformer() }
- val viewfinderDescription =
- stringResource(R.string.camera_viewfinder_content_description)
- CameraXViewfinder(
- modifier = Modifier
- .fillMaxSize()
- .semantics { contentDescription = viewfinderDescription }
- .pointerInput(onFlipCamera) {
- detectTapGestures(
- onDoubleTap = { offset ->
- // double tap to flip camera
- Log.d(TAG, "onDoubleTap $offset")
- onFlipCamera()
- },
- onTap = {
- with(coordinateTransformer) {
- val surfaceCoords = it.transform()
- Log.d(
- "TAG",
- "onTapToFocus: " +
- "input{$it} -> surface{$surfaceCoords}"
- )
- onTapToFocus(surfaceCoords.x, surfaceCoords.y)
+ val coordinateTransformer = remember { MutableCoordinateTransformer() }
+ var viewfinderSize by remember { mutableStateOf(IntSize.Zero) }
+ val minTouchTargetPx = with(LocalDensity.current) {
+ ViewfinderMinTouchTargetSize.roundToPx()
+ }
+ val hasInteractiveBounds = viewfinderSize.width >= minTouchTargetPx &&
+ viewfinderSize.height >= minTouchTargetPx
+ val viewfinderDescription =
+ stringResource(R.string.camera_viewfinder_content_description)
+ val focusActionLabel =
+ stringResource(R.string.preview_tap_to_focus_action_label)
+ val focusAtViewfinderOffset: (Offset) -> Unit = { offset ->
+ with(coordinateTransformer) {
+ val surfaceCoords = offset.transform()
+ Log.d(
+ TAG,
+ "onTapToFocus: input{$offset} -> surface{$surfaceCoords}"
+ )
+ currentOnTapToFocus(surfaceCoords.x, surfaceCoords.y)
+ }
+ }
+ val triggerCenterFocus: () -> Boolean = {
+ if (viewfinderSize.width > 0 && viewfinderSize.height > 0) {
+ focusAtViewfinderOffset(
+ Offset(
+ viewfinderSize.width / 2f,
+ viewfinderSize.height / 2f
+ )
+ )
+ true
+ } else {
+ false
+ }
+ }
+ CameraXViewfinder(
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged { viewfinderSize = it }
+ .semantics {
+ contentDescription = viewfinderDescription
+ if (hasInteractiveBounds) {
+ onClick(label = focusActionLabel) { triggerCenterFocus() }
+ }
+ }
+ .onKeyEvent { event ->
+ val isActivationKey = event.key == Key.Enter ||
+ event.key == Key.NumPadEnter ||
+ event.key == Key.DirectionCenter ||
+ event.key == Key.Spacebar
+ if (hasInteractiveBounds && isActivationKey) {
+ when (event.type) {
+ KeyEventType.KeyUp -> triggerCenterFocus()
+ KeyEventType.KeyDown -> true
+ else -> false
}
+ } else {
+ false
}
- )
- },
- surfaceRequest = surfaceRequest,
- implementationMode = implementationMode,
- coordinateTransformer = coordinateTransformer
- )
- FocusMeteringIndicator(
- focusMeteringUiState = focusMeteringUiState,
- coordinateTransformer = coordinateTransformer
- )
- } else {
- CircularProgressIndicator(
- modifier = Modifier.align(Alignment.Center).size(LOADING_SPINNER_SIZE),
- color = Color.White
- )
+ }
+ .focusable(enabled = hasInteractiveBounds)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = { offset ->
+ // double tap to flip camera
+ Log.d(TAG, "onDoubleTap $offset")
+ currentOnFlipCamera()
+ },
+ onTap = { offset ->
+ focusAtViewfinderOffset(offset)
+ }
+ )
+ },
+ surfaceRequest = surfaceRequest,
+ implementationMode = implementationMode,
+ coordinateTransformer = coordinateTransformer
+ )
+ FocusMeteringIndicator(
+ focusMeteringUiState = focusMeteringUiState,
+ coordinateTransformer = coordinateTransformer
+ )
+ } else {
+ val loadingDescription = stringResource(R.string.camera_not_ready)
+ val indicatorModifier = Modifier
+ .align(Alignment.Center)
+ .size(LOADING_SPINNER_SIZE)
+ .clearAndSetSemantics {
+ contentDescription = loadingDescription
+ progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate
+ }
+ if (disableAnimations) {
+ CircularProgressIndicator(
+ progress = { LOADING_SPINNER_STATIC_PROGRESS },
+ modifier = indicatorModifier,
+ color = Color.White,
+ trackColor = Color.Transparent
+ )
+ } else {
+ CircularProgressIndicator(
+ modifier = indicatorModifier,
+ color = Color.White
+ )
+ }
+ }
}
}
}
}
+@Composable
+private fun PreviewDisplaySample(
+ aspectRatio: AspectRatio = AspectRatio.THREE_FOUR,
+ contentAlignment: Alignment = Alignment.TopCenter,
+ useLandscapeAspectRatio: Boolean = false
+) {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ PreviewDisplay(
+ previewDisplayUiState = PreviewDisplayUiState(
+ aspectRatioUiState = AspectRatioUiState.Available(
+ selectedAspectRatio = aspectRatio,
+ availableAspectRatios = listOf(
+ SingleSelectableUiState.SelectableUi(aspectRatio)
+ )
+ )
+ ),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified,
+ contentAlignment = contentAlignment,
+ useLandscapeAspectRatio = useLandscapeAspectRatio
+ )
+ }
+}
+
+@Preview(name = "Tablet 1280x800 Centered 4:3 Pillarbox", widthDp = 1280, heightDp = 800)
+@Composable
+private fun PreviewDisplayTabletPillarboxPreview() {
+ PreviewDisplaySample(
+ aspectRatio = AspectRatio.THREE_FOUR,
+ contentAlignment = Alignment.Center,
+ useLandscapeAspectRatio = true
+ )
+}
+
+@Preview(name = "Phone 412x915 Top-Aligned 3:4", widthDp = 412, heightDp = 915)
+@Composable
+private fun PreviewDisplayPhoneTopAlignedPreview() {
+ PreviewDisplaySample(
+ aspectRatio = AspectRatio.THREE_FOUR,
+ contentAlignment = Alignment.TopCenter,
+ useLandscapeAspectRatio = false
+ )
+}
+
+@Preview(name = "PreviewDisplay RTL", locale = "ar", widthDp = 412, heightDp = 915)
+@Composable
+private fun PreviewDisplayRtlPreview() {
+ PreviewDisplaySample(
+ aspectRatio = AspectRatio.THREE_FOUR,
+ contentAlignment = Alignment.TopCenter,
+ useLandscapeAspectRatio = false
+ )
+}
+
/**
* A wrapper composable for the primary capture button.
*
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt
index 0a8f91444e..6378a25d4e 100644
--- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt
@@ -60,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -90,8 +91,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.takeOrElse
+import com.google.jetpackcamera.model.DeviceRotation
import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutDefaults
+import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolution
import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolver
+import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSpec
import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds
import com.google.jetpackcamera.ui.components.capture.layout.CameraWindow
import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution
@@ -199,15 +203,19 @@ internal fun Modifier.suppressBackgroundForModal(isModalOpen: Boolean): Modifier
* Hosts the camera viewfinder, top status/indicator bar, bottom control stack, and an in-hierarchy
* [BottomSheetScaffold] for Quick Settings controlled by [state] without spawning secondary
* windows. Tags its root container with [COMPACT_CONTROLS_TAG], provides
- * [LocalActiveCaptureLayout] = `"CompactPortraitLayout"`, and scopes [LocalCaptureRtlPolicy]
- * (`mirrorCompactBottomBar`) exclusively to the horizontal capture and bottom-toolbar rows while
- * restoring the active locale's `LayoutDirection` inside every child slot.
+ * [LocalActiveCaptureLayout] = `"CompactPortraitLayout"`, counter-rotates into canonical portrait
+ * coordinates when `windowWidth > windowHeight` (`COMPACT` / `COMPACT_SLIM` horizontal postures),
+ * and scopes [LocalCaptureRtlPolicy] (`mirrorCompactBottomBar`) exclusively to the horizontal
+ * capture and bottom-toolbar rows while restoring the active locale's `LayoutDirection` inside
+ * every child slot.
*
* @param slots [CaptureSlots] supplying the capture screen UI controls and overlays.
* @param viewfinder Composable rendering the camera preview frame.
* @param modifier [Modifier] applied to the outer layout container.
* @param state Mode-agnostic [CaptureScaffoldState] controlling the Quick Settings bottom sheet and
* snackbar host.
+ * @param deviceRotation Physical gravity orientation of the device.
+ * @param windowRotation Display/window rotation reported by Android.
* @param controlsVisible Whether the camera control slots should be composed and visible.
* @param isErrorDialogShowing Whether a modal camera error dialog is currently visible above the
* capture chrome.
@@ -228,6 +236,8 @@ internal fun CompactPortraitLayout(
viewfinder: @Composable (Modifier) -> Unit,
modifier: Modifier = Modifier,
state: CaptureScaffoldState = rememberCaptureScaffoldState(),
+ deviceRotation: DeviceRotation = LocalDeviceRotation.current,
+ windowRotation: DeviceRotation = LocalWindowRotation.current,
controlsVisible: Boolean = true,
isErrorDialogShowing: Boolean = false,
includeStatusBarsInTopBar: Boolean = true,
@@ -286,219 +296,486 @@ internal fun CompactPortraitLayout(
onDismissQuickSettings()
}
- CompositionLocalProvider(
- LocalOverlapTargetBounds provides overlapTargetBounds,
- LocalActiveCaptureLayout provides "CompactPortraitLayout"
+ SeamlessRotationAnimationEffect()
+
+ val isSheetVisible = state.isQuickSettingsOpen ||
+ bottomSheetState.isVisible ||
+ bottomSheetState.targetValue == SheetValue.Expanded
+ var isErrorDialogPlaced by remember { mutableStateOf(false) }
+ val isErrorModalActive = isErrorDialogShowing || isErrorDialogPlaced
+ val isModalOpen = isSheetVisible || isErrorModalActive
+ val externalVfMetrics = remember { ExternalViewfinderMetrics() }
+
+ BoxWithConstraints(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .testTag(COMPACT_CONTROLS_TAG)
) {
- Box(
- modifier = modifier
- .fillMaxSize()
- .testTag(COMPACT_CONTROLS_TAG)
+ val windowWidth = maxWidth
+ val windowHeight = maxHeight
+ val shouldCounterRotate = windowWidth > windowHeight
+ val effectiveDeviceRotation = if (
+ deviceRotation == DeviceRotation.Natural &&
+ windowRotation.isLandscape &&
+ shouldCounterRotate
) {
- BottomSheetScaffold(
- modifier = Modifier.fillMaxSize(),
- scaffoldState = scaffoldState,
- sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight,
- sheetDragHandle = {
- BottomSheetDefaults.DragHandle(
- modifier = Modifier
- .testTag(QUICK_SETTINGS_DRAG_HANDLE)
- .minimumInteractiveComponentSize()
- .clickable(
- role = Role.Button,
- onClickLabel = dismissQuickSettingsLabel,
- onClick = handleDismissQuickSettings
- )
- )
- },
- sheetShape = RoundedCornerShape(
- topStart = CompactPortraitTokens.SheetTopCornerRadius,
- topEnd = CompactPortraitTokens.SheetTopCornerRadius
- ),
- sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- sheetContent = {
- CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
- OverlapAwareCaptureSlot(
- orientation = Orientation.Vertical,
- isCompact = false,
- content = slots.quickSettingsOverlay
- )
- }
- },
- snackbarHost = {}
+ windowRotation
+ } else {
+ deviceRotation
+ }
+ val windowGravityDeg = effectiveDeviceRotation.toGravityRotationDegrees(windowRotation)
+ val effectiveWindowRotation = when {
+ !shouldCounterRotate -> DeviceRotation.Natural
+ windowGravityDeg == 90 -> DeviceRotation.Rotated90
+ windowGravityDeg == 270 -> DeviceRotation.Rotated270
+ windowRotation.isLandscape -> windowRotation
+ else -> DeviceRotation.Rotated270
+ }
+ val containerCounterDeg = effectiveWindowRotation.toWindowCounterRotationDegrees().toInt()
+ val containerFrameRotation = DeviceRotation.snapFrom(
+ ((windowRotation.toClockwiseRotationDegrees() - containerCounterDeg) % 360 + 360) % 360
+ )
+ val gravityRotationState = rememberGravityRotationState(
+ deviceRotation = effectiveDeviceRotation,
+ windowRotation = containerFrameRotation
+ )
+ val unrotatedGravityRotationState = rememberGravityRotationState(
+ deviceRotation = effectiveDeviceRotation,
+ windowRotation = windowRotation
+ )
+
+ val portraitWidth = if (shouldCounterRotate) windowHeight else windowWidth
+ val portraitHeight = if (shouldCounterRotate) windowWidth else windowHeight
+ val portraitAspectRatio = portraitHeight.value / portraitWidth.value
+ val isHandheldPortrait =
+ portraitAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD
+ val minTouchTarget = LocalMinimumInteractiveComponentSize.current
+ .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget }
+ val (topBarHeight, topBarTopPadding) = computeTopBarMetrics(
+ includeStatusBarsInTopBar = includeStatusBarsInTopBar,
+ minTouchTarget = minTouchTarget,
+ windowRotation = effectiveWindowRotation
+ )
+ val navInset = WindowInsets.navigationBarsIgnoringVisibility
+ .toPortraitWindowInsets(effectiveWindowRotation)
+ .asPaddingValues()
+ .calculateBottomPadding()
+ val rawGestureInset = WindowInsets.systemGestures
+ .toPortraitWindowInsets(effectiveWindowRotation)
+ .asPaddingValues()
+ .calculateBottomPadding()
+ val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset)
+ val spec = remember(minTouchTarget) {
+ CameraLayoutDefaults.spec(minInteractiveTouchTarget = minTouchTarget)
+ }
+ val portraitSolution = if (isHandheldPortrait) {
+ val cameraWindow = remember(
+ portraitWidth,
+ portraitHeight,
+ topBarHeight,
+ navInset,
+ effectiveGestureInset
) {
- BoxWithConstraints(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black)
- ) {
- val isSheetVisible = state.isQuickSettingsOpen ||
- bottomSheetState.isVisible ||
- bottomSheetState.targetValue == SheetValue.Expanded
- var isErrorDialogPlaced by remember { mutableStateOf(false) }
- val isErrorModalActive = isErrorDialogShowing || isErrorDialogPlaced
- val isModalOpen = isSheetVisible || isErrorModalActive
-
- val windowAspectRatio = maxHeight.value / maxWidth.value
- val isHandheldPortrait =
- windowAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD
- if (isHandheldPortrait) {
- AdaptivePortraitCaptureLayout(
- windowWidth = maxWidth,
- windowHeight = maxHeight,
- includeStatusBarsInTopBar = includeStatusBarsInTopBar,
- controlsVisible = controlsVisible,
- modifier = Modifier.suppressBackgroundForModal(isModalOpen),
- viewfinder = viewfinder,
- topStartContent = topStartContent,
- viewfinderTopEndContent = viewfinderTopEndContent,
- onRequiresImmersiveChange = onRequiresImmersiveChange,
- indicatorRow = indicatorRow,
- topBarTrailingContent = topBarTrailingContent,
- elapsedTimeDisplay = elapsedTimeDisplay,
- zoomControls = zoomLevelDisplay,
- imageWell = imageWell,
- captureButton = captureButton,
- flipCameraButton = flipCameraButton,
- captureModeCarousel = captureModeCarousel,
- quickSettingsToggleButton = quickSettingsButton,
- captureModeToggleSwitch = captureModeToggle,
- snackbarHost = { hostModifier ->
- PillSnackbarHost(
- snackbarHostState = state.snackbarHostState,
- modifier = hostModifier
- )
- }
- )
- Box(
+ CameraWindow(
+ width = portraitWidth,
+ height = portraitHeight,
+ topInset = topBarHeight,
+ navInset = navInset,
+ gestureInset = effectiveGestureInset
+ )
+ }
+ remember(spec, cameraWindow) { CameraLayoutSolver.solve(spec, cameraWindow) }
+ } else {
+ null
+ }
+
+ CompositionLocalProvider(
+ LocalOverlapTargetBounds provides overlapTargetBounds,
+ LocalActiveCaptureLayout provides "CompactPortraitLayout",
+ LocalDeviceRotation provides effectiveDeviceRotation,
+ LocalWindowRotation provides if (shouldCounterRotate) {
+ windowRotation
+ } else {
+ effectiveWindowRotation
+ },
+ LocalCompactCounterRotation provides effectiveWindowRotation,
+ LocalGravityRotationState provides if (shouldCounterRotate) {
+ unrotatedGravityRotationState
+ } else {
+ gravityRotationState
+ },
+ LocalCameraLayoutSolution provides portraitSolution,
+ LocalLayoutDirection provides localeDirection
+ ) {
+ OuterViewfinderHost(
+ viewfinder = viewfinder,
+ solution = portraitSolution,
+ spec = spec,
+ topBarHeight = topBarHeight,
+ shouldCounterRotate = shouldCounterRotate,
+ counterRotation = effectiveWindowRotation,
+ metrics = externalVfMetrics,
+ modifier = Modifier
+ .fillMaxSize()
+ .suppressBackgroundForModal(isModalOpen)
+ )
+ }
+
+ CompositionLocalProvider(
+ LocalOverlapTargetBounds provides overlapTargetBounds,
+ LocalActiveCaptureLayout provides "CompactPortraitLayout",
+ LocalDeviceRotation provides effectiveDeviceRotation,
+ LocalWindowRotation provides effectiveWindowRotation,
+ LocalCompactCounterRotation provides effectiveWindowRotation,
+ LocalGravityRotationState provides gravityRotationState
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .counterRotateToPortrait(effectiveWindowRotation)
+ ) {
+ BottomSheetScaffold(
+ modifier = Modifier.fillMaxSize(),
+ scaffoldState = scaffoldState,
+ containerColor = Color.Transparent,
+ sheetPeekHeight = CompactPortraitTokens.SheetPeekHeight,
+ sheetDragHandle = {
+ BottomSheetDefaults.DragHandle(
modifier = Modifier
- .fillMaxSize()
- .suppressBackgroundForModal(isModalOpen)
- .windowInsetsPadding(
- WindowInsets.systemBarsIgnoringVisibility
- .union(WindowInsets.displayCutout)
+ .testTag(QUICK_SETTINGS_DRAG_HANDLE)
+ .minimumInteractiveComponentSize()
+ .clickable(
+ role = Role.Button,
+ onClickLabel = dismissQuickSettingsLabel,
+ onClick = handleDismissQuickSettings
)
- ) {
- CompositionLocalProvider(
- LocalLayoutDirection provides localeDirection
- ) {
- snackBar(Modifier, state.snackbarHostState)
- }
+ )
+ },
+ sheetShape = RoundedCornerShape(
+ topStart = CompactPortraitTokens.SheetTopCornerRadius,
+ topEnd = CompactPortraitTokens.SheetTopCornerRadius
+ ),
+ sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ sheetContent = {
+ CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
+ OverlapAwareCaptureSlot(
+ orientation = Orientation.Vertical,
+ isCompact = false,
+ content = slots.quickSettingsOverlay
+ )
}
- } else {
- Column(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) {
- val minTouchTarget = LocalMinimumInteractiveComponentSize.current
- .takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget }
- val (topBarHeight, topBarTopPadding) = computeTopBarMetrics(
- includeStatusBarsInTopBar = includeStatusBarsInTopBar,
- minTouchTarget = minTouchTarget
+ },
+ snackbarHost = {}
+ ) {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ val portraitViewfinderProxy: @Composable (Modifier) -> Unit = { proxyMod ->
+ ExternalViewfinderProxy(
+ metrics = externalVfMetrics,
+ modifier = proxyMod
)
- CompactTopBarBox(
- topBarHeight = topBarHeight,
- topBarTopPadding = topBarTopPadding,
+ }
+ if (isHandheldPortrait) {
+ AdaptivePortraitCaptureLayout(
+ windowWidth = maxWidth,
+ windowHeight = maxHeight,
+ windowRotation = effectiveWindowRotation,
+ includeStatusBarsInTopBar = includeStatusBarsInTopBar,
+ controlsVisible = controlsVisible,
+ precomputedSolution = portraitSolution,
+ modifier = Modifier.suppressBackgroundForModal(isModalOpen),
+ viewfinder = portraitViewfinderProxy,
+ topStartContent = topStartContent,
+ viewfinderTopEndContent = viewfinderTopEndContent,
+ onRequiresImmersiveChange = onRequiresImmersiveChange,
indicatorRow = indicatorRow,
- topBarTrailingContent = topBarTrailingContent
- )
- Box {
- viewfinder(Modifier)
- OverlapAwareStyleProvider(Modifier.align(Alignment.TopStart)) {
- topStartContent(Modifier)
+ topBarTrailingContent = topBarTrailingContent,
+ elapsedTimeDisplay = elapsedTimeDisplay,
+ zoomControls = zoomLevelDisplay,
+ imageWell = imageWell,
+ captureButton = captureButton,
+ flipCameraButton = flipCameraButton,
+ captureModeCarousel = captureModeCarousel,
+ quickSettingsToggleButton = quickSettingsButton,
+ captureModeToggleSwitch = captureModeToggle,
+ snackbarHost = { hostModifier ->
+ PillSnackbarHost(
+ snackbarHostState = state.snackbarHostState,
+ modifier = hostModifier
+ )
}
- OverlapAwareStyleProvider(Modifier.align(Alignment.TopEnd)) {
- viewfinderTopEndContent(Modifier)
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .suppressBackgroundForModal(isModalOpen)
+ .windowInsetsPadding(
+ WindowInsets.systemBarsIgnoringVisibility
+ .union(WindowInsets.displayCutout)
+ .toPortraitWindowInsets(effectiveWindowRotation)
+ )
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides localeDirection
+ ) {
+ snackBar(Modifier, state.snackbarHostState)
}
}
- }
-
- Box(
- modifier = Modifier
- .fillMaxSize()
- .suppressBackgroundForModal(isModalOpen)
- .windowInsetsPadding(
- WindowInsets.systemBarsIgnoringVisibility
- .union(WindowInsets.displayCutout)
+ } else {
+ Column(modifier = Modifier.suppressBackgroundForModal(isModalOpen)) {
+ CompactTopBarBox(
+ topBarHeight = topBarHeight,
+ topBarTopPadding = topBarTopPadding,
+ windowRotation = effectiveWindowRotation,
+ indicatorRow = indicatorRow,
+ topBarTrailingContent = topBarTrailingContent
)
- ) {
- if (controlsVisible) {
- VerticalMaterialControls(
- captureButton = captureButton,
- imageWell = imageWell,
- flipCameraButton = flipCameraButton,
- quickSettingsToggleButton = quickSettingsButton,
- captureModeToggleSwitch = captureModeToggle,
- captureModeCarousel = captureModeCarousel,
- zoomControls = zoomLevelDisplay,
- elapsedTimeDisplay = elapsedTimeDisplay,
- snackbarHost = { hostModifier ->
- PillSnackbarHost(
- snackbarHostState = state.snackbarHostState,
- modifier = hostModifier
- )
+ Box {
+ portraitViewfinderProxy(Modifier)
+ OverlapAwareStyleProvider(Modifier.align(Alignment.TopStart)) {
+ topStartContent(Modifier)
}
- )
+ OverlapAwareStyleProvider(Modifier.align(Alignment.TopEnd)) {
+ viewfinderTopEndContent(Modifier)
+ }
+ }
}
- CompositionLocalProvider(
- LocalLayoutDirection provides localeDirection
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .suppressBackgroundForModal(isModalOpen)
+ .windowInsetsPadding(
+ WindowInsets.systemBarsIgnoringVisibility
+ .union(WindowInsets.displayCutout)
+ .toPortraitWindowInsets(effectiveWindowRotation)
+ )
) {
- snackBar(Modifier, state.snackbarHostState)
+ if (controlsVisible) {
+ VerticalMaterialControls(
+ windowRotation = effectiveWindowRotation,
+ captureButton = captureButton,
+ imageWell = imageWell,
+ flipCameraButton = flipCameraButton,
+ quickSettingsToggleButton = quickSettingsButton,
+ captureModeToggleSwitch = captureModeToggle,
+ captureModeCarousel = captureModeCarousel,
+ zoomControls = zoomLevelDisplay,
+ elapsedTimeDisplay = elapsedTimeDisplay,
+ snackbarHost = { hostModifier ->
+ PillSnackbarHost(
+ snackbarHostState = state.snackbarHostState,
+ modifier = hostModifier
+ )
+ }
+ )
+ }
+ CompositionLocalProvider(
+ LocalLayoutDirection provides localeDirection
+ ) {
+ snackBar(Modifier, state.snackbarHostState)
+ }
}
}
- }
- CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
- OverlapAwareStyleProvider(
- modifier = Modifier.suppressBackgroundForModal(isModalOpen)
- ) {
- overlay(Modifier)
+ CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
+ OverlapAwareStyleProvider(
+ modifier = Modifier.suppressBackgroundForModal(isModalOpen)
+ ) {
+ overlay(Modifier)
+ }
}
- }
- if (isSheetVisible && !isErrorModalActive) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .testTag(QUICK_SETTINGS_SCRIM)
- .semantics {
- contentDescription = dismissQuickSettingsLabel
- traversalIndex = 1f
- }
- .clickable(
- role = Role.Button,
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- onClick = handleDismissQuickSettings
- )
- )
- }
+ if (isSheetVisible && !isErrorModalActive) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag(QUICK_SETTINGS_SCRIM)
+ .semantics {
+ contentDescription = dismissQuickSettingsLabel
+ traversalIndex = 1f
+ }
+ .clickable(
+ role = Role.Button,
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = handleDismissQuickSettings
+ )
+ )
+ }
- CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
- Box(
- modifier = Modifier.onSizeChanged { size ->
- isErrorDialogPlaced = size.width > 0 && size.height > 0
+ CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
+ Box(
+ modifier = Modifier.onSizeChanged { size ->
+ isErrorDialogPlaced = size.width > 0 && size.height > 0
+ }
+ ) {
+ errorDialog(Modifier)
}
- ) {
- errorDialog(Modifier)
}
}
}
+ // Drawn last and outside of both the inset-padded controls and the bottom sheet so
+ // the flash covers the entire window, including system bar and cutout areas.
+ screenFlashOverlay(Modifier)
}
- // Drawn last and outside of both the inset-padded controls and the bottom sheet so the
- // flash covers the entire window, including system bar and display cutout areas.
- screenFlashOverlay(Modifier)
}
}
}
+private class ExternalViewfinderMetrics {
+ var portraitWidthPx by mutableIntStateOf(0)
+ var portraitHeightPx by mutableIntStateOf(0)
+ var reportedTopPx by mutableIntStateOf(AlignmentLine.Unspecified)
+}
+
+@Composable
+private fun OuterViewfinderHost(
+ viewfinder: @Composable (Modifier) -> Unit,
+ solution: CameraLayoutSolution?,
+ spec: CameraLayoutSpec,
+ topBarHeight: Dp,
+ shouldCounterRotate: Boolean,
+ counterRotation: DeviceRotation,
+ metrics: ExternalViewfinderMetrics,
+ modifier: Modifier = Modifier
+) {
+ Layout(
+ modifier = modifier,
+ content = { viewfinder(Modifier) }
+ ) { measurables, constraints ->
+ val measurable = measurables.firstOrNull()
+ if (measurable == null) {
+ layout(constraints.maxWidth, constraints.maxHeight) {}
+ } else if (!shouldCounterRotate) {
+ if (solution != null) {
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val vf = measurable.measure(looseConstraints)
+ val reportedTop = vf[ViewfinderTopLine]
+ val viewfinderY = if (vf.height in 1 until constraints.maxHeight) {
+ val ratio = vf.width.toFloat() / vf.height.toFloat()
+ solution.viewfinderBandFor(ratio, spec.viewfinders)?.top?.roundToPx()
+ } else {
+ null
+ } ?: 0
+ val vfX = (constraints.maxWidth - vf.width) / 2
+ if (metrics.portraitWidthPx != vf.width) metrics.portraitWidthPx = vf.width
+ if (metrics.portraitHeightPx != vf.height) metrics.portraitHeightPx = vf.height
+ if (metrics.reportedTopPx != reportedTop) metrics.reportedTopPx = reportedTop
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ vf.placeRelative(vfX, viewfinderY)
+ }
+ } else {
+ val topBarPx = topBarHeight.roundToPx().coerceIn(0, constraints.maxHeight)
+ val availHeight = (constraints.maxHeight - topBarPx).coerceAtLeast(0)
+ val vf = measurable.measure(
+ Constraints(
+ minWidth = 0,
+ maxWidth = constraints.maxWidth,
+ minHeight = 0,
+ maxHeight = availHeight
+ )
+ )
+ if (metrics.portraitWidthPx != vf.width) metrics.portraitWidthPx = vf.width
+ if (metrics.portraitHeightPx != vf.height) metrics.portraitHeightPx = vf.height
+ if (metrics.reportedTopPx != AlignmentLine.Unspecified) {
+ metrics.reportedTopPx = AlignmentLine.Unspecified
+ }
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ vf.placeRelative(0, topBarPx)
+ }
+ }
+ } else if (solution != null) {
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val vf = measurable.measure(looseConstraints)
+ val reportedTop = vf[ViewfinderTopLine]
+ val xOffset = if (
+ reportedTop == AlignmentLine.Unspecified &&
+ vf.width in 1 until constraints.maxWidth &&
+ vf.height > 0
+ ) {
+ val portraitRatio = vf.height.toFloat() / vf.width.toFloat()
+ val bandTopPx = solution
+ .viewfinderBandFor(portraitRatio, spec.viewfinders)
+ ?.top
+ ?.roundToPx() ?: 0
+ if (counterRotation == DeviceRotation.Rotated90) {
+ (constraints.maxWidth - bandTopPx - vf.width).coerceAtLeast(0)
+ } else {
+ bandTopPx.coerceIn(0, (constraints.maxWidth - vf.width).coerceAtLeast(0))
+ }
+ } else {
+ 0
+ }
+ val yOffset = (constraints.maxHeight - vf.height) / 2
+ if (metrics.portraitWidthPx != vf.height) metrics.portraitWidthPx = vf.height
+ if (metrics.portraitHeightPx != vf.width) metrics.portraitHeightPx = vf.width
+ if (metrics.reportedTopPx != reportedTop) metrics.reportedTopPx = reportedTop
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ vf.place(xOffset, yOffset)
+ }
+ } else {
+ val topBarPx = topBarHeight.roundToPx().coerceIn(0, constraints.maxWidth)
+ val availWidth = (constraints.maxWidth - topBarPx).coerceAtLeast(0)
+ val vf = measurable.measure(
+ Constraints(
+ minWidth = 0,
+ maxWidth = availWidth,
+ minHeight = 0,
+ maxHeight = constraints.maxHeight
+ )
+ )
+ val xOffset = if (counterRotation == DeviceRotation.Rotated90) {
+ (availWidth - vf.width) / 2
+ } else {
+ topBarPx + (availWidth - vf.width) / 2
+ }
+ val yOffset = (constraints.maxHeight - vf.height) / 2
+ if (metrics.portraitWidthPx != vf.height) metrics.portraitWidthPx = vf.height
+ if (metrics.portraitHeightPx != vf.width) metrics.portraitHeightPx = vf.width
+ if (metrics.reportedTopPx != AlignmentLine.Unspecified) {
+ metrics.reportedTopPx = AlignmentLine.Unspecified
+ }
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ vf.place(xOffset, yOffset)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExternalViewfinderProxy(
+ metrics: ExternalViewfinderMetrics,
+ modifier: Modifier = Modifier
+) {
+ Layout(modifier = modifier) { _, constraints ->
+ val w = metrics.portraitWidthPx.coerceIn(0, constraints.maxWidth)
+ val h = metrics.portraitHeightPx.coerceIn(0, constraints.maxHeight)
+ val reportedTop = metrics.reportedTopPx
+ val lines: Map = if (reportedTop == AlignmentLine.Unspecified) {
+ emptyMap()
+ } else {
+ mapOf(ViewfinderTopLine to reportedTop)
+ }
+ layout(w, h, alignmentLines = lines) {}
+ }
+}
+
@Composable
private fun computeTopBarMetrics(
includeStatusBarsInTopBar: Boolean,
- minTouchTarget: Dp
+ minTouchTarget: Dp,
+ windowRotation: DeviceRotation = LocalWindowRotation.current
): Pair {
- val cutoutTopInset = WindowInsets.displayCutout.asPaddingValues().calculateTopPadding()
+ val portraitCutoutInsets = WindowInsets.displayCutout.toPortraitWindowInsets(windowRotation)
+ val cutoutTopInset = portraitCutoutInsets.asPaddingValues().calculateTopPadding()
return if (includeStatusBarsInTopBar) {
- val statusBarTopInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
- val combinedTopInset = WindowInsets.statusBars
- .union(WindowInsets.displayCutout)
+ val portraitStatusBars = WindowInsets.statusBars.toPortraitWindowInsets(windowRotation)
+ val statusBarTopInset = portraitStatusBars.asPaddingValues().calculateTopPadding()
+ val combinedTopInset = portraitStatusBars
+ .union(portraitCutoutInsets)
.asPaddingValues()
.calculateTopPadding()
max(combinedTopInset, statusBarTopInset + minTouchTarget) to statusBarTopInset
@@ -513,7 +790,8 @@ private fun CompactTopBarBox(
topBarTopPadding: Dp,
indicatorRow: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ windowRotation: DeviceRotation = LocalWindowRotation.current
) {
val localeDirection = LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides localeDirection) {
@@ -523,7 +801,9 @@ private fun CompactTopBarBox(
.height(topBarHeight)
.padding(top = topBarTopPadding)
.windowInsetsPadding(
- WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)
+ WindowInsets.displayCutout
+ .toPortraitWindowInsets(windowRotation)
+ .only(WindowInsetsSides.Horizontal)
)
.padding(horizontal = CompactPortraitTokens.LowerSectionHorizontalPadding),
contentAlignment = Alignment.CenterStart
@@ -683,7 +963,9 @@ internal fun AdaptivePortraitCaptureLayout(
captureModeToggleSwitch: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
controlsVisible: Boolean,
modifier: Modifier = Modifier,
+ windowRotation: DeviceRotation = LocalWindowRotation.current,
includeStatusBarsInTopBar: Boolean = true,
+ precomputedSolution: CameraLayoutSolution? = null,
snackbarHost: @Composable (Modifier) -> Unit = {}
) {
val localeDirection = LocalLayoutDirection.current
@@ -695,9 +977,11 @@ internal fun AdaptivePortraitCaptureLayout(
}
val navInset = WindowInsets.navigationBarsIgnoringVisibility
+ .toPortraitWindowInsets(windowRotation)
.asPaddingValues()
.calculateBottomPadding()
val rawGestureInset = WindowInsets.systemGestures
+ .toPortraitWindowInsets(windowRotation)
.asPaddingValues()
.calculateBottomPadding()
val effectiveGestureInset = max(CompactPortraitTokens.MinGestureInset, rawGestureInset)
@@ -705,7 +989,8 @@ internal fun AdaptivePortraitCaptureLayout(
.takeOrElse { CompactPortraitTokens.DefaultMinTouchTarget }
val (topBarHeight, topBarTopPadding) = computeTopBarMetrics(
includeStatusBarsInTopBar = includeStatusBarsInTopBar,
- minTouchTarget = minTouchTarget
+ minTouchTarget = minTouchTarget,
+ windowRotation = windowRotation
)
val window = remember(
@@ -731,7 +1016,9 @@ internal fun AdaptivePortraitCaptureLayout(
val spec = remember(minTouchTarget) {
CameraLayoutDefaults.spec(minInteractiveTouchTarget = minTouchTarget)
}
- val solution = remember(spec, window) { CameraLayoutSolver.solve(spec, window) }
+ val solution = precomputedSolution ?: remember(spec, window) {
+ CameraLayoutSolver.solve(spec, window)
+ }
val currentOnRequiresImmersiveChange by rememberUpdatedState(onRequiresImmersiveChange)
DisposableEffect(solution.requiresImmersive) {
@@ -762,6 +1049,7 @@ internal fun AdaptivePortraitCaptureLayout(
CompactTopBarBox(
topBarHeight = topBarHeight,
topBarTopPadding = topBarTopPadding,
+ windowRotation = windowRotation,
modifier = Modifier.layoutId(CaptureSlotId.TopBar),
indicatorRow = indicatorRow,
topBarTrailingContent = topBarTrailingContent
@@ -946,6 +1234,7 @@ internal fun AdaptivePortraitCaptureLayout(
@Composable
internal fun VerticalMaterialControls(
modifier: Modifier = Modifier,
+ windowRotation: DeviceRotation = LocalWindowRotation.current,
captureButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
zoomControls: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
imageWell: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit,
@@ -969,6 +1258,7 @@ internal fun VerticalMaterialControls(
contentAlignment = Alignment.BottomCenter
) {
val navBarBottom = WindowInsets.navigationBarsIgnoringVisibility
+ .toPortraitWindowInsets(windowRotation)
.asPaddingValues()
.calculateBottomPadding()
val isShortScreen = maxHeight < CompactPortraitTokens.ShortScreenThreshold
@@ -1083,6 +1373,21 @@ private fun CompactPortraitShortPhonePreview() {
}
}
+@Preview(
+ name = "CompactPortrait - Phone Horizontal (800x400dp)",
+ device = "spec:width=800dp,height=400dp",
+ showBackground = true
+)
+@Composable
+private fun CompactPortraitHorizontalPhonePreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ CompactPortraitLayout(
+ slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true),
+ viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }
+ )
+ }
+}
+
@Preview(
name = "CompactPortrait - RTL Default Policy (Unmirrored Bar)",
device = "spec:width=412dp,height=915dp",
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt
index 44864e0fc9..c3d9356048 100644
--- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRow.kt
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import com.google.jetpackcamera.core.common.ignoreResult
+import com.google.jetpackcamera.model.DeviceRotation
import kotlin.math.max
import kotlin.math.roundToInt
@@ -82,7 +83,8 @@ import kotlin.math.roundToInt
* @param verticalAlignment vertical alignment of children within the row.
* @param cutoutRectsOverride optional list of cutout bounding rectangles in the row's local
* coordinate space, used for deterministic testing. When `null`, cutout rects are queried from
- * the host window's [android.view.DisplayCutout] and mapped into the row's local coordinates.
+ * the host window's [androidx.core.view.DisplayCutoutCompat] and mapped into the row's local
+ * coordinates.
* @param onPillSegmentsPlaced optional callback invoked with the physical local [IntRect] bounds of
* each active pill segment after layout, used for testing.
* @param content the row children, scoped to [RowScope].
@@ -102,6 +104,7 @@ fun CutoutAwareRow(
content: @Composable RowScope.() -> Unit
) {
val view = LocalView.current
+ val windowRotation = LocalWindowRotation.current
val cutoutInsets = WindowInsets.displayCutout
var rowBoundsInWindow by remember { mutableStateOf(null) }
val lockedMinX = remember { mutableMapOf() }
@@ -145,8 +148,10 @@ fun CutoutAwareRow(
}
}
) { measurables, constraints ->
- // Read WindowInsets.displayCutout inside measure to subscribe to inset updates.
+ // Read WindowInsets.displayCutout inside measure to subscribe to inset updates across
+ // all physical edges when counter-rotated.
cutoutInsets.getTop(this).ignoreResult()
+ cutoutInsets.getBottom(this).ignoreResult()
cutoutInsets.getLeft(this, layoutDirection).ignoreResult()
cutoutInsets.getRight(this, layoutDirection).ignoreResult()
@@ -187,7 +192,10 @@ fun CutoutAwareRow(
?.displayCutout?.boundingRects.orEmpty(),
rowBoundsInWindow = rowBoundsInWindow,
isRtl = isRtl,
- rtlReferenceWidth = rtlReferenceWidth
+ rtlReferenceWidth = rtlReferenceWidth,
+ windowRotation = windowRotation,
+ fallbackViewWidth = view.width,
+ fallbackViewHeight = view.height
)
// Convert vertically overlapping cutout rects into sorted horizontal keep-out ranges.
@@ -356,23 +364,61 @@ internal fun resolveLocalCutoutRects(
windowCutoutRects: List,
rowBoundsInWindow: IntRect?,
isRtl: Boolean,
- rtlReferenceWidth: Int
+ rtlReferenceWidth: Int,
+ windowRotation: DeviceRotation = DeviceRotation.Natural,
+ fallbackViewWidth: Int = 0,
+ fallbackViewHeight: Int = 0
): List {
val ltrRects = cutoutRectsOverride ?: run {
- val offsetX = rowBoundsInWindow?.left ?: 0
- val offsetY = rowBoundsInWindow?.top ?: 0
+ val bounds = rowBoundsInWindow
windowCutoutRects.map { rect ->
- IntRect(
- left = rect.left - offsetX,
- top = rect.top - offsetY,
- right = rect.right - offsetX,
- bottom = rect.bottom - offsetY
- )
+ when (windowRotation) {
+ DeviceRotation.Natural -> {
+ val offsetX = bounds?.left ?: 0
+ val offsetY = bounds?.top ?: 0
+ IntRect(
+ left = rect.left - offsetX,
+ top = rect.top - offsetY,
+ right = rect.right - offsetX,
+ bottom = rect.bottom - offsetY
+ )
+ }
+ DeviceRotation.Rotated270 -> {
+ val refBottom = bounds?.bottom ?: fallbackViewHeight
+ val refLeft = bounds?.left ?: 0
+ IntRect(
+ left = refBottom - rect.bottom,
+ top = rect.left - refLeft,
+ right = refBottom - rect.top,
+ bottom = rect.right - refLeft
+ )
+ }
+ DeviceRotation.Rotated90 -> {
+ val refTop = bounds?.top ?: 0
+ val refRight = bounds?.right ?: fallbackViewWidth
+ IntRect(
+ left = rect.top - refTop,
+ top = refRight - rect.right,
+ right = rect.bottom - refTop,
+ bottom = refRight - rect.left
+ )
+ }
+ DeviceRotation.Rotated180 -> {
+ val refRight = bounds?.right ?: fallbackViewWidth
+ val refBottom = bounds?.bottom ?: fallbackViewHeight
+ IntRect(
+ left = refRight - rect.right,
+ top = refBottom - rect.bottom,
+ right = refRight - rect.left,
+ bottom = refBottom - rect.top
+ )
+ }
+ }
}
}
return if (isRtl) {
val startEdgeX = if (cutoutRectsOverride == null && rowBoundsInWindow != null) {
- rowBoundsInWindow.width
+ if (windowRotation.isLandscape) rowBoundsInWindow.height else rowBoundsInWindow.width
} else {
rtlReferenceWidth
}
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt
index f37270b521..e509c78b1e 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
@@ -36,12 +36,17 @@ private const val ORIENTATION_HYSTERESIS = 5
/**
* A flow that emits the device's orientation, debounced to avoid rapid changes.
*
- * Seeded with the current display rotation from [context] so that starting or restarting
+ * Seeded by default with the current display rotation from [context] so that starting or restarting
* collection immediately reports the active rotation before the sensor delivers a new reading.
*
* @param context the application or display 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 = currentDeviceRotation(context)
+) = callbackFlow {
val orientationListener = object : OrientationEventListener(context) {
override fun onOrientationChanged(orientation: Int) {
trySend(orientation)
@@ -54,19 +59,22 @@ fun debouncedOrientationFlow(context: Context) = callbackFlow {
orientationListener.disable()
}
}.buffer(capacity = CONFLATED)
- .runningFold(initial = currentDeviceRotation(context)) { 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()
-private fun currentDeviceRotation(context: Context): DeviceRotation {
+internal fun currentDeviceRotation(context: Context): DeviceRotation {
val surfaceRotation = context.getSystemService(DisplayManager::class.java)
?.getDisplay(Display.DEFAULT_DISPLAY)
?.rotation
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..cbc2cdcf25
--- /dev/null
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt
@@ -0,0 +1,422 @@
+/*
+ * 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.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.hardware.display.DisplayManager
+import android.os.Build
+import android.view.Display
+import android.view.Surface
+import android.view.View
+import android.view.WindowManager
+import androidx.activity.compose.LocalActivity
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.State
+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.rememberUpdatedState
+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.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+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
+internal 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 the active counter-rotation applied by [CompactPortraitLayout] when rendering in a
+ * horizontal (`width > height`) window (`Rotated90` or `Rotated270`), or [DeviceRotation.Natural]
+ * when no counter-rotation is active.
+ */
+internal val LocalCompactCounterRotation: ProvidableCompositionLocal =
+ compositionLocalOf { DeviceRotation.Natural }
+
+/**
+ * Provides a shared, root-animated [State] holding the continuous clockwise gravity compensation
+ * angle in degrees. Reading [State.value] inside [Modifier.graphicsLayer] updates only the draw/
+ * 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.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
+}
+
+/**
+ * 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.
+ * - 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,
+ animateQuarterTurns: Boolean = true
+): 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 isHalfTurnFlip = rawDelta == -180f || rawDelta == 180f
+ val delta = if (rawDelta == -180f) 180f else rawDelta
+ 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 (snapCurrentTransition) {
+ 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()
+ }
+ }
+ }
+
+/**
+ * Counter-rotates a compact portrait container back to the device's canonical portrait (`0°`)
+ * coordinate frame when [windowRotation] is landscape ([DeviceRotation.Rotated90] or
+ * [DeviceRotation.Rotated270]).
+ *
+ * When [windowRotation] is portrait ([DeviceRotation.Natural] or [DeviceRotation.Rotated180]),
+ * measures with [Constraints] unchanged and places at `(0, 0)` with zero layer rotation.
+ *
+ * @param windowRotation Active OS window [DeviceRotation] to counter-rotate from.
+ */
+fun Modifier.counterRotateToPortrait(windowRotation: DeviceRotation): Modifier =
+ layout { measurable, constraints ->
+ if (!windowRotation.isLandscape) {
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ placeable.place(0, 0)
+ }
+ } else {
+ val swappedMaxWidth = if (constraints.hasBoundedHeight) {
+ constraints.maxHeight
+ } else {
+ Constraints.Infinity
+ }
+ val swappedMaxHeight = if (constraints.hasBoundedWidth) {
+ constraints.maxWidth
+ } else {
+ Constraints.Infinity
+ }
+ val childConstraints = Constraints(
+ minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth),
+ maxWidth = swappedMaxWidth,
+ minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight),
+ maxHeight = swappedMaxHeight
+ )
+ val placeable = measurable.measure(childConstraints)
+ val layoutWidth = placeable.height
+ val layoutHeight = placeable.width
+ layout(layoutWidth, layoutHeight) {
+ val offsetX = (layoutWidth - placeable.width) / 2
+ val offsetY = (layoutHeight - placeable.height) / 2
+ placeable.placeWithLayer(offsetX, offsetY) {
+ rotationZ = windowRotation.toWindowCounterRotationDegrees()
+ }
+ }
+ }
+ }
+
+/**
+ * [WindowInsets] wrapper that remaps physical window edges from the host OS window's
+ * [windowRotation] coordinate frame into the canonical portrait (`0°`) coordinate frame used inside
+ * [counterRotateToPortrait].
+ *
+ * @property baseInsets Underlying host [WindowInsets] in the OS window's coordinate frame.
+ * @property windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated
+ * container.
+ */
+@Immutable
+class PortraitWindowInsets(
+ private val baseInsets: WindowInsets,
+ private val windowRotation: DeviceRotation
+) : WindowInsets {
+ override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int =
+ when (windowRotation) {
+ DeviceRotation.Natural -> baseInsets.getLeft(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated270 -> baseInsets.getBottom(density)
+ DeviceRotation.Rotated90 -> baseInsets.getTop(density)
+ DeviceRotation.Rotated180 -> baseInsets.getRight(density, LayoutDirection.Ltr)
+ }
+
+ override fun getTop(density: Density): Int = when (windowRotation) {
+ DeviceRotation.Natural -> baseInsets.getTop(density)
+ DeviceRotation.Rotated270 -> baseInsets.getLeft(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated90 -> baseInsets.getRight(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated180 -> baseInsets.getBottom(density)
+ }
+
+ override fun getRight(density: Density, layoutDirection: LayoutDirection): Int =
+ when (windowRotation) {
+ DeviceRotation.Natural -> baseInsets.getRight(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated270 -> baseInsets.getTop(density)
+ DeviceRotation.Rotated90 -> baseInsets.getBottom(density)
+ DeviceRotation.Rotated180 -> baseInsets.getLeft(density, LayoutDirection.Ltr)
+ }
+
+ override fun getBottom(density: Density): Int = when (windowRotation) {
+ DeviceRotation.Natural -> baseInsets.getBottom(density)
+ DeviceRotation.Rotated270 -> baseInsets.getRight(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated90 -> baseInsets.getLeft(density, LayoutDirection.Ltr)
+ DeviceRotation.Rotated180 -> baseInsets.getTop(density)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is PortraitWindowInsets) return false
+ return baseInsets == other.baseInsets && windowRotation == other.windowRotation
+ }
+
+ override fun hashCode(): Int {
+ var result = baseInsets.hashCode()
+ result = 31 * result + windowRotation.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "PortraitWindowInsets(baseInsets=$baseInsets, windowRotation=$windowRotation)"
+}
+
+/**
+ * Remaps this [WindowInsets] instance from the host OS window's [windowRotation] coordinate frame
+ * into the canonical portrait (`0°`) coordinate frame.
+ *
+ * @param windowRotation Effective [DeviceRotation] applied by the enclosing counter-rotated
+ * container.
+ * @return A [PortraitWindowInsets] remapping all four edges into portrait coordinates.
+ */
+fun WindowInsets.toPortraitWindowInsets(windowRotation: DeviceRotation): WindowInsets =
+ PortraitWindowInsets(this, windowRotation)
+
+/**
+ * Requests [WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS] on the host [Activity] window
+ * on API 26+ while [enabled] is `true`, restoring the previous window rotation animation on
+ * disposal so counter-rotated compact camera chrome transitions across OS window rotations without
+ * a crossfade jump.
+ */
+@Composable
+internal fun SeamlessRotationAnimationEffect(enabled: Boolean = true) {
+ val context = LocalContext.current
+ val activity = LocalActivity.current ?: remember(context) { context.findActivity() }
+ DisposableEffect(activity, enabled) {
+ val window = activity?.window
+ if (enabled && window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val previousAnimation = window.attributes.rotationAnimation
+ if (previousAnimation != WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS) {
+ window.attributes = window.attributes.apply {
+ rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS
+ }
+ }
+ onDispose {
+ window.attributes = window.attributes.apply {
+ rotationAnimation = previousAnimation
+ }
+ }
+ } else {
+ onDispose {}
+ }
+ }
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt
index 1e2526e75f..efbdb15355 100644
--- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt
@@ -70,6 +70,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
+import com.google.jetpackcamera.model.DeviceRotation
// TODO(#648): Replace with QUICK_SETTINGS_OVERLAY_CARD_TAG from QuickSettingsOverlayCard once #648
// merges into main.
@@ -300,7 +301,9 @@ internal fun SideRailCaptureScaffold(
CompositionLocalProvider(
LocalOverlapTargetBounds provides overlapTargetBounds,
- LocalActiveCaptureLayout provides config.activeLayoutName
+ LocalActiveCaptureLayout provides config.activeLayoutName,
+ LocalWindowRotation provides DeviceRotation.Natural,
+ LocalGravityRotationState provides DefaultGravityRotationState
) {
Box(
modifier = modifier
diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt
index 75447f5daa..35e4f3066a 100644
--- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt
+++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt
@@ -37,6 +37,7 @@ import com.google.jetpackcamera.model.DynamicRange
import com.google.jetpackcamera.model.FlashMode
import com.google.jetpackcamera.model.ImageOutputFormat
import com.google.jetpackcamera.model.LensFacing
+import com.google.jetpackcamera.ui.components.capture.LocalWindowRotation
import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_BOTTOM_SHEET
import com.google.jetpackcamera.ui.components.capture.R
import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.AspectRatioRow
@@ -44,6 +45,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.CaptureMo
import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashRow
import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrRow
import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickNavSettings
+import com.google.jetpackcamera.ui.components.capture.toPortraitWindowInsets
import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController
import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState
import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState
@@ -72,10 +74,12 @@ fun QuickSettingsScaffoldContent(
showMoreSettingsButton: Boolean = true
) {
if (quickSettingsUiState is QuickSettingsUiState.Available) {
+ val portraitNavBars = WindowInsets.navigationBarsIgnoringVisibility
+ .toPortraitWindowInsets(LocalWindowRotation.current)
Column(
modifier = modifier
.fillMaxWidth()
- .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility)
+ .windowInsetsPadding(portraitNavBars)
.padding(bottom = 24.dp)
.testTag(QUICK_SETTINGS_BOTTOM_SHEET)
) {
diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml
index d69bbf553b..c08726e0de 100644
--- a/ui/components/capture/src/main/res/values/strings.xml
+++ b/ui/components/capture/src/main/res/values/strings.xml
@@ -139,6 +139,8 @@
Camera viewfinder
+
+ Focus center
Focusing
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/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt
index 2146ed02f3..7b8a50f65c 100644
--- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt
+++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CutoutAwareRowTest.kt
@@ -43,6 +43,7 @@ import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import com.google.jetpackcamera.model.DEFAULT_HDR_DYNAMIC_RANGE
import com.google.jetpackcamera.model.DEFAULT_HDR_IMAGE_OUTPUT
+import com.google.jetpackcamera.model.DeviceRotation
import com.google.jetpackcamera.model.FlashMode
import com.google.jetpackcamera.model.StabilizationMode
import com.google.jetpackcamera.model.VideoQuality
@@ -315,6 +316,35 @@ class CutoutAwareRowTest {
rtlReferenceWidth = 360
)
).containsExactly(IntRect(left = 310, top = 0, right = 350, bottom = 40))
+
+ // Verify fallbackViewWidth / fallbackViewHeight when rowBoundsInWindow is null on pass 1.
+ val landscapeLeftCutout = listOf(android.graphics.Rect(0, 300, 48, 340))
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = landscapeLeftCutout,
+ rowBoundsInWindow = null,
+ isRtl = false,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated270,
+ fallbackViewWidth = 800,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48))
+
+ val landscapeRightCutout = listOf(android.graphics.Rect(752, 20, 800, 60))
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = landscapeRightCutout,
+ rowBoundsInWindow = null,
+ isRtl = false,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated90,
+ fallbackViewWidth = 800,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48))
}
@Test
diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt
new file mode 100644
index 0000000000..da7803655f
--- /dev/null
+++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/LayoutRotationTest.kt
@@ -0,0 +1,609 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.ui.components.capture
+
+import android.graphics.Rect as AndroidRect
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.width
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import com.google.jetpackcamera.model.AspectRatio
+import com.google.jetpackcamera.model.DeviceRotation
+import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode
+import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolution
+import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds
+import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode
+import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution
+import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState
+import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState
+import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState
+import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+private object LayoutRotationTestTokens {
+ const val INDICATOR_TAG = "RotationTestIndicatorRow"
+ const val VIEWFINDER_TOP_MARKER_TAG = "RotationTestViewfinderTopMarker"
+
+ val CaptureButtonSize = 80.dp
+ val ControlSize = 48.dp
+ val ZoomBarWidth = 160.dp
+ val IndicatorWidth = 120.dp
+ val IndicatorHeight = 36.dp
+ val ModeToggleWidth = 96.dp
+}
+
+/**
+ * Robolectric Compose UI tests verifying layout-level counter-rotation scoping across full-screen
+ * compact handhelds, portrait & horizontal split-screen panes (`852x420dp`, `800x400dp`,
+ * `550x500dp`), un-rotated viewfinder hosting, 7-mode side-rail postures (`FOLDABLE`,
+ * `TABLET_PORTRAIT`, `TABLET_LANDSCAPE`, `COMPACT_SHORT`), and [CutoutAwareRow] cutout remapping.
+ */
+@RunWith(RobolectricTestRunner::class)
+class LayoutRotationTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Composable
+ private fun RotationLayoutUnderTest(
+ deviceRotation: DeviceRotation,
+ windowRotation: DeviceRotation,
+ modifier: Modifier = Modifier,
+ slots: CaptureSlots? = null,
+ onViewfinderObserved: (
+ mode: CameraLayoutMode,
+ vfWindowRotation: DeviceRotation,
+ vfGravityDegrees: Float,
+ solution: CameraLayoutSolution?,
+ viewfinderSize: IntSize
+ ) -> Unit = { _, _, _, _, _ -> },
+ onChromeObserved: (
+ chromeWindowRotation: DeviceRotation,
+ chromeGravityDegrees: Float,
+ solution: CameraLayoutSolution?
+ ) -> Unit = { _, _, _ -> }
+ ) {
+ MaterialTheme {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalDisableAnimations provides true
+ ) {
+ CaptureScaffoldLayout(
+ modifier = modifier,
+ deviceRotation = deviceRotation,
+ windowRotation = windowRotation,
+ slots = slots,
+ viewfinder = { vfModifier ->
+ val mode = LocalCameraLayoutMode.current
+ val vfWinRot = LocalWindowRotation.current
+ val vfGravityDeg = LocalGravityRotationState.current.value
+ val solution = LocalCameraLayoutSolution.current
+ Box(
+ modifier = vfModifier
+ .fillMaxSize()
+ .onSizeChanged { vfSize ->
+ onViewfinderObserved(
+ mode,
+ vfWinRot,
+ vfGravityDeg,
+ solution,
+ vfSize
+ )
+ }
+ .testTag(PREVIEW_DISPLAY)
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .size(width = 24.dp, height = 6.dp)
+ .testTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG)
+ )
+ }
+ },
+ captureButton = { mod ->
+ onChromeObserved(
+ LocalWindowRotation.current,
+ LocalGravityRotationState.current.value,
+ LocalCameraLayoutSolution.current
+ )
+ Box(
+ mod
+ .size(LayoutRotationTestTokens.CaptureButtonSize)
+ .testTag(CAPTURE_BUTTON)
+ )
+ },
+ imageWell = { mod ->
+ Box(
+ mod
+ .size(LayoutRotationTestTokens.ControlSize)
+ .testTag(IMAGE_WELL_TAG)
+ )
+ },
+ flipCameraButton = { mod ->
+ Box(
+ mod
+ .size(LayoutRotationTestTokens.ControlSize)
+ .testTag(FLIP_CAMERA_BUTTON)
+ )
+ },
+ zoomLevelDisplay = { mod ->
+ Box(
+ mod
+ .size(
+ width = LayoutRotationTestTokens.ZoomBarWidth,
+ height = LayoutRotationTestTokens.ControlSize
+ )
+ .testTag(ZOOM_BUTTON_ROW_TAG)
+ )
+ },
+ elapsedTimeDisplay = {},
+ quickSettingsButton = { mod ->
+ Box(
+ mod
+ .size(LayoutRotationTestTokens.ControlSize)
+ .testTag(QUICK_SETTINGS_DROP_DOWN)
+ )
+ },
+ indicatorRow = { mod ->
+ Box(
+ mod
+ .size(
+ width = LayoutRotationTestTokens.IndicatorWidth,
+ height = LayoutRotationTestTokens.IndicatorHeight
+ )
+ .testTag(LayoutRotationTestTokens.INDICATOR_TAG)
+ )
+ },
+ captureModeToggle = { mod ->
+ Box(
+ mod
+ .size(
+ width = LayoutRotationTestTokens.ModeToggleWidth,
+ height = LayoutRotationTestTokens.ControlSize
+ )
+ .testTag(CAPTURE_MODE_TOGGLE_BUTTON)
+ )
+ }
+ )
+ }
+ }
+ }
+
+ @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi")
+ @Test
+ fun compactPortraitLayout_landscapePhone_counterRotatesChromeWithUprightViewfinder() {
+ var windowRotation by mutableStateOf(DeviceRotation.Rotated90)
+ var observedMode: CameraLayoutMode? = null
+ var observedVfWinRot: DeviceRotation? = null
+ var observedChromeWinRot: DeviceRotation? = null
+ var observedChromeGravityDeg: Float? = null
+ var observedSolution: CameraLayoutSolution? = null
+ var observedViewfinderSize = IntSize.Zero
+
+ composeTestRule.setContent {
+ RotationLayoutUnderTest(
+ deviceRotation = windowRotation,
+ windowRotation = windowRotation,
+ onViewfinderObserved = { mode, vfWinRot, _, solution, vfSize ->
+ observedMode = mode
+ observedVfWinRot = vfWinRot
+ observedSolution = solution
+ observedViewfinderSize = vfSize
+ },
+ onChromeObserved = { chromeWinRot, chromeGravDeg, _ ->
+ observedChromeWinRot = chromeWinRot
+ observedChromeGravityDeg = chromeGravDeg
+ }
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT)
+ assertThat(observedVfWinRot).isEqualTo(DeviceRotation.Rotated90)
+ assertThat(observedChromeWinRot).isEqualTo(DeviceRotation.Rotated90)
+ assertThat(observedSolution).isNotNull()
+ assertThat(observedViewfinderSize).isEqualTo(IntSize(740, 360))
+ assertThat(((observedChromeGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(270f)
+
+ // Viewfinder top marker remains at physical top of the 740x360dp window (0° rotation).
+ val topMarker90 = composeTestRule
+ .onNodeWithTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG)
+ .getBoundsInRoot()
+ assertThat(topMarker90.top.value).isEqualTo(0f)
+
+ val captureBand90 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW)
+ val expectedCaptureCenterX90 =
+ 740f - (captureBand90.top.value + captureBand90.bottom.value) / 2f
+ val indicatorRotated90 = composeTestRule
+ .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG)
+ .fetchSemanticsNode()
+ .boundsInRoot
+ val captureRotated90 = composeTestRule
+ .onNodeWithTag(CAPTURE_BUTTON)
+ .fetchSemanticsNode()
+ .boundsInRoot
+ assertThat(indicatorRotated90.left).isGreaterThan(680f)
+ assertThat(captureRotated90.center.x).isWithin(1f).of(expectedCaptureCenterX90)
+ assertThat(captureRotated90.center.y).isWithin(1f).of(180f)
+
+ windowRotation = DeviceRotation.Rotated270
+ composeTestRule.waitForIdle()
+
+ assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT)
+ assertThat(observedVfWinRot).isEqualTo(DeviceRotation.Rotated270)
+ assertThat(observedChromeWinRot).isEqualTo(DeviceRotation.Rotated270)
+ assertThat(observedSolution).isNotNull()
+ assertThat(observedViewfinderSize).isEqualTo(IntSize(740, 360))
+ assertThat(((observedChromeGravityDeg!! % 360f) + 360f) % 360f).isEqualTo(90f)
+
+ val captureBand270 = observedSolution!!.rowBands.getValue(CameraRowIds.CAPTURE_ROW)
+ val expectedCaptureCenterX270 =
+ (captureBand270.top.value + captureBand270.bottom.value) / 2f
+ val indicatorRotated270 = composeTestRule
+ .onNodeWithTag(LayoutRotationTestTokens.INDICATOR_TAG)
+ .fetchSemanticsNode()
+ .boundsInRoot
+ val captureRotated270 = composeTestRule
+ .onNodeWithTag(CAPTURE_BUTTON)
+ .fetchSemanticsNode()
+ .boundsInRoot
+ assertThat(indicatorRotated270.right).isLessThan(60f)
+ assertThat(captureRotated270.center.x).isWithin(1f).of(expectedCaptureCenterX270)
+ assertThat(captureRotated270.center.y).isWithin(1f).of(180f)
+ }
+
+ @Config(qualifiers = "sw360dp-w800dp-h800dp-mdpi")
+ @Test
+ fun compactPortraitLayout_portraitSplitScreenOnRotatedPhone_doesNotCounterRotate() {
+ var observedMode: CameraLayoutMode? = null
+ var observedEffectiveWinRot: DeviceRotation? = null
+ var observedGravityDeg: Float? = null
+ var observedSolution: CameraLayoutSolution? = null
+ var observedViewfinderSize = IntSize.Zero
+
+ composeTestRule.setContent {
+ RotationLayoutUnderTest(
+ modifier = Modifier.size(width = 380.dp, height = 760.dp),
+ deviceRotation = DeviceRotation.Rotated90,
+ windowRotation = DeviceRotation.Rotated90,
+ onViewfinderObserved = { mode, effWinRot, gravDeg, solution, vfSize ->
+ observedMode = mode
+ observedEffectiveWinRot = effWinRot
+ observedGravityDeg = gravDeg
+ observedSolution = solution
+ observedViewfinderSize = vfSize
+ }
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT)
+ assertThat(observedEffectiveWinRot).isEqualTo(DeviceRotation.Natural)
+ assertThat(observedGravityDeg).isEqualTo(0f)
+ assertThat(observedSolution).isNotNull()
+ assertThat(observedViewfinderSize).isEqualTo(IntSize(380, 760))
+ }
+
+ @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi")
+ @Test
+ fun compactHorizontalSplitScreen_keepsViewfinderUnrotatedAcrossSizesAndHostRotations() {
+ var paneSize by mutableStateOf(DpSize(852.dp, 420.dp))
+ var hostWindowRotation by mutableStateOf(DeviceRotation.Natural)
+ var observedMode: CameraLayoutMode? = null
+ var observedVfWinRot: DeviceRotation? = null
+ var observedVfGravityDeg: Float? = null
+ var observedChromeWinRot: DeviceRotation? = null
+ var observedChromeGravityDeg: Float? = null
+ var observedViewfinderSize = IntSize.Zero
+
+ composeTestRule.setContent {
+ RotationLayoutUnderTest(
+ modifier = Modifier.size(paneSize),
+ deviceRotation = hostWindowRotation,
+ windowRotation = hostWindowRotation,
+ onViewfinderObserved = { mode, vfWinRot, vfGravDeg, _, vfSize ->
+ observedMode = mode
+ observedVfWinRot = vfWinRot
+ observedVfGravityDeg = vfGravDeg
+ observedViewfinderSize = vfSize
+ },
+ onChromeObserved = { chromeWinRot, chromeGravDeg, _ ->
+ observedChromeWinRot = chromeWinRot
+ observedChromeGravityDeg = chromeGravDeg
+ }
+ )
+ }
+
+ val paneSizes = listOf(
+ DpSize(852.dp, 420.dp) to IntSize(852, 420),
+ DpSize(800.dp, 400.dp) to IntSize(800, 400),
+ DpSize(550.dp, 500.dp) to IntSize(502, 500)
+ )
+ val hostRotations = listOf(
+ DeviceRotation.Natural,
+ DeviceRotation.Rotated90,
+ DeviceRotation.Rotated270
+ )
+
+ for ((size, expectedVfSize) in paneSizes) {
+ for (winRot in hostRotations) {
+ paneSize = size
+ hostWindowRotation = winRot
+ composeTestRule.waitForIdle()
+
+ val label = "pane=$size hostWinRot=$winRot"
+ assertWithMessage(label).that(observedMode).isEqualTo(CameraLayoutMode.COMPACT)
+ // Viewfinder stays in un-rotated host window frame (0° upright, LocalWindowRotation
+ // == windowRotation), while UI chrome counter-rotates into portrait bands.
+ assertWithMessage(label).that(observedVfWinRot).isEqualTo(winRot)
+ assertWithMessage(label).that(observedVfGravityDeg).isEqualTo(0f)
+ assertWithMessage(label).that(observedViewfinderSize).isEqualTo(expectedVfSize)
+ assertWithMessage(label).that(observedChromeWinRot?.isLandscape).isTrue()
+ assertWithMessage(label)
+ .that(((observedChromeGravityDeg!! % 360f) + 360f) % 360f)
+ .isIn(listOf(90f, 270f))
+
+ val topMarker = composeTestRule
+ .onNodeWithTag(LayoutRotationTestTokens.VIEWFINDER_TOP_MARKER_TAG)
+ .getBoundsInRoot()
+ assertWithMessage("$label viewfinder top marker upright at y=0")
+ .that(topMarker.top.value)
+ .isEqualTo(0f)
+ assertWithMessage("$label viewfinder top marker horizontal")
+ .that(topMarker.width.value)
+ .isGreaterThan(topMarker.height.value)
+ }
+ }
+ }
+
+ @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi")
+ @Test
+ fun previewDisplay_inHorizontalCompactSplitScreen_alignsSolvedBandHorizontallyWithChrome() {
+ var hostRotation by mutableStateOf(DeviceRotation.Rotated270)
+ var observedSolution: CameraLayoutSolution? = null
+
+ composeTestRule.setContent {
+ MaterialTheme {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalDisableAnimations provides true
+ ) {
+ CaptureScaffoldLayout(
+ modifier = Modifier.size(width = 800.dp, height = 400.dp),
+ deviceRotation = hostRotation,
+ windowRotation = hostRotation,
+ viewfinder = { vfModifier ->
+ observedSolution = LocalCameraLayoutSolution.current
+ PreviewDisplay(
+ modifier = vfModifier,
+ previewDisplayUiState = PreviewDisplayUiState(
+ lastBlinkTimeStamp = 0L,
+ aspectRatioUiState = AspectRatioUiState.Available(
+ selectedAspectRatio = AspectRatio.THREE_FOUR,
+ availableAspectRatios = listOf(
+ SingleSelectableUiState.SelectableUi(
+ AspectRatio.THREE_FOUR
+ )
+ )
+ )
+ ),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified,
+ useLandscapeAspectRatio = true
+ )
+ },
+ indicatorRow = { mod ->
+ Box(
+ mod
+ .size(120.dp, 36.dp)
+ .testTag(LayoutRotationTestTokens.INDICATOR_TAG)
+ )
+ },
+ captureButton = { mod ->
+ Box(mod.size(80.dp).testTag(CAPTURE_BUTTON))
+ }
+ )
+ }
+ }
+ }
+ composeTestRule.waitForIdle()
+
+ val band270 = checkNotNull(observedSolution).viewfinderBandFor(3f / 4f)!!
+ val vfBounds270 = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getBoundsInRoot()
+ // Rotated270 (-90° CCW chrome): top bar on left [0, 48], 4:3 frame [48, 48 + 533.33] x 400.
+ assertThat(vfBounds270.left.value).isWithin(1f).of(band270.top.value)
+ assertThat(vfBounds270.width.value).isWithin(1f).of(band270.height.value)
+ assertThat(vfBounds270.height.value).isWithin(1f).of(400f)
+
+ // Rotated90 (+90° CW chrome): top bar on right [752, 800], 4:3 frame flush against x=752.
+ hostRotation = DeviceRotation.Rotated90
+ composeTestRule.waitForIdle()
+
+ val band90 = checkNotNull(observedSolution).viewfinderBandFor(3f / 4f)!!
+ val vfBounds90 = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getBoundsInRoot()
+ assertThat(vfBounds90.right.value).isWithin(1f).of(800f - band90.top.value)
+ assertThat(vfBounds90.width.value).isWithin(1f).of(band90.height.value)
+ assertThat(vfBounds90.height.value).isWithin(1f).of(400f)
+ }
+
+ @Config(qualifiers = "sw800dp-w1400dp-h1400dp-mdpi")
+ @Test
+ fun foldableTabletAndCompactShortLayouts_neverCounterRotate() {
+ var containerSize by mutableStateOf(DpSize(1280.dp, 800.dp))
+ var windowRotation by mutableStateOf(DeviceRotation.Rotated90)
+ var observedMode: CameraLayoutMode? = null
+ var observedViewfinderSize = IntSize.Zero
+
+ composeTestRule.setContent {
+ RotationLayoutUnderTest(
+ modifier = Modifier.size(containerSize),
+ deviceRotation = windowRotation,
+ windowRotation = windowRotation,
+ slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true),
+ onViewfinderObserved = { mode, _, _, _, vfSize ->
+ observedMode = mode
+ observedViewfinderSize = vfSize
+ }
+ )
+ }
+
+ val testPostures = listOf(
+ Triple(DpSize(1280.dp, 800.dp), CameraLayoutMode.TABLET_LANDSCAPE, IntSize(1280, 800)),
+ Triple(DpSize(800.dp, 1280.dp), CameraLayoutMode.TABLET_PORTRAIT, IntSize(800, 1280)),
+ Triple(DpSize(883.dp, 852.dp), CameraLayoutMode.FOLDABLE, IntSize(883, 852)),
+ Triple(DpSize(440.dp, 412.dp), CameraLayoutMode.COMPACT_SHORT, IntSize(440, 412))
+ )
+
+ for ((size, expectedMode, expectedVfSize) in testPostures) {
+ for (rotation in listOf(DeviceRotation.Rotated90, DeviceRotation.Rotated270)) {
+ containerSize = size
+ windowRotation = rotation
+ composeTestRule.waitForIdle()
+
+ assertThat(observedMode).isEqualTo(expectedMode)
+ assertThat(observedViewfinderSize).isEqualTo(expectedVfSize)
+
+ composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertIsDisplayed()
+
+ val startRailBounds = composeTestRule
+ .onNodeWithTag(START_SIDE_RAIL_TAG)
+ .fetchSemanticsNode()
+ .boundsInRoot
+ val endRailBounds = composeTestRule
+ .onNodeWithTag(END_SIDE_RAIL_TAG)
+ .fetchSemanticsNode()
+ .boundsInRoot
+
+ assertThat(startRailBounds.left).isLessThan(endRailBounds.left)
+ assertThat(startRailBounds.height).isGreaterThan(startRailBounds.width)
+ assertThat(endRailBounds.height).isGreaterThan(endRailBounds.width)
+ }
+ }
+ }
+
+ @Test
+ fun cutoutAwareRow_remapsCutoutsUnderRotated90AndRotated270() {
+ val rotated90RowBounds = IntRect(left = 692, top = 0, right = 740, bottom = 360)
+ val rotated90WindowCutout = listOf(AndroidRect(692, 20, 740, 60))
+
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = rotated90WindowCutout,
+ rowBoundsInWindow = rotated90RowBounds,
+ isRtl = false,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated90,
+ fallbackViewWidth = 740,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48))
+
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = rotated90WindowCutout,
+ rowBoundsInWindow = rotated90RowBounds,
+ isRtl = true,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated90,
+ fallbackViewWidth = 740,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48))
+
+ val rotated270RowBounds = IntRect(left = 0, top = 0, right = 48, bottom = 360)
+ val rotated270WindowCutout = listOf(AndroidRect(0, 300, 48, 340))
+
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = rotated270WindowCutout,
+ rowBoundsInWindow = rotated270RowBounds,
+ isRtl = false,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated270,
+ fallbackViewWidth = 740,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 20, top = 0, right = 60, bottom = 48))
+
+ assertThat(
+ resolveLocalCutoutRects(
+ cutoutRectsOverride = null,
+ windowCutoutRects = rotated270WindowCutout,
+ rowBoundsInWindow = rotated270RowBounds,
+ isRtl = true,
+ rtlReferenceWidth = 360,
+ windowRotation = DeviceRotation.Rotated270,
+ fallbackViewWidth = 740,
+ fallbackViewHeight = 360
+ )
+ ).containsExactly(IntRect(left = 300, top = 0, right = 340, bottom = 48))
+
+ var placedSegments = emptyList()
+ composeTestRule.setContent {
+ CompositionLocalProvider(
+ LocalDensity provides Density(1f),
+ LocalWindowRotation provides DeviceRotation.Rotated90
+ ) {
+ CutoutAwareRow(
+ pillHorizontalPadding = 8.dp,
+ pillMinHeight = 32.dp,
+ cutoutRectsOverride = listOf(IntRect(20, 0, 60, 48)),
+ onPillSegmentsPlaced = { placedSegments = it }
+ ) {
+ Box(Modifier.size(24.dp))
+ }
+ }
+ }
+ composeTestRule.waitForIdle()
+ assertThat(placedSegments).hasSize(1)
+ assertThat(placedSegments.single().left).isAtLeast(68)
+ }
+}
diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt
new file mode 100644
index 0000000000..6c19372ba9
--- /dev/null
+++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt
@@ -0,0 +1,821 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.jetpackcamera.ui.components.capture
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.graphics.Matrix
+import android.graphics.Rect as AndroidRect
+import android.util.Size
+import android.view.KeyEvent as AndroidKeyEvent
+import android.view.Surface
+import androidx.camera.core.DynamicRange as CXDynamicRange
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.impl.CameraInternal
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertContentDescriptionEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertRangeInfoEquals
+import androidx.compose.ui.test.click
+import androidx.compose.ui.test.doubleClick
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.isFocusable
+import androidx.compose.ui.test.isNotFocusable
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performKeyPress
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.pinch
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.width
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.google.jetpackcamera.model.AspectRatio
+import com.google.jetpackcamera.model.DeviceRotation
+import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState
+import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState
+import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState
+import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState
+import java.lang.reflect.Proxy
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Unit and Robolectric Compose UI tests for [calculateViewfinderSize] and [PreviewDisplay].
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(qualifiers = "w1400dp-h1400dp")
+class PreviewDisplayTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val createdSurfaceRequests = mutableListOf()
+
+ @SuppressLint("RestrictedApi")
+ @After
+ fun tearDown() {
+ createdSurfaceRequests.forEach { request ->
+ request.willNotProvideSurface()
+ request.deferrableSurface.close()
+ }
+ createdSurfaceRequests.clear()
+ }
+
+ @Test
+ fun calculateViewfinderSize_phonePortrait_usesPortraitAspectRatios() {
+ val threeFour = calculateViewfinderSize(
+ maxWidth = 412.dp,
+ maxHeight = 915.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = false
+ )
+ assertThat(threeFour.width.value).isWithin(0.01f).of(412f)
+ assertThat(threeFour.height.value).isWithin(0.01f).of(412f * 4f / 3f)
+
+ val nineSixteen = calculateViewfinderSize(
+ maxWidth = 412.dp,
+ maxHeight = 915.dp,
+ aspectRatio = AspectRatio.NINE_SIXTEEN,
+ useLandscapeAspectRatio = false
+ )
+ assertThat(nineSixteen.width.value).isWithin(0.01f).of(412f)
+ assertThat(nineSixteen.height.value).isWithin(0.01f).of(412f * 16f / 9f)
+
+ val oneOne = calculateViewfinderSize(
+ maxWidth = 412.dp,
+ maxHeight = 915.dp,
+ aspectRatio = AspectRatio.ONE_ONE,
+ useLandscapeAspectRatio = false
+ )
+ assertThat(oneOne).isEqualTo(DpSize(412.dp, 412.dp))
+ }
+
+ @Test
+ fun calculateViewfinderSize_phoneRotated_withoutLandscapeFlag_keepsPortraitRatio() {
+ val rotatedThreeFour = calculateViewfinderSize(
+ maxWidth = 915.dp,
+ maxHeight = 412.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = false
+ )
+ assertThat(rotatedThreeFour.width.value).isWithin(0.01f).of(309f)
+ assertThat(rotatedThreeFour.height.value).isWithin(0.01f).of(412f)
+ }
+
+ @Test
+ fun calculateViewfinderSize_tabletHorizontal_withLandscapeFlag_usesLandscapeRatios() {
+ val fourThree = calculateViewfinderSize(
+ maxWidth = 1280.dp,
+ maxHeight = 800.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(fourThree.width.value).isWithin(0.01f).of(800f * 4f / 3f)
+ assertThat(fourThree.height.value).isWithin(0.01f).of(800f)
+
+ val sixteenNine = calculateViewfinderSize(
+ maxWidth = 1280.dp,
+ maxHeight = 800.dp,
+ aspectRatio = AspectRatio.NINE_SIXTEEN,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(sixteenNine.width.value).isWithin(0.01f).of(1280f)
+ assertThat(sixteenNine.height.value).isWithin(0.01f).of(720f)
+
+ val oneOne = calculateViewfinderSize(
+ maxWidth = 1280.dp,
+ maxHeight = 800.dp,
+ aspectRatio = AspectRatio.ONE_ONE,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(oneOne).isEqualTo(DpSize(800.dp, 800.dp))
+ }
+
+ @Test
+ fun calculateViewfinderSize_tabletVertical_withLandscapeFlag_keepsPortraitRatios() {
+ val threeFour = calculateViewfinderSize(
+ maxWidth = 800.dp,
+ maxHeight = 1280.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(threeFour.width.value).isWithin(0.01f).of(800f)
+ assertThat(threeFour.height.value).isWithin(0.01f).of(800f * 4f / 3f)
+
+ val nineSixteen = calculateViewfinderSize(
+ maxWidth = 800.dp,
+ maxHeight = 1280.dp,
+ aspectRatio = AspectRatio.NINE_SIXTEEN,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(nineSixteen.width.value).isWithin(0.01f).of(720f)
+ assertThat(nineSixteen.height.value).isWithin(0.01f).of(1280f)
+ }
+
+ @Test
+ fun calculateViewfinderSize_foldable_handlesNearSquareBounds() {
+ val portraitFoldableThreeFour = calculateViewfinderSize(
+ maxWidth = 840.dp,
+ maxHeight = 900.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(portraitFoldableThreeFour.width.value).isWithin(0.01f).of(675f)
+ assertThat(portraitFoldableThreeFour.height.value).isWithin(0.01f).of(900f)
+
+ val portraitFoldableOneOne = calculateViewfinderSize(
+ maxWidth = 840.dp,
+ maxHeight = 900.dp,
+ aspectRatio = AspectRatio.ONE_ONE,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(portraitFoldableOneOne).isEqualTo(DpSize(840.dp, 840.dp))
+
+ val landscapeFoldableFourThree = calculateViewfinderSize(
+ maxWidth = 900.dp,
+ maxHeight = 840.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = true
+ )
+ assertThat(landscapeFoldableFourThree.width.value).isWithin(0.01f).of(900f)
+ assertThat(landscapeFoldableFourThree.height.value).isWithin(0.01f).of(675f)
+ }
+
+ @Test
+ fun calculateViewfinderSize_zeroOrNegativeBounds_returnsZero() {
+ assertThat(
+ calculateViewfinderSize(
+ maxWidth = 0.dp,
+ maxHeight = 800.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = true
+ )
+ ).isEqualTo(DpSize.Zero)
+
+ assertThat(
+ calculateViewfinderSize(
+ maxWidth = 412.dp,
+ maxHeight = 0.dp,
+ aspectRatio = AspectRatio.THREE_FOUR,
+ useLandscapeAspectRatio = false
+ )
+ ).isEqualTo(DpSize.Zero)
+
+ assertThat(
+ calculateViewfinderSize(
+ maxWidth = (-1).dp,
+ maxHeight = 800.dp,
+ aspectRatio = AspectRatio.ONE_ONE,
+ useLandscapeAspectRatio = false
+ )
+ ).isEqualTo(DpSize.Zero)
+ }
+
+ @Test
+ fun previewDisplay_topCenteredDefault_positionsViewfinderAndUpdatesOverlapTargetBounds() {
+ val targetBoundsState = mutableStateOf(Rect.Zero)
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(
+ LocalOverlapTargetBounds provides targetBoundsState,
+ LocalDisableAnimations provides true
+ ) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 500.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ val bounds = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot()
+ assertThat(bounds.left).isEqualTo(0.dp)
+ assertThat(bounds.top).isEqualTo(0.dp)
+ assertThat(bounds.width).isEqualTo(300.dp)
+ assertThat(bounds.height).isEqualTo(400.dp)
+
+ assertThat(targetBoundsState.value).isEqualTo(Rect(0f, 0f, 300f, 400f))
+ }
+
+ @Test
+ fun previewDisplay_centeredLandscapeFraming_updatesBoundsAndOverlapTargetOnRatioChange() {
+ val targetBoundsState = mutableStateOf(Rect.Zero)
+ var selectedRatio by mutableStateOf(AspectRatio.THREE_FOUR)
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(
+ LocalOverlapTargetBounds provides targetBoundsState,
+ LocalDisableAnimations provides true
+ ) {
+ Box(modifier = Modifier.size(width = 1280.dp, height = 800.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(selectedRatio),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified,
+ contentAlignment = Alignment.Center,
+ useLandscapeAspectRatio = true
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ val pillarboxBounds =
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot()
+ assertThat(pillarboxBounds.left.value).isWithin(1f).of(106.5f)
+ assertThat(pillarboxBounds.top).isEqualTo(0.dp)
+ assertThat(pillarboxBounds.width.value).isWithin(1f).of(1067f)
+ assertThat(pillarboxBounds.height).isEqualTo(800.dp)
+ assertThat(targetBoundsState.value.left).isWithin(1f).of(106.5f)
+ assertThat(targetBoundsState.value.top).isEqualTo(0f)
+ assertThat(targetBoundsState.value.width).isWithin(1f).of(1067f)
+ assertThat(targetBoundsState.value.height).isEqualTo(800f)
+
+ selectedRatio = AspectRatio.NINE_SIXTEEN
+ composeTestRule.waitForIdle()
+
+ val letterboxBounds =
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).getUnclippedBoundsInRoot()
+ assertThat(letterboxBounds.left).isEqualTo(0.dp)
+ assertThat(letterboxBounds.top).isEqualTo(40.dp)
+ assertThat(letterboxBounds.width).isEqualTo(1280.dp)
+ assertThat(letterboxBounds.height).isEqualTo(720.dp)
+ assertThat(targetBoundsState.value).isEqualTo(Rect(0f, 40f, 1280f, 760f))
+ }
+
+ @Test
+ fun previewDisplay_ltrAndRtl_produceIdenticalTapToFocusCoordinatesAndIndicatorBounds() {
+ val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800)
+ var layoutDirection by mutableStateOf(LayoutDirection.Ltr)
+ var focusMeteringUiState by
+ mutableStateOf(FocusMeteringUiState.Unspecified)
+ val recordedTaps = mutableListOf()
+ var flipCount = 0
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides layoutDirection,
+ LocalDisableAnimations provides true
+ ) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) },
+ onFlipCamera = { flipCount++ },
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = focusMeteringUiState
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ // Tap at an asymmetric physical point (left quarter of the 300x400 viewfinder) in LTR.
+ val physicalTapOffset = Offset(75f, 120f)
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput {
+ click(physicalTapOffset)
+ }
+ composeTestRule.mainClock.advanceTimeBy(500L)
+ composeTestRule.waitForIdle()
+
+ assertThat(recordedTaps).hasSize(1)
+ val ltrTap = recordedTaps.single()
+ assertThat(ltrTap.x).isLessThan(300f)
+
+ focusMeteringUiState = FocusMeteringUiState.Specified(
+ surfaceCoordinates = ltrTap,
+ status = FocusMeteringUiState.Status.RUNNING
+ )
+ composeTestRule.waitForIdle()
+
+ val ltrIndicatorBounds =
+ composeTestRule.onNodeWithTag(FOCUS_METERING_INDICATOR_TAG).getUnclippedBoundsInRoot()
+ assertThat(ltrIndicatorBounds.left.value).isLessThan(150f)
+
+ // Switch ambient layout direction to RTL and repeat the identical physical tap.
+ recordedTaps.clear()
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ layoutDirection = LayoutDirection.Rtl
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput {
+ click(physicalTapOffset)
+ }
+ composeTestRule.mainClock.advanceTimeBy(500L)
+ composeTestRule.waitForIdle()
+
+ assertThat(recordedTaps).hasSize(1)
+ val rtlTap = recordedTaps.single()
+ assertThat(rtlTap.x).isWithin(0.01f).of(ltrTap.x)
+ assertThat(rtlTap.y).isWithin(0.01f).of(ltrTap.y)
+
+ focusMeteringUiState = FocusMeteringUiState.Specified(
+ surfaceCoordinates = rtlTap,
+ status = FocusMeteringUiState.Status.RUNNING
+ )
+ composeTestRule.waitForIdle()
+
+ val rtlIndicatorBounds =
+ composeTestRule.onNodeWithTag(FOCUS_METERING_INDICATOR_TAG).getUnclippedBoundsInRoot()
+ assertThat(rtlIndicatorBounds).isEqualTo(ltrIndicatorBounds)
+
+ // Verify double-tap flip callback still dispatches through rememberUpdatedState.
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput {
+ doubleClick(physicalTapOffset)
+ }
+ composeTestRule.mainClock.advanceTimeBy(500L)
+ composeTestRule.waitForIdle()
+ assertThat(flipCount).isEqualTo(1)
+ }
+
+ @Test
+ fun previewDisplay_updatedCallbacksAndPinchZoom_dispatchLatestHandlers() {
+ val surfaceRequest = createFakeSurfaceRequest(width = 600, height = 800)
+ val zoomFactors = mutableListOf()
+ var activeGeneration by mutableStateOf(1)
+ val flippedGenerations = mutableListOf()
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ val capturedGeneration = activeGeneration
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = { flippedGenerations.add(capturedGeneration) },
+ onScaleZoom = { zoom -> zoomFactors.add(zoom) },
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ activeGeneration = 2
+ composeTestRule.waitForIdle()
+
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput {
+ doubleClick(Offset(150f, 200f))
+ }
+ composeTestRule.mainClock.advanceTimeBy(500L)
+ composeTestRule.waitForIdle()
+ assertThat(flippedGenerations).containsExactly(2)
+
+ composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).performTouchInput {
+ pinch(
+ start0 = Offset(100f, 200f),
+ end0 = Offset(50f, 200f),
+ start1 = Offset(200f, 200f),
+ end1 = Offset(250f, 200f)
+ )
+ }
+ composeTestRule.waitForIdle()
+ assertThat(zoomFactors).isNotEmpty()
+ assertThat(zoomFactors.any { it > 1f }).isTrue()
+ }
+
+ @Test
+ fun previewDisplay_loadingState_withAnimationsDisabled_settlesAndExposesLoadingSemantics() {
+ val context = ApplicationProvider.getApplicationContext()
+ val loadingDescription = context.getString(R.string.camera_not_ready)
+ assertThat(loadingDescription).isEqualTo("Camera Loading…")
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = null,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithContentDescription(loadingDescription)
+ .assertIsDisplayed()
+ .assertContentDescriptionEquals("Camera Loading…")
+ .assertRangeInfoEquals(ProgressBarRangeInfo.Indeterminate)
+ }
+
+ @Test
+ fun previewDisplay_viewfinder_exposesCenterFocusSemanticsAndKeyboardActivation() {
+ val context = ApplicationProvider.getApplicationContext()
+ val viewfinderDescription =
+ context.getString(R.string.camera_viewfinder_content_description)
+ val focusActionLabel = context.getString(R.string.preview_tap_to_focus_action_label)
+ assertThat(viewfinderDescription).isEqualTo("Camera viewfinder")
+ assertThat(focusActionLabel).isEqualTo("Focus center")
+
+ val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400)
+ val recordedTaps = mutableListOf()
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { x, y -> recordedTaps.add(Offset(x, y)) },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+
+ val viewfinderNode = composeTestRule.onNodeWithContentDescription("Camera viewfinder")
+ viewfinderNode
+ .assertIsDisplayed()
+ .assertContentDescriptionEquals("Camera viewfinder")
+ .assert(isFocusable())
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+
+ val onClickAction =
+ viewfinderNode.fetchSemanticsNode().config.getOrNull(SemanticsActions.OnClick)
+ assertThat(onClickAction).isNotNull()
+ assertThat(onClickAction?.label).isEqualTo("Focus center")
+
+ viewfinderNode.performSemanticsAction(SemanticsActions.OnClick)
+ composeTestRule.waitForIdle()
+ assertThat(recordedTaps).hasSize(1)
+ assertThat(recordedTaps.single().x).isWithin(0.5f).of(150f)
+ assertThat(recordedTaps.single().y).isWithin(0.5f).of(200f)
+
+ viewfinderNode.performSemanticsAction(SemanticsActions.RequestFocus)
+ viewfinderNode.assertIsFocused()
+
+ val activationKeyCodes = listOf(
+ AndroidKeyEvent.KEYCODE_ENTER,
+ AndroidKeyEvent.KEYCODE_NUMPAD_ENTER,
+ AndroidKeyEvent.KEYCODE_DPAD_CENTER,
+ AndroidKeyEvent.KEYCODE_SPACE
+ )
+ activationKeyCodes.forEach { keyCode ->
+ recordedTaps.clear()
+ val downConsumed = viewfinderNode.performKeyPress(
+ KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_DOWN, keyCode))
+ )
+ assertThat(downConsumed).isTrue()
+ assertThat(recordedTaps).isEmpty()
+
+ val upConsumed = viewfinderNode.performKeyPress(
+ KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_UP, keyCode))
+ )
+ assertThat(upConsumed).isTrue()
+ assertThat(recordedTaps).hasSize(1)
+ assertThat(recordedTaps.single().x).isWithin(0.5f).of(150f)
+ assertThat(recordedTaps.single().y).isWithin(0.5f).of(200f)
+ }
+
+ recordedTaps.clear()
+ val nonActivationConsumed = viewfinderNode.performKeyPress(
+ KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_A))
+ )
+ assertThat(nonActivationConsumed).isFalse()
+ assertThat(recordedTaps).isEmpty()
+ }
+
+ @Test
+ fun previewDisplay_viewfinderBelowMinimumTouchTarget_disablesFocusAndClickSemantics() {
+ val surfaceRequest = createFakeSurfaceRequest(width = 32, height = 32)
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 32.dp, height = 32.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(AspectRatio.ONE_ONE),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithContentDescription("Camera viewfinder")
+ .assertIsDisplayed()
+ .assert(isNotFocusable())
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick))
+ }
+
+ @Test
+ fun previewDisplay_captureBlink_skipsZeroAlphaStrobeWhenAnimationsDisabled() {
+ val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400)
+ var lastBlinkTimeStamp by mutableLongStateOf(0L)
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides true) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(
+ aspectRatio = AspectRatio.THREE_FOUR,
+ lastBlinkTimeStamp = lastBlinkTimeStamp
+ ),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ val alphaProvider = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY)
+ .fetchSemanticsNode()
+ .config[PreviewBlinkAlphaProviderKey]
+ assertThat(alphaProvider()).isEqualTo(1f)
+
+ composeTestRule.mainClock.autoAdvance = false
+ try {
+ lastBlinkTimeStamp = 1_000L
+ val observedAlphas = mutableListOf()
+ repeat(15) {
+ composeTestRule.mainClock.advanceTimeBy(16L)
+ observedAlphas.add(alphaProvider())
+ }
+ assertThat(observedAlphas).isNotEmpty()
+ assertThat(observedAlphas.all { it == 1f }).isTrue()
+ } finally {
+ composeTestRule.mainClock.autoAdvance = true
+ }
+ }
+
+ @Test
+ fun previewDisplay_captureBlink_animatesToZeroAndRecoversWhenAnimationsEnabled() {
+ val surfaceRequest = createFakeSurfaceRequest(width = 300, height = 400)
+ var lastBlinkTimeStamp by mutableLongStateOf(0L)
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDisableAnimations provides false) {
+ Box(modifier = Modifier.size(width = 300.dp, height = 400.dp)) {
+ PreviewDisplay(
+ previewDisplayUiState = createUiState(
+ aspectRatio = AspectRatio.THREE_FOUR,
+ lastBlinkTimeStamp = lastBlinkTimeStamp
+ ),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = {},
+ surfaceRequest = surfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ val alphaProvider = composeTestRule.onNodeWithTag(PREVIEW_DISPLAY)
+ .fetchSemanticsNode()
+ .config[PreviewBlinkAlphaProviderKey]
+ assertThat(alphaProvider()).isEqualTo(1f)
+
+ composeTestRule.mainClock.autoAdvance = false
+ try {
+ lastBlinkTimeStamp = 1_000L
+ val observedAlphas = mutableListOf()
+ repeat(20) {
+ Snapshot.sendApplyNotifications()
+ composeTestRule.mainClock.advanceTimeBy(16L)
+ observedAlphas.add(alphaProvider())
+ }
+ assertThat(observedAlphas).contains(0f)
+ assertThat(observedAlphas.last()).isEqualTo(1f)
+ } finally {
+ composeTestRule.mainClock.autoAdvance = true
+ }
+ }
+
+ @Test
+ fun previewDisplay_withHdrSurfaceRequest_requestsColorModeHdrAcrossAllPostures() {
+ val hdrSurfaceRequest = createFakeSurfaceRequest(
+ width = 1920,
+ height = 1080,
+ dynamicRange = CXDynamicRange.HLG_10_BIT
+ )
+ var containerSize by mutableStateOf(DpSize(412.dp, 915.dp))
+ var windowRotation by mutableStateOf(DeviceRotation.Natural)
+ val requestedColorModes = mutableListOf()
+
+ composeTestRule.setContent {
+ CaptureScaffoldLayout(
+ modifier = Modifier.size(containerSize),
+ deviceRotation = windowRotation,
+ windowRotation = windowRotation,
+ viewfinder = { vfModifier ->
+ PreviewDisplay(
+ modifier = vfModifier,
+ previewDisplayUiState = createUiState(AspectRatio.THREE_FOUR),
+ onTapToFocus = { _, _ -> },
+ onFlipCamera = {},
+ onScaleZoom = {},
+ onRequestWindowColorMode = { requestedColorModes += it },
+ surfaceRequest = hdrSurfaceRequest,
+ focusMeteringUiState = FocusMeteringUiState.Unspecified,
+ useLandscapeAspectRatio = true
+ )
+ }
+ )
+ }
+ composeTestRule.waitForIdle()
+ assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR)
+
+ // Counter-rotated horizontal compact split-screen (800x400dp) preserves EXTERNAL + HDR.
+ containerSize = DpSize(800.dp, 400.dp)
+ windowRotation = DeviceRotation.Rotated90
+ composeTestRule.waitForIdle()
+ assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR)
+
+ // Side-rail tablet landscape (1280x800dp) and round-trip back to compact portrait
+ // (412x915dp) preserve EXTERNAL + HDR without resetting to COLOR_MODE_DEFAULT.
+ containerSize = DpSize(1280.dp, 800.dp)
+ windowRotation = DeviceRotation.Natural
+ composeTestRule.waitForIdle()
+ assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR)
+
+ containerSize = DpSize(412.dp, 915.dp)
+ composeTestRule.waitForIdle()
+ assertThat(requestedColorModes.last()).isEqualTo(ActivityInfo.COLOR_MODE_HDR)
+ assertThat(requestedColorModes).doesNotContain(ActivityInfo.COLOR_MODE_DEFAULT)
+ }
+
+ private fun createUiState(
+ aspectRatio: AspectRatio,
+ lastBlinkTimeStamp: Long = 0L
+ ): PreviewDisplayUiState = PreviewDisplayUiState(
+ lastBlinkTimeStamp = lastBlinkTimeStamp,
+ aspectRatioUiState = AspectRatioUiState.Available(
+ selectedAspectRatio = aspectRatio,
+ availableAspectRatios = listOf(
+ SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR),
+ SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN),
+ SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE)
+ )
+ )
+ )
+
+ @SuppressLint("RestrictedApi")
+ private fun createFakeSurfaceRequest(
+ width: Int,
+ height: Int,
+ dynamicRange: CXDynamicRange = CXDynamicRange.SDR
+ ): SurfaceRequest {
+ val fakeCamera = Proxy.newProxyInstance(
+ CameraInternal::class.java.classLoader,
+ arrayOf(CameraInternal::class.java)
+ ) { proxy, method, args ->
+ when (method.name) {
+ "equals" -> proxy === args?.firstOrNull()
+ "hashCode" -> System.identityHashCode(proxy)
+ "toString" -> "FakeCameraInternal"
+ "getHasTransform" -> true
+ "isFrontFacing" -> false
+ "isRemoved" -> false
+ else -> when (method.returnType) {
+ Boolean::class.javaPrimitiveType -> false
+ Int::class.javaPrimitiveType -> 0
+ Long::class.javaPrimitiveType -> 0L
+ Float::class.javaPrimitiveType -> 0f
+ Double::class.javaPrimitiveType -> 0.0
+ else -> null
+ }
+ }
+ } as CameraInternal
+
+ return SurfaceRequest(
+ Size(width, height),
+ fakeCamera,
+ dynamicRange,
+ SurfaceRequest.FRAME_RATE_RANGE_UNSPECIFIED
+ ) {}.also { request ->
+ request.updateTransformationInfo(
+ SurfaceRequest.TransformationInfo.of(
+ AndroidRect(0, 0, width, height),
+ 0,
+ Surface.ROTATION_0,
+ true,
+ Matrix(),
+ false
+ )
+ )
+ createdSurfaceRequests.add(request)
+ }
+ }
+}
diff --git a/ui/components/capture/src/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..36aace2082
--- /dev/null
+++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt
@@ -0,0 +1,312 @@
+/*
+ * 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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+<<<<<<< HEAD
+import androidx.compose.ui.Alignment
+=======
+>>>>>>> kim/adaptive/debug-layout-readout
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth.assertThat
+import com.google.jetpackcamera.model.DeviceRotation
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(qualifiers = "w1000dp-h1000dp-mdpi")
+class RotationModifiersTest {
+
+ @get:Rule
+ 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 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
+ 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))
+ }
+
+ @Test
+ fun counterRotateToPortrait_swapsConstraintsAndRotatesForLandscape_andIsNoOpForPortrait() {
+ val topStripTag = "CounterRotateTopStrip"
+ var windowRotation by mutableStateOf(DeviceRotation.Natural)
+ var outerSize = IntSize.Zero
+ var innerMeasuredSize = IntSize.Zero
+
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ Box(
+ modifier = Modifier
+ .size(width = 740.dp, height = 360.dp)
+ .onSizeChanged { outerSize = it }
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .counterRotateToPortrait(windowRotation)
+ .onSizeChanged { innerMeasuredSize = it }
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .fillMaxWidth()
+ .height(48.dp)
+ .testTag(topStripTag)
+ )
+ // Also exercise unbounded width constraint swapping without crashing.
+ Box(
+ modifier = Modifier
+ .wrapContentWidth(unbounded = true)
+ .counterRotateToPortrait(windowRotation)
+ .size(width = 24.dp, height = 12.dp)
+ )
+ }
+ }
+ }
+ }
+
+ // Natural: no constraint swap, placed at (0, 0) with 0° rotation.
+ composeTestRule.waitForIdle()
+ assertThat(outerSize).isEqualTo(IntSize(740, 360))
+ assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360))
+ assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot)
+ .isEqualTo(Rect(0f, 0f, 740f, 48f))
+
+ // Rotated180: still portrait (!isLandscape), complete no-op with 0° rotation.
+ windowRotation = DeviceRotation.Rotated180
+ composeTestRule.waitForIdle()
+ assertThat(innerMeasuredSize).isEqualTo(IntSize(740, 360))
+ assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot)
+ .isEqualTo(Rect(0f, 0f, 740f, 48f))
+
+ // Rotated90: swaps constraints to 360x740 and counter-rotates +90° CW -> top strip on right
+ windowRotation = DeviceRotation.Rotated90
+ composeTestRule.waitForIdle()
+ assertThat(outerSize).isEqualTo(IntSize(740, 360))
+ assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740))
+ assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot)
+ .isEqualTo(Rect(692f, 0f, 740f, 360f))
+
+ // Rotated270: swaps constraints to 360x740 and counter-rotates -90° CCW -> top on left.
+ windowRotation = DeviceRotation.Rotated270
+ composeTestRule.waitForIdle()
+ assertThat(outerSize).isEqualTo(IntSize(740, 360))
+ assertThat(innerMeasuredSize).isEqualTo(IntSize(360, 740))
+ assertThat(composeTestRule.onNodeWithTag(topStripTag).fetchSemanticsNode().boundsInRoot)
+ .isEqualTo(Rect(0f, 0f, 48f, 360f))
+ }
+
+ @Test
+ fun toPortraitWindowInsets_remapsAllEdges_acrossAllRotationsInLtrAndRtl() {
+ val density = Density(1f)
+ val observedDirections = mutableListOf()
+ val baseInsets = object : WindowInsets {
+ override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int {
+ observedDirections.add(layoutDirection)
+ return 10
+ }
+
+ override fun getTop(density: Density): Int = 20
+
+ override fun getRight(density: Density, layoutDirection: LayoutDirection): Int {
+ observedDirections.add(layoutDirection)
+ return 30
+ }
+
+ override fun getBottom(density: Density): Int = 40
+ }
+
+ for (direction in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) {
+ observedDirections.clear()
+ val natural = baseInsets.toPortraitWindowInsets(DeviceRotation.Natural)
+ assertThat(natural.getLeft(density, direction)).isEqualTo(10)
+ assertThat(natural.getTop(density)).isEqualTo(20)
+ assertThat(natural.getRight(density, direction)).isEqualTo(30)
+ assertThat(natural.getBottom(density)).isEqualTo(40)
+
+ val rotated270 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270)
+ assertThat(rotated270.getLeft(density, direction)).isEqualTo(40)
+ assertThat(rotated270.getTop(density)).isEqualTo(10)
+ assertThat(rotated270.getRight(density, direction)).isEqualTo(20)
+ assertThat(rotated270.getBottom(density)).isEqualTo(30)
+
+ val rotated90 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90)
+ assertThat(rotated90.getLeft(density, direction)).isEqualTo(20)
+ assertThat(rotated90.getTop(density)).isEqualTo(30)
+ assertThat(rotated90.getRight(density, direction)).isEqualTo(40)
+ assertThat(rotated90.getBottom(density)).isEqualTo(10)
+
+ val rotated180 = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated180)
+ assertThat(rotated180.getLeft(density, direction)).isEqualTo(30)
+ assertThat(rotated180.getTop(density)).isEqualTo(40)
+ assertThat(rotated180.getRight(density, direction)).isEqualTo(10)
+ assertThat(rotated180.getBottom(density)).isEqualTo(20)
+
+ // Physical window edges must always query baseInsets with LayoutDirection.Ltr.
+ assertThat(observedDirections).isNotEmpty()
+ assertThat(observedDirections.all { it == LayoutDirection.Ltr }).isTrue()
+ }
+
+ // Verify value equality and hashCode contract on PortraitWindowInsets.
+ val first = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90)
+ val second = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated90)
+ val different = baseInsets.toPortraitWindowInsets(DeviceRotation.Rotated270)
+ assertThat(first).isEqualTo(second)
+ assertThat(first.hashCode()).isEqualTo(second.hashCode())
+ assertThat(first).isNotEqualTo(different)
+ }
+}
diff --git a/ui/debug/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 de8ffc2c2f..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
@@ -15,12 +15,21 @@
*/
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
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 +42,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,11 +52,18 @@ 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
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.SideEffect
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
@@ -54,6 +71,13 @@ 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.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
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -61,9 +85,15 @@ 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 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"
@@ -104,12 +134,492 @@ private fun ZoomRatioText(modifier: Modifier = Modifier, primaryZoomRatio: Float
)
}
+/**
+ * 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,
+ COMPACT_SLIM,
+ COMPACT_SHORT,
+ COMPACT_SHORT_SLIM,
+ 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 7-mode breakpoint and `±7dp` hysteresis rules.
+ */
+ fun fromWindowDp(
+ widthDp: Int,
+ heightDp: Int,
+ 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 if (isSlim) COMPACT_SLIM else COMPACT
+ }
+
+ 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 {
+ heightDp >= SHORT_BREAKPOINT_DP + HYSTERESIS_HALF_BAND_DP
+ }
+ 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 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 {
+ 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
+
+ val previousHolder = remember { PreviousDebugFormFactorHolder() }
+ val resolved = DebugFormFactor.fromWindowDp(
+ widthDp = widthDp,
+ heightDp = heightDp,
+ isFullScreenCompactPhone = isFullScreenCompactPhone,
+ previousFormFactor = previousHolder.formFactor
+ )
+ SideEffect {
+ previousHolder.formFactor = resolved
+ }
+ return resolved
+}
+
+private tailrec fun Context.findDebugActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findDebugActivity()
+ else -> null
+}
+
+/**
+ * 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.
+ */
+@Immutable
+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
+) {
+ /**
+ * Clockwise rotation in degrees needed inside the current window so that icons and overlays
+ * 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_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.FOLDABLE,
+ DebugFormFactor.TABLET_PORTRAIT,
+ DebugFormFactor.TABLET_LANDSCAPE,
+ DebugFormFactor.COMPACT_SHORT,
+ DebugFormFactor.COMPACT_SHORT_SLIM,
+ DebugFormFactor.UNKNOWN -> 0
+ DebugFormFactor.COMPACT,
+ DebugFormFactor.COMPACT_SLIM ->
+ if (heightDp >= widthDp) {
+ 0
+ } else {
+ 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
+ }
+}
+
+/**
+ * Displays the debug layout and rotation readout as individually tagged rows for window container
+ * 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.
+ */
+@Composable
+internal fun DebugLayoutReadout(
+ state: DebugLayoutReadoutState? = null,
+ modifier: Modifier = Modifier
+) {
+ val resolvedState = if (state != null) {
+ state
+ } else {
+ val view = LocalView.current
+ val liveWindowRotation = DeviceRotation.fromUiSurfaceRotation(
+ runCatching { view.display?.rotation }.getOrNull() ?: Surface.ROTATION_0
+ )
+ val (liveWidthDp, liveHeightDp) = with(LocalDensity.current) {
+ val containerSize = LocalWindowInfo.current.containerSize
+ 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 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 -> 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.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),
+ value = stringResource(
+ R.string.debug_text_window_size,
+ resolvedState.widthDp,
+ resolvedState.heightDp
+ ),
+ 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(
+ 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
+ )
+ 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),
+ 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)
+ }
+ }
+}
+
@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
@@ -126,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(
@@ -149,13 +659,21 @@ 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.
+ */
@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)) {
@@ -167,7 +685,8 @@ fun DebugOverlay(
DebugConsole(
debugUiState = debugUiState,
onToggleDebugOverlay = debugController::toggleDebugOverlay,
- extraControls = extraControls
+ extraControls = extraControls,
+ layoutReadoutState = layoutReadoutState
)
}
}
@@ -185,29 +704,19 @@ 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.
- */
@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(
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = Alignment.CenterVertically
) {
- // debug menu button
DebugDialogContainerToggle(toggleIsOpen = onToggleDebugOverlay)
extraControls.forEach { it() }
}
@@ -217,6 +726,7 @@ private fun DebugConsole(
modifier = Modifier,
primaryZoomRatio = debugUiState.currentPrimaryZoomRatio
)
+ DebugLayoutReadout(state = layoutReadoutState)
}
}
@@ -469,3 +979,45 @@ private enum class SelectedDialog {
SetZoom,
SetTestPattern
}
+
+@Preview(name = "Compact Portrait", showBackground = true)
+@Composable
+private fun DebugLayoutReadoutCompactPreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(
+ widthDp = 412,
+ heightDp = 915,
+ cameraLayoutMode = "COMPACT"
+ )
+ )
+ }
+}
+
+@Preview(name = "Foldable", showBackground = true)
+@Composable
+private fun DebugLayoutReadoutFoldablePreview() {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(
+ 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 a4edfcc2dd..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
@@ -21,6 +21,17 @@ 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 = "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"
+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 c7084c4b4b..109a674610 100644
--- a/ui/debug/src/main/res/values/strings.xml
+++ b/ui/debug/src/main/res/values/strings.xml
@@ -23,4 +23,33 @@
Hiding components
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°
+ PORTRAIT
+ HORIZONTAL
+ COMPACT
+ COMPACT_SLIM
+ COMPACT_SHORT
+ COMPACT_SHORT_SLIM
+ 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
new file mode 100644
index 0000000000..c07c41dd74
--- /dev/null
+++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt
@@ -0,0 +1,395 @@
+/*
+ * 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.DeviceRotation
+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], 7-mode [DebugFormFactor],
+ * [DebugWindowOrientation], active layout composable reporting, [DebugTextBar] modifier scoping,
+ * and [DebugOverlay] integration.
+ */
+@RunWith(RobolectricTestRunner::class)
+class DebugLayoutReadoutTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun debugLayoutReadout_whenExplicitStateProvided_displaysAllTaggedRowsAndActiveLayout() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(
+ widthDp = 673,
+ heightDp = 841,
+ formFactor = DebugFormFactor.FOLDABLE,
+ cameraLayoutMode = "FOLDABLE",
+ activeLayoutName = "FoldableLayout",
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Natural
+ )
+ )
+ }
+ }
+
+ 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_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°")
+ assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("90°")
+ assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("90°")
+ }
+
+ @Test
+ fun debugLayoutReadout_whenDefaultsUsed_derivesOrientationFormFactorAndActiveLayout() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(
+ widthDp = 412,
+ heightDp = 915
+ )
+ )
+ }
+ }
+
+ assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("412x915dp")
+ 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°")
+ assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("0°")
+ }
+
+ @Test
+ fun debugLayoutReadout_appliesModifierOnlyToOuterContainer_notChildTextNodes() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(widthDp = 412, heightDp = 915),
+ modifier = Modifier.testTag("custom_row_tag")
+ )
+ }
+ }
+
+ assertThat(
+ composeTestRule.onAllNodesWithTag("custom_row_tag").fetchSemanticsNodes().size
+ ).isEqualTo(1)
+ assertThat(
+ composeTestRule
+ .onAllNodesWithTag("custom_row_tag", useUnmergedTree = true)
+ .fetchSemanticsNodes()
+ .size
+ ).isEqualTo(1)
+ }
+
+ @Test
+ @Config(qualifiers = "w360dp-h800dp")
+ fun debugOverlay_whenLayoutReadoutStateIsNull_derivesLiveWindowDimensionsAndFormFactor() {
+ 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_WINDOW_SIZE_TAG)).isEqualTo("360x800dp")
+ 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°")
+ }
+
+ @Test
+ fun debugOverlay_whenExplicitTabletPortraitStateProvided_displaysTabletPortraitReadout() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ DebugOverlay(
+ onChangeZoomRatio = {},
+ debugUiState = DebugUiState.Enabled.Closed(
+ currentPhysicalCameraId = "0",
+ currentLogicalCameraId = "0",
+ currentPrimaryZoomRatio = 1f,
+ debugHidingComponents = false
+ ),
+ debugController = FakeDebugController(),
+ layoutReadoutState = DebugLayoutReadoutState(
+ widthDp = 800,
+ heightDp = 1280,
+ deviceRotation = DeviceRotation.Rotated90,
+ windowRotation = DeviceRotation.Natural
+ )
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag(DEBUG_LAYOUT_READOUT_TAG).assertIsDisplayed()
+ assertThat(readNodeText(DEBUG_WINDOW_SIZE_TAG)).isEqualTo("800x1280dp")
+ 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°")
+ assertThat(readNodeText(DEBUG_ICON_ROTATION_TAG)).isEqualTo("270°")
+ assertThat(readNodeStateDescription(DEBUG_ICON_ROTATION_INDICATOR_TAG)).isEqualTo("270°")
+ }
+
+ @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()
+ composeTestRule.onNodeWithTag(DEBUG_WINDOW_SIZE_TAG).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(DEBUG_WINDOW_ORIENTATION_TAG).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(DEBUG_FORM_FACTOR_TAG).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(DEBUG_ACTIVE_LAYOUT_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun debugLayoutReadoutState_expectedLayoutRotationDegrees_handlesAllSevenModesAnd550x500() {
+ // Portrait compact phone (heightDp >= widthDp) -> 0°
+ assertThat(
+ DebugLayoutReadoutState(
+ widthDp = 412,
+ heightDp = 915,
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Natural
+ ).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+
+ // 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(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(
+ DebugLayoutReadoutState(
+ widthDp = 915,
+ heightDp = 412,
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Rotated270
+ ).expectedLayoutRotationDegrees
+ ).isEqualTo(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 CompactShortLayout
+ assertThat(
+ DebugLayoutReadoutState(widthDp = 412, heightDp = 412).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+ assertThat(
+ DebugLayoutReadoutState(widthDp = 433, heightDp = 346).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+
+ // Foldable, TabletPortrait, and TabletLandscape always remain upright (0°)
+ assertThat(
+ DebugLayoutReadoutState(
+ widthDp = 841,
+ heightDp = 673,
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Rotated270
+ ).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+ assertThat(
+ DebugLayoutReadoutState(
+ widthDp = 800,
+ heightDp = 1280,
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Natural
+ ).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+ assertThat(
+ DebugLayoutReadoutState(
+ widthDp = 1280,
+ heightDp = 800,
+ deviceRotation = DeviceRotation.Rotated270,
+ windowRotation = DeviceRotation.Natural
+ ).expectedLayoutRotationDegrees
+ ).isEqualTo(0)
+ }
+
+ @Test
+ 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)
+
+ // 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)
+
+ // 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.TABLET_PORTRAIT)
+ assertThat(DebugFormFactor.fromWindowDp(1280, 800))
+ .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
+ assertThat(DebugFormFactor.fromWindowDp(0, 800)).isEqualTo(DebugFormFactor.UNKNOWN)
+ assertThat(DebugFormFactor.fromWindowDp(412, -1)).isEqualTo(DebugFormFactor.UNKNOWN)
+ }
+
+ @Test
+ fun debugLayoutReadout_whenCameraLayoutModeProvided_displaysLayoutModeRow() {
+ composeTestRule.setContent {
+ DebugLayoutReadout(
+ state = DebugLayoutReadoutState(
+ widthDp = 800,
+ heightDp = 400,
+ cameraLayoutMode = "COMPACT"
+ )
+ )
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(readNodeText(DEBUG_LAYOUT_MODE_TAG)).isEqualTo("COMPACT")
+ }
+
+ private fun readNodeText(tag: String): String = composeTestRule
+ .onNodeWithTag(tag)
+ .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 {
+ override fun toggleDebugHidingComponents() = Unit
+ override fun toggleDebugOverlay() = Unit
+ override fun setTestPattern(testPattern: TestPattern) = Unit
+}