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 +}