diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts index b1f17cc5..c953bdd6 100644 --- a/feature/camera/build.gradle.kts +++ b/feature/camera/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.androidx.concurrent.futures.ktx) implementation(libs.androidx.window) implementation(libs.androidx.window.core) + implementation(libs.androidx.xr.compose) implementation(libs.accompanist.permissions) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.play.services) @@ -82,6 +83,7 @@ dependencies { implementation(projects.core.theme) implementation(projects.core.util) implementation(projects.data) + implementation(projects.core.xr) // Android Instrumented Tests androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraLayout.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraLayout.kt index a19395af..519228b7 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraLayout.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraLayout.kt @@ -45,10 +45,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect +import com.android.developers.androidify.camera.xr.CameraLayoutSpatial import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.TertiaryContainer import com.android.developers.androidify.util.FoldablePreviewParameters @@ -57,6 +59,7 @@ import com.android.developers.androidify.util.allowsFullContent import com.android.developers.androidify.util.isAtLeastMedium import com.android.developers.androidify.util.shouldShowTabletopLayout import com.android.developers.androidify.util.supportsTabletop +import com.android.developers.androidify.xr.LocalSpatialCapabilities @Composable internal fun CameraLayout( @@ -68,12 +71,16 @@ internal fun CameraLayout( guideText: @Composable (modifier: Modifier) -> Unit, guide: @Composable (modifier: Modifier) -> Unit, rearCameraButton: @Composable (modifier: Modifier) -> Unit, + surfaceAspectRatio: Float, + xrEnabled: Boolean = false, supportsTabletop: Boolean = supportsTabletop(), isTabletop: Boolean = false, ) { val mContext = LocalContext.current + val inspection = LocalInspectionMode.current var isCameraLeft by remember { mutableStateOf(false) } LifecycleStartEffect(Unit) { + if (inspection) return@LifecycleStartEffect onStopOrDispose { } val displayManager = mContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayChanged(displayId: Int) { @@ -94,6 +101,16 @@ internal fun CameraLayout( .background(TertiaryContainer), ) { when { + xrEnabled && LocalSpatialCapabilities.current.isSpatialUiEnabled -> CameraLayoutSpatial( + viewfinder, + captureButton, + flipCameraButton, + zoomButton, + guideText, + guide, + surfaceAspectRatio, + ) + isAtLeastMedium() && shouldShowTabletopLayout( supportsTabletop = supportsTabletop, isTabletop = isTabletop, @@ -565,6 +582,7 @@ private fun CameraOverlayPreview( }, supportsTabletop = parameters.supportsTabletop, isTabletop = parameters.isTabletop, + surfaceAspectRatio = 16f / 9f, ) } } diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt index 80b649fd..2c78626b 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt @@ -147,6 +147,8 @@ fun CameraPreviewScreen( toggleRearCameraFeature = { viewModel.toggleRearDisplayFeature(activity) }, isRearCameraEnabled = uiState.isRearCameraActive, cameraSessionId = uiState.cameraSessionId, + xrEnabled = uiState.xrEnabled, + surfaceAspectRatio = uiState.surfaceAspectRatio, ) } } else { @@ -203,12 +205,14 @@ fun StatelessCameraPreviewContent( onAnimateZoom: (Float) -> Unit, requestCaptureImage: () -> Unit, modifier: Modifier = Modifier, + surfaceAspectRatio: Float = 9f / 16f, + xrEnabled: Boolean = false, foldingFeature: FoldingFeature? = null, shouldShowRearCameraFeature: () -> Boolean = { false }, toggleRearCameraFeature: () -> Unit = {}, isRearCameraEnabled: Boolean = false, ) { - var aspectRatio by remember { mutableFloatStateOf(9f / 16f) } + var layoutAspectRatio by remember { mutableFloatStateOf(9f / 16f) } val emptyComposable: @Composable (Modifier) -> Unit = {} val rearCameraButton: @Composable (Modifier) -> Unit = { rearModifier -> RearCameraButton( @@ -259,7 +263,7 @@ fun StatelessCameraPreviewContent( CameraGuide( detectedPose = detectedPose, modifier = guideModifier, - defaultAspectRatio = aspectRatio, + defaultAspectRatio = layoutAspectRatio, ) }, rearCameraButton = ( @@ -273,9 +277,12 @@ fun StatelessCameraPreviewContent( modifier = modifier.onSizeChanged { size -> if (size.height > 0) { // Recalculate aspect ratio based on the overall layout size - aspectRatio = calculateCorrectAspectRatio(size.height, size.width, aspectRatio) + layoutAspectRatio = + calculateCorrectAspectRatio(size.height, size.width, layoutAspectRatio) } }, + surfaceAspectRatio = surfaceAspectRatio, + xrEnabled = xrEnabled, ) } @@ -298,11 +305,13 @@ private fun CameraPreviewContent( zoomLevel: () -> Float, onChangeZoomLevel: (zoomLevel: Float) -> Unit, requestCaptureImage: () -> Unit, + surfaceAspectRatio: Float, modifier: Modifier = Modifier, foldingFeature: FoldingFeature? = null, shouldShowRearCameraFeature: () -> Boolean = { false }, toggleRearCameraFeature: () -> Unit = {}, isRearCameraEnabled: Boolean = false, + xrEnabled: Boolean = false, ) { val scope = rememberCoroutineScope() val zoomState = remember(cameraSessionId) { @@ -324,7 +333,8 @@ private fun CameraPreviewContent( onScaleZoom = { scope.launch { zoomState.scaleZoom(it) } }, modifier = viewfinderModifier.onSizeChanged { size -> // Apply modifier from slot if (size.height > 0) { - aspectRatio = calculateCorrectAspectRatio(size.height, size.width, aspectRatio) + aspectRatio = + calculateCorrectAspectRatio(size.height, size.width, aspectRatio) } }, ) @@ -341,6 +351,8 @@ private fun CameraPreviewContent( shouldShowRearCameraFeature = shouldShowRearCameraFeature, toggleRearCameraFeature = toggleRearCameraFeature, isRearCameraEnabled = isRearCameraEnabled, + surfaceAspectRatio = surfaceAspectRatio, + xrEnabled = xrEnabled, modifier = modifier, ) } diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraViewModel.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraViewModel.kt index 5f672505..48abeb5b 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraViewModel.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraViewModel.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker +import com.android.developers.androidify.data.ConfigProvider import com.android.developers.androidify.util.LocalFileProvider import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.pose.PoseDetection @@ -76,8 +77,9 @@ class CameraViewModel application: Application, val localFileProvider: LocalFileProvider, val rearCameraUseCase: RearCameraUseCase, + configProvider: ConfigProvider, ) : AndroidViewModel(application) { - private var _uiState = MutableStateFlow(CameraUiState()) + private var _uiState = MutableStateFlow(CameraUiState(xrEnabled = configProvider.isXrEnabled())) val uiState: StateFlow get() = _uiState @@ -90,10 +92,12 @@ class CameraViewModel private val cameraPreviewUseCase = Preview.Builder().build().apply { setSurfaceProvider { newSurfaceRequest -> - _uiState.update { it.copy(surfaceRequest = newSurfaceRequest) } + val width = newSurfaceRequest.resolution.width.toFloat() + val height = newSurfaceRequest.resolution.height.toFloat() + _uiState.update { it.copy(surfaceRequest = newSurfaceRequest, surfaceAspectRatio = height / width) } surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory( - newSurfaceRequest.resolution.width.toFloat(), - newSurfaceRequest.resolution.height.toFloat(), + width, + height, ) } } @@ -351,6 +355,8 @@ data class CameraUiState( val canFlipCamera: Boolean = true, val isRearCameraActive: Boolean = false, val autofocusUiState: AutofocusUiState = AutofocusUiState.Unspecified, + val xrEnabled: Boolean = false, + val surfaceAspectRatio: Float = 9f / 16f, ) { val zoomOptions = when { zoomMinRatio <= 0.6f && zoomMaxRatio >= 1f -> listOf(0.6f, 1f) diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/xr/CameraLayoutSpatial.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/xr/CameraLayoutSpatial.kt new file mode 100644 index 00000000..81bb8bea --- /dev/null +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/xr/CameraLayoutSpatial.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 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 + * + * https://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.android.developers.androidify.camera.xr + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.xr.compose.spatial.ContentEdge +import androidx.xr.compose.spatial.Orbiter +import androidx.xr.compose.spatial.OrbiterOffsetType +import androidx.xr.compose.spatial.Subspace +import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.aspectRatio +import androidx.xr.compose.subspace.layout.fillMaxSize +import com.android.developers.androidify.xr.MainPanelWorkaround +import com.android.developers.androidify.xr.RequestHomeSpaceIconButton + +@Composable +fun CameraLayoutSpatial( + viewfinder: @Composable (modifier: Modifier) -> Unit, + captureButton: @Composable (modifier: Modifier) -> Unit, + flipCameraButton: @Composable (modifier: Modifier) -> Unit, + zoomButton: @Composable (modifier: Modifier) -> Unit, + guideText: @Composable (modifier: Modifier) -> Unit, + guide: @Composable (modifier: Modifier) -> Unit, + surfaceAspectRatio: Float, +) { + Subspace { + MainPanelWorkaround() + SpatialPanel( + SubspaceModifier + .fillMaxSize(0.5f) + .aspectRatio(surfaceAspectRatio), + ) { + Orbiter( + position = ContentEdge.Top, + offsetType = OrbiterOffsetType.InnerEdge, + offset = 32.dp, + alignment = Alignment.End, + ) { + RequestHomeSpaceIconButton( + modifier = Modifier + .size(64.dp, 64.dp) + .padding(8.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) + } + Orbiter(ContentEdge.Start, offsetType = OrbiterOffsetType.InnerEdge, offset = 16.dp) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + captureButton(Modifier) + flipCameraButton(Modifier) + } + } + Orbiter(ContentEdge.Bottom, offsetType = OrbiterOffsetType.InnerEdge) { + zoomButton(Modifier) + } + Box(Modifier.fillMaxSize()) { + viewfinder(Modifier) + guide(Modifier.fillMaxSize()) + guideText( + Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 36.dp, vertical = 64.dp), + ) + } + } + } +}