diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d50ebb44..2400b7b56 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 d230426a6..0d903ad14 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 000000000..022ac0250 --- /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/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 0e68aa73a..438c61081 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/CaptureScaffold.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt index 577837dd2..3b3b2489b 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 @@ -1048,7 +1048,8 @@ fun CaptureScaffold( onScaleZoom = { zoomRatio -> onScaleZoomLambda(zoomRatio) }, surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = focusMeteringState.value + focusMeteringUiState = focusMeteringState.value, + useLandscapeAspectRatio = true ) } } 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 613649ac5..4d8c6b40d 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,32 +101,48 @@ 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 @@ -165,7 +179,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 +831,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 +878,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 +889,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 +902,302 @@ 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 + 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 solvedBand = if (maxHeight > maxWidth) { + layoutSolution?.viewfinderBandFor(aspectRatio.toFloat()) + } else { + null + } + val topOffsetPx = (solvedBand?.top ?: 0.dp).roundToPx() + .coerceIn(0, constraints.maxHeight) + val size = if (solvedBand != null) { + DpSize(maxWidth, solvedBand.height) + } else { + 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 - 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) + } } - } - .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/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index d69bbf553..c08726e0d 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/PreviewDisplayTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt new file mode 100644 index 000000000..acd1e1c72 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt @@ -0,0 +1,756 @@ +/* + * 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.graphics.Matrix +import android.graphics.Rect as AndroidRect +import android.util.Size +import android.view.KeyEvent as AndroidKeyEvent +import android.view.Surface +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.impl.CameraInternal +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.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 + } + } + + private fun createUiState( + aspectRatio: AspectRatio, + lastBlinkTimeStamp: Long = 0L + ): PreviewDisplayUiState = PreviewDisplayUiState( + lastBlinkTimeStamp = lastBlinkTimeStamp, + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = aspectRatio, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE) + ) + ) + ) + + @SuppressLint("RestrictedApi") + private fun createFakeSurfaceRequest(width: Int, height: Int): SurfaceRequest { + 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) + } + } +}