From 0e742fb08c017766a284d726d63714c016fe09c6 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 17:49:50 +0200 Subject: [PATCH 1/5] Add XR libraries to Camera feature module --- feature/camera/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) 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)) From 5a9320ecc6cd9ee136ed02fc4150645138d8c2cb Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 17:52:40 +0200 Subject: [PATCH 2/5] Add XR feature enablement flag to CameraViewModel --- .../com/android/developers/androidify/camera/CameraLayout.kt | 1 + .../com/android/developers/androidify/camera/CameraScreen.kt | 5 +++++ .../android/developers/androidify/camera/CameraViewModel.kt | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) 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..dac646d2 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 @@ -68,6 +68,7 @@ internal fun CameraLayout( guideText: @Composable (modifier: Modifier) -> Unit, guide: @Composable (modifier: Modifier) -> Unit, rearCameraButton: @Composable (modifier: Modifier) -> Unit, + xrEnabled: Boolean = false, supportsTabletop: Boolean = supportsTabletop(), isTabletop: Boolean = false, ) { 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..a417ce5b 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,7 @@ fun CameraPreviewScreen( toggleRearCameraFeature = { viewModel.toggleRearDisplayFeature(activity) }, isRearCameraEnabled = uiState.isRearCameraActive, cameraSessionId = uiState.cameraSessionId, + xrEnabled = uiState.xrEnabled, ) } } else { @@ -203,6 +204,7 @@ fun StatelessCameraPreviewContent( onAnimateZoom: (Float) -> Unit, requestCaptureImage: () -> Unit, modifier: Modifier = Modifier, + xrEnabled: Boolean = false, foldingFeature: FoldingFeature? = null, shouldShowRearCameraFeature: () -> Boolean = { false }, toggleRearCameraFeature: () -> Unit = {}, @@ -276,6 +278,7 @@ fun StatelessCameraPreviewContent( aspectRatio = calculateCorrectAspectRatio(size.height, size.width, aspectRatio) } }, + xrEnabled = xrEnabled, ) } @@ -303,6 +306,7 @@ private fun CameraPreviewContent( shouldShowRearCameraFeature: () -> Boolean = { false }, toggleRearCameraFeature: () -> Unit = {}, isRearCameraEnabled: Boolean = false, + xrEnabled: Boolean = false, ) { val scope = rememberCoroutineScope() val zoomState = remember(cameraSessionId) { @@ -341,6 +345,7 @@ private fun CameraPreviewContent( shouldShowRearCameraFeature = shouldShowRearCameraFeature, toggleRearCameraFeature = toggleRearCameraFeature, isRearCameraEnabled = isRearCameraEnabled, + 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..4b58e632 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 @@ -351,6 +353,7 @@ data class CameraUiState( val canFlipCamera: Boolean = true, val isRearCameraActive: Boolean = false, val autofocusUiState: AutofocusUiState = AutofocusUiState.Unspecified, + val xrEnabled: Boolean = false, ) { val zoomOptions = when { zoomMinRatio <= 0.6f && zoomMaxRatio >= 1f -> listOf(0.6f, 1f) From cf4a1d4a38b0f679101ee018cc024db6b8559f53 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 17:54:00 +0200 Subject: [PATCH 3/5] Fix Preview error due to trying to obtain a system service --- .../com/android/developers/androidify/camera/CameraLayout.kt | 3 +++ 1 file changed, 3 insertions(+) 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 dac646d2..1953c136 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,6 +45,7 @@ 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 @@ -73,8 +74,10 @@ internal fun CameraLayout( 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) { From 1e16d5e0016888c4c604bab4e6e968806855f366 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 18:08:09 +0200 Subject: [PATCH 4/5] Add Spatial layout for CameraScreen --- .../androidify/camera/CameraLayout.kt | 11 +++ .../camera/xr/CameraLayoutSpatial.kt | 91 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 feature/camera/src/main/java/com/android/developers/androidify/camera/xr/CameraLayoutSpatial.kt 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 1953c136..2824ab7f 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 @@ -50,6 +50,7 @@ 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 @@ -58,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( @@ -98,6 +100,15 @@ internal fun CameraLayout( .background(TertiaryContainer), ) { when { + xrEnabled && LocalSpatialCapabilities.current.isSpatialUiEnabled -> CameraLayoutSpatial( + viewfinder, + captureButton, + flipCameraButton, + zoomButton, + guideText, + guide, + ) + isAtLeastMedium() && shouldShowTabletopLayout( supportsTabletop = supportsTabletop, isTabletop = isTabletop, 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..177be847 --- /dev/null +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/xr/CameraLayoutSpatial.kt @@ -0,0 +1,91 @@ +/* + * 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), + ) { + 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), + ) + } + } + } +} From 6867ba7f78859b95bc6be3c977428a1043f0eee1 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 18:09:23 +0200 Subject: [PATCH 5/5] Add camera preview aspect ratio hint. This allows the Spatial layout to maintain an arbitrary aspect ratio without any cropping. --- .../developers/androidify/camera/CameraLayout.kt | 3 +++ .../developers/androidify/camera/CameraScreen.kt | 15 +++++++++++---- .../androidify/camera/CameraViewModel.kt | 9 ++++++--- .../androidify/camera/xr/CameraLayoutSpatial.kt | 3 ++- 4 files changed, 22 insertions(+), 8 deletions(-) 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 2824ab7f..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 @@ -71,6 +71,7 @@ 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, @@ -107,6 +108,7 @@ internal fun CameraLayout( zoomButton, guideText, guide, + surfaceAspectRatio, ) isAtLeastMedium() && shouldShowTabletopLayout( @@ -580,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 a417ce5b..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 @@ -148,6 +148,7 @@ fun CameraPreviewScreen( isRearCameraEnabled = uiState.isRearCameraActive, cameraSessionId = uiState.cameraSessionId, xrEnabled = uiState.xrEnabled, + surfaceAspectRatio = uiState.surfaceAspectRatio, ) } } else { @@ -204,13 +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( @@ -261,7 +263,7 @@ fun StatelessCameraPreviewContent( CameraGuide( detectedPose = detectedPose, modifier = guideModifier, - defaultAspectRatio = aspectRatio, + defaultAspectRatio = layoutAspectRatio, ) }, rearCameraButton = ( @@ -275,9 +277,11 @@ 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, ) } @@ -301,6 +305,7 @@ private fun CameraPreviewContent( zoomLevel: () -> Float, onChangeZoomLevel: (zoomLevel: Float) -> Unit, requestCaptureImage: () -> Unit, + surfaceAspectRatio: Float, modifier: Modifier = Modifier, foldingFeature: FoldingFeature? = null, shouldShowRearCameraFeature: () -> Boolean = { false }, @@ -328,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) } }, ) @@ -345,6 +351,7 @@ 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 4b58e632..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 @@ -92,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, ) } } @@ -354,6 +356,7 @@ data class CameraUiState( 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 index 177be847..81bb8bea 100644 --- 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 @@ -51,7 +51,8 @@ fun CameraLayoutSpatial( MainPanelWorkaround() SpatialPanel( SubspaceModifier - .fillMaxSize(0.5f), + .fillMaxSize(0.5f) + .aspectRatio(surfaceAspectRatio), ) { Orbiter( position = ContentEdge.Top,