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..46761e8e1d 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -22,12 +22,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tracing.Trace +import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.ImageCaptureEvent @@ -40,11 +45,15 @@ import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldDefaults import com.google.jetpackcamera.ui.components.capture.CaptureScaffoldLayout import com.google.jetpackcamera.ui.components.capture.LOADING_CAPTURE_UI_STATE_SKELETON import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton +import com.google.jetpackcamera.ui.components.capture.rememberWindowRotation +import com.google.jetpackcamera.ui.debug.DebugLayoutReadoutState import com.google.jetpackcamera.ui.debug.DebugOverlay import com.google.jetpackcamera.ui.debug.DebugUiState +import com.google.jetpackcamera.ui.debug.rememberDebugFormFactor import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState +import kotlin.math.roundToInt import kotlinx.coroutines.flow.transformWhile private const val TAG = "PreviewScreen" @@ -76,9 +85,26 @@ fun PreviewScreen( val surfaceRequest: SurfaceRequest? by viewModel.surfaceRequest.collectAsState() + val readyStateProvider: () -> CaptureUiState.Ready = remember { + { + (rawUiState.value as? CaptureUiState.Ready) ?: LOADING_CAPTURE_UI_STATE_SKELETON + } + } + val videoRecordingState = remember { + derivedStateOf { readyStateProvider().videoRecordingState } + } + val isRecordingInProgress = remember { + derivedStateOf { videoRecordingState.value !is VideoRecordingState.Inactive } + } + + val windowRotation = rememberWindowRotation() + var deviceRotation by remember { mutableStateOf(windowRotation) } + CameraSessionEffect( cameraController = viewModel.cameraController, enabled = true, + isRecording = isRecordingInProgress.value, + onDeviceRotationChanged = { deviceRotation = it }, onSessionStarted = viewModel::startLocationUpdates, onSessionStopped = viewModel::stopLocationUpdates ) @@ -113,12 +139,6 @@ fun PreviewScreen( } } - val readyStateProvider: () -> CaptureUiState.Ready = remember { - { - (rawUiState.value as? CaptureUiState.Ready) ?: LOADING_CAPTURE_UI_STATE_SKELETON - } - } - val debugController = viewModel.debugController val currentDebugUiState = debugUiState val isDebugHidingComponents = @@ -133,14 +153,13 @@ fun PreviewScreen( val audioState = remember { derivedStateOf { readyStateProvider().audioUiState } } - val videoRecordingState = remember { - derivedStateOf { readyStateProvider().videoRecordingState } - } CaptureScaffold( captureUiState = readyStateProvider, surfaceRequest = surfaceRequest, modifier = modifier, + deviceRotation = deviceRotation, + windowRotation = windowRotation, onNavigateToSettings = onNavigateToSettings, onRequestWindowColorMode = onRequestWindowColorMode, onRequiresImmersiveChange = onRequiresImmersiveChange, @@ -172,6 +191,20 @@ fun PreviewScreen( }, overlay = { slotModifier -> if (currentDebugUiState is DebugUiState.Enabled) { + val (widthDp, heightDp) = with(LocalDensity.current) { + val containerSize = LocalWindowInfo.current.containerSize + containerSize.width.toDp().value.roundToInt() to + containerSize.height.toDp().value.roundToInt() + } + val formFactor = rememberDebugFormFactor(widthDp = widthDp, heightDp = heightDp) + val layoutReadoutState = DebugLayoutReadoutState( + widthDp = widthDp, + heightDp = heightDp, + formFactor = formFactor, + activeLayoutName = "CaptureScaffoldLayout", + deviceRotation = deviceRotation, + windowRotation = windowRotation + ) DebugOverlay( modifier = slotModifier, onChangeZoomRatio = { ratio -> @@ -195,7 +228,8 @@ fun PreviewScreen( currentRecordingStateProvider = { videoRecordingState.value } ) }, - debugController = debugController + debugController = debugController, + layoutReadoutState = layoutReadoutState ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt index ac1a404be7..e2c5a63f40 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraSessionEffect.kt @@ -15,19 +15,30 @@ */ package com.google.jetpackcamera.ui.components.capture +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.controller.CameraController /** - * Composable effect that manages the camera session lifecycle and display rotation updates. + * Composable effect that manages the camera session lifecycle, display rotation updates, and + * recording orientation lock. * * While [enabled] is `true` and the current [LocalLifecycleOwner] is at least * [Lifecycle.State.STARTED], this effect: @@ -35,7 +46,11 @@ import com.google.jetpackcamera.ui.controller.CameraController * the camera via [CameraController.stopCamera] and invoking [onSessionStopped] when the * lifecycle stops, [enabled] becomes `false`, or the effect leaves the composition. * 2. Collects [debouncedOrientationFlow] and forwards device rotation changes to - * [CameraController.setDisplayRotation]. + * [CameraController.setDisplayRotation] and [onDeviceRotationChanged] while [isRecording] is + * `false`. + * 3. While [isRecording] is `true`, freezes display rotation updates and locks the host + * [Activity]'s `requestedOrientation` to [ActivityInfo.SCREEN_ORIENTATION_LOCKED], restoring its + * previous `requestedOrientation` and flushing any pending sensor rotation once recording ends. * * [enabled] has no default value so that host screens make an explicit readiness or runtime * permission decision before starting the camera session. @@ -43,6 +58,9 @@ import com.google.jetpackcamera.ui.controller.CameraController * @param cameraController the [CameraController] to start, stop, and update with display rotation. * @param enabled whether the camera session is allowed to run (for example, `true` once required * camera permissions have been granted). + * @param isRecording whether an active or starting video recording session is in progress. + * @param onDeviceRotationChanged optional callback notified with the active (unfrozen) physical + * [DeviceRotation]. * @param onSessionStarted optional callback invoked right after [CameraController.startCamera]. * @param onSessionStopped optional callback invoked right before [CameraController.stopCamera]. */ @@ -50,6 +68,8 @@ import com.google.jetpackcamera.ui.controller.CameraController fun CameraSessionEffect( cameraController: CameraController, enabled: Boolean, + isRecording: Boolean = false, + onDeviceRotationChanged: (DeviceRotation) -> Unit = {}, onSessionStarted: () -> Unit = {}, onSessionStopped: () -> Unit = {} ) { @@ -72,11 +92,54 @@ fun CameraSessionEffect( if (enabled) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentIsRecording by rememberUpdatedState(isRecording) + val currentOnDeviceRotationChanged by rememberUpdatedState(onDeviceRotationChanged) + var pendingRotation by remember(cameraController) { mutableStateOf(null) } + var lastDeliveredRotation by remember(cameraController) { + mutableStateOf(null) + } + LaunchedEffect(cameraController, context, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - debouncedOrientationFlow(context) - .collect(cameraController::setDisplayRotation) + debouncedOrientationFlow(context).collect { rotation -> + pendingRotation = rotation + if (!currentIsRecording) { + lastDeliveredRotation = rotation + cameraController.setDisplayRotation(rotation) + currentOnDeviceRotationChanged(rotation) + } + } + } + } + + LaunchedEffect(isRecording, cameraController) { + if (!isRecording) { + val rotation = pendingRotation + if (rotation != null && rotation != lastDeliveredRotation) { + lastDeliveredRotation = rotation + cameraController.setDisplayRotation(rotation) + currentOnDeviceRotationChanged(rotation) + } + } + } + + val activity = LocalActivity.current ?: remember(context) { context.findSessionActivity() } + DisposableEffect(activity, isRecording) { + if (activity != null && isRecording) { + val previousOrientation = activity.requestedOrientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + onDispose { + activity.requestedOrientation = previousOrientation + } + } else { + onDispose {} } } } } + +private tailrec fun Context.findSessionActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findSessionActivity() + else -> null +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index 787be102e2..7556a590b4 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.min import androidx.compose.ui.unit.takeOrElse +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutDefaults import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds @@ -231,7 +232,9 @@ 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() ) { val bottomSheetState = rememberStandardBottomSheetState( initialValue = if (state.isQuickSettingsOpen) SheetValue.Expanded else SheetValue.Hidden, @@ -262,6 +265,10 @@ fun CaptureScaffoldLayout( } val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } + val gravityRotationState = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = windowRotation + ) val dismissQuickSettingsLabel = stringResource(R.string.quick_settings_btn_close_expanded_settings_description) val handleDismissQuickSettings = { @@ -269,7 +276,12 @@ fun CaptureScaffoldLayout( onDismissQuickSettings() } - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalDeviceRotation provides deviceRotation, + LocalWindowRotation provides windowRotation, + LocalGravityRotationState provides gravityRotationState + ) { Box(modifier = modifier.fillMaxSize()) { BottomSheetScaffold( modifier = Modifier.fillMaxSize(), diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt index 3b3b2489b1..97d115a785 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt @@ -57,6 +57,7 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CameraError import com.google.jetpackcamera.model.CameraZoomRatio import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.LensToZoom @@ -768,6 +769,8 @@ private fun rememberZoomStateManager( * viewfinder. * @param topBarTrailingContent optional composable slot aligned to the end of the top bar. * @param overlay optional overlay composable slot drawn above the viewfinder and controls. + * @param deviceRotation physical gravity orientation of the device. + * @param windowRotation display/window rotation reported by Android. */ @Composable fun CaptureScaffold( @@ -775,6 +778,8 @@ fun CaptureScaffold( surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, state: CaptureScaffoldState = rememberCaptureScaffoldState(), + deviceRotation: DeviceRotation = rememberDeviceRotation(), + windowRotation: DeviceRotation = rememberWindowRotation(), onNavigateToSettings: () -> Unit = {}, onRequestWindowColorMode: (Int) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, @@ -1315,7 +1320,9 @@ fun CaptureScaffold( { cameraController?.dismissCameraError() } ) } - } + }, + deviceRotation = deviceRotation, + windowRotation = windowRotation ) } diff --git a/ui/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..d289f6628c --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.Surface +import android.view.View +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.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 +private val DefaultGravityRotationState: State = mutableFloatStateOf(0f) + +/** + * Provides the current physical gravity [DeviceRotation] to descendant composables in the capture + * screen. + */ +val LocalDeviceRotation: ProvidableCompositionLocal = + compositionLocalOf { DeviceRotation.Natural } + +/** + * Provides the current OS window/display [DeviceRotation] to descendant composables in the capture + * screen. + */ +val LocalWindowRotation: ProvidableCompositionLocal = + compositionLocalOf { DeviceRotation.Natural } + +/** + * Provides a shared, root-animated [State] holding the continuous clockwise gravity compensation + * angle in degrees. Reading [State.value] inside [Modifier.graphicsLayer] updates only the draw/ + * RenderNode phase with zero recompositions or layout passes. + */ +val LocalGravityRotationState: ProvidableCompositionLocal> = + compositionLocalOf { DefaultGravityRotationState } + +/** + * Tracks the current OS display rotation synchronously on configuration changes and via + * [DisplayManager.DisplayListener] while `STARTED` (catching seamless 180-degree + * landscape-to-landscape flips that do not trigger configuration changes). + */ +@Composable +fun rememberWindowRotation(): DeviceRotation { + val view = LocalView.current + val context = LocalContext.current + val configuration = LocalConfiguration.current + var windowRotation by remember { + mutableStateOf(readCurrentDisplayRotation(view, context)) + } + + LaunchedEffect(configuration, view, context) { + windowRotation = readCurrentDisplayRotation(view, context) + } + + LifecycleStartEffect(view, context) { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + val listener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + override fun onDisplayRemoved(displayId: Int) = Unit + override fun onDisplayChanged(displayId: Int) { + val targetDisplayId = view.display?.displayId ?: Display.DEFAULT_DISPLAY + if (displayId == targetDisplayId) { + val updated = readCurrentDisplayRotation(view, context) + if (updated != windowRotation) { + windowRotation = updated + } + } + } + } + windowRotation = readCurrentDisplayRotation(view, context) + displayManager?.registerDisplayListener(listener, null) + onStopOrDispose { + displayManager?.unregisterDisplayListener(listener) + } + } + + return windowRotation +} + +private fun readCurrentDisplayRotation(view: View, context: Context): DeviceRotation { + val surfaceRotation = runCatching { + (view.display ?: ContextCompat.getDisplayOrDefault(context)).rotation + }.getOrDefault(Surface.ROTATION_0) + return DeviceRotation.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() + } + } + } 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/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt new file mode 100644 index 0000000000..14dfc69034 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.DeviceRotation +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RotationModifiersTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rememberGravityRotationState_usesShortestPathAcrossZeroAnd270Boundary() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + var currentDegrees = 0f + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + val state = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + currentDegrees = state.value + } + } + + assertThat(currentDegrees).isEqualTo(0f) + + // Tilting 90° CW (Dev 90°, Win 0°) -> target 270°, shortest path from 0° is -90° + deviceRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(-90f) + + // Returning to Natural (Dev 0°, Win 0°) -> shortest path from -90° back to 0° is 0° + deviceRotation = DeviceRotation.Natural + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(0f) + + // Tilting 90° CCW (Dev 270°, Win 0°) -> target 90°, shortest path from 0° is +90° + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(90f) + } + + @Test + fun 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)) + } +} 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..116e0ebec6 --- /dev/null +++ b/ui/debug/src/test/java/com/google/jetpackcamera/ui/debug/DebugLayoutReadoutTest.kt @@ -0,0 +1,379 @@ +/* + * 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) + } + + 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 +}