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