Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ In the sample you can see an implementation of:

# 💻 Development Environment

**Hello Android XR** uses the Gradle build system and can be imported directly into Android Studio
(make sure you are using the latest stable version available
[here](https://developer.android.com/studio)).
**Hello Android XR** uses the Gradle build system and can be imported directly into Android Studio.
Ensure you have the latest Canary version available, and update the XR emulator image in Android
Studio's SDK Manager before creating a new XR Emulator. The Canary version of Android Studio is
available [here](https://developer.android.com/studio/preview)).

# Additional Resources

Expand Down
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Indicates that the app makes use of XR spatial APIs, but this isn't a requirement that
should block installing the app -->
<uses-feature android:name="android.software.xr.api.spatial" android:required="false" />

<application
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
Expand All @@ -34,11 +36,18 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
Expand All @@ -61,10 +70,13 @@ import androidx.xr.compose.subspace.layout.alpha
import androidx.xr.compose.subspace.layout.fillMaxSize
import androidx.xr.compose.subspace.layout.fillMaxWidth
import androidx.xr.compose.subspace.layout.height
import androidx.xr.compose.subspace.layout.movable
import androidx.xr.compose.subspace.layout.padding
import androidx.xr.compose.subspace.layout.resizable
import androidx.xr.compose.subspace.layout.size
import androidx.xr.compose.subspace.layout.width
import com.example.helloandroidxr.R
import com.example.helloandroidxr.ui.components.BugdroidModel
import com.example.helloandroidxr.ui.components.EnvironmentControls
import com.example.helloandroidxr.ui.components.SearchBar
import com.example.helloandroidxr.ui.theme.HelloAndroidXRTheme
Expand Down Expand Up @@ -111,12 +123,21 @@ private fun SpatialLayout(
SpatialRow(modifier = SubspaceModifier.height(816.dp).fillMaxWidth()) {
SpatialColumn(modifier = SubspaceModifier.width(400.dp)) {
SpatialPanel(
SubspaceModifier.alpha(animatedAlpha.value).size(400.dp).padding(bottom = 16.dp)
SubspaceModifier
.alpha(animatedAlpha.value)
.size(400.dp)
.padding(bottom = 16.dp)
.movable()
.resizable()
) {
firstSupportingContent()
}
SpatialPanel(
SubspaceModifier.alpha(animatedAlpha.value).weight(1f)
SubspaceModifier
.alpha(animatedAlpha.value)
.weight(1f)
.movable()
.resizable()
) {
secondSupportingContent()
}
Expand All @@ -126,12 +147,12 @@ private fun SpatialLayout(
.alpha(animatedAlpha.value)
.fillMaxSize()
.padding(left = 16.dp)
.movable()
.resizable()
) {
Column {
TopAppBar()
Row {
primaryContent()
}
primaryContent()
}
}
}
Expand Down Expand Up @@ -193,8 +214,7 @@ private fun SideBySidePaneLayout(
}
}
Spacer(Modifier.width(16.dp))
Surface(modifier.clip(RoundedCornerShape(16.dp))
) {
Surface(modifier.clip(RoundedCornerShape(16.dp))) {
primaryPane()
}
}
Expand All @@ -210,7 +230,7 @@ private fun TopAndBottomPaneLayout(
modifier: Modifier = Modifier
) {
Column(modifier.verticalScroll(rememberScrollState())) {
Surface(Modifier.requiredHeight(500.dp)){
Surface(Modifier.requiredHeight(500.dp)) {
primaryPane()
}
Spacer(Modifier.height(16.dp))
Expand Down Expand Up @@ -259,11 +279,31 @@ private fun TopAppBar() {

@Composable
private fun PrimaryContent(modifier: Modifier = Modifier) {
TextPane(
text = stringResource(R.string.primary_content),
modifier = modifier
.clip(RoundedCornerShape(16.dp))
)
var showBugdroid by rememberSaveable { mutableStateOf(false) }

if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
Surface(modifier.fillMaxSize()) {
Box(modifier.padding(48.dp), contentAlignment = Alignment.Center) {
Button(
onClick = {
showBugdroid = true
},
modifier = modifier
Comment on lines +285 to +291

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The modifier parameter passed to PrimaryContent is being reused for multiple Composables (Surface, Box, and Button). This is generally not recommended as a Modifier instance should ideally be applied to a single UI element in a composition branch. Reusing it can lead to unexpected layout behaviors or conflicts, especially if the passed-in modifier has properties like size, padding, or click handlers that are not intended for all nested elements.

Could you refactor this to apply the incoming modifier to the root element within PrimaryContent (e.g., the Surface or TextPane) and use new Modifier instances for specific styling of internal elements like the Box or Button?

Suggested change
Surface(modifier.fillMaxSize()) {
Box(modifier.padding(48.dp), contentAlignment = Alignment.Center) {
Button(
onClick = {
showBugdroid = true
},
modifier = modifier
Surface(modifier = modifier.fillMaxSize()) { // Apply the incoming 'modifier' to the root Surface
Box(
// Use a new Modifier for Box's specific padding, not the one passed to PrimaryContent
modifier = Modifier.padding(48.dp),
contentAlignment = Alignment.Center
) {
Button(
onClick = {
showBugdroid = true
}
// Removed 'modifier = modifier'. Add specific Button modifiers if necessary.
) {

) {
Text(
text = stringResource(id = R.string.show_bugdroid),
style = MaterialTheme.typography.labelLarge
)
}
BugdroidModel(showBugdroid = showBugdroid)
}
}
} else {
TextPane(
text = stringResource(R.string.primary_content),
modifier = modifier.clip(RoundedCornerShape(16.dp))
)
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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.example.helloandroidxr.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.xr.compose.platform.LocalSession
import androidx.xr.compose.spatial.Subspace
import androidx.xr.compose.subspace.Volume
import androidx.xr.compose.subspace.layout.SubspaceModifier
import androidx.xr.compose.subspace.layout.offset
import androidx.xr.scenecore.GltfModel
import androidx.xr.scenecore.GltfModelEntity
import com.example.helloandroidxr.R
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import java.io.InputStream

@Composable
fun BugdroidModel(showBugdroid: Boolean) {
if (showBugdroid) {
val xrSession = checkNotNull(LocalSession.current)
val scope = rememberCoroutineScope()
val context = LocalContext.current

Subspace {
val inputStream: InputStream =
context.resources.openRawResource(R.raw.bugdroid_animated_wave)
Volume(
SubspaceModifier.offset(z = 400.dp) // Relative position
) { parent ->
scope.launch {
val gltfModel = GltfModel.create(
session = xrSession,
assetData = inputStream.readBytes(),
assetKey = "BUGDROID"
).await()
val gltfEntity = GltfModelEntity.create(xrSession, gltfModel)
// Make this glTF a child of the Volume
gltfEntity.setParent(parent)
// Change the size of the large glTF to 10%
gltfEntity.setScale(0.1f)
gltfEntity.startAnimation(
loop = true,
animationName = "Armature|Take 001|BaseLayer"
)
}
Comment on lines +43 to +63

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are a couple of concerns with the model loading logic:

  1. InputStream Handling: The inputStream is obtained at line 43. If the scope.launch block (lines 48-63) is executed multiple times due to recompositions of the Volume's content lambda, inputStream.readBytes() would be called on the same stream instance. Since readBytes() closes the stream after reading, subsequent calls on the closed stream will fail, likely causing a crash.
  2. Error Handling: The model loading process (GltfModel.create(...).await()) can throw exceptions (e.g., file not found, network issues, parsing errors). These are not currently caught, which could lead to unhandled exceptions.
  3. Execution Context: Launching the coroutine directly within the Volume's content lambda means it could re-execute more often than necessary. While GltfModel.create caches the model data by assetKey, the entity creation and parenting logic would still run.

Could you consider refactoring this to use LaunchedEffect? This would provide better control over when the loading logic executes (e.g., when xrSession or the parent entity from Volume changes), allow for safer InputStream handling by opening it inside the effect, and provide a natural place for try-catch error handling.

            // It's generally safer to open and use the InputStream within the scope where it's consumed,
            // especially with effects, to ensure it's fresh if the effect re-runs.
            Volume(
                SubspaceModifier.offset(z = 400.dp) // Relative position
            ) { parentNode -> // Renamed from 'parent' for clarity
                LaunchedEffect(xrSession, parentNode, context) { // Add keys that define when this effect should run
                    val modelInputStream: InputStream = try {
                        context.resources.openRawResource(R.raw.bugdroid_animated_wave)
                    } catch (e: Exception) {
                        // Log.e("BugdroidModel", "Failed to open resource stream", e)
                        return@LaunchedEffect // Stop if stream can't be opened
                    }

                    try {
                        val gltfModel = GltfModel.create(
                            session = xrSession,
                            assetData = modelInputStream.readBytes(), // readBytes() closes the stream
                            assetKey = "BUGDROID"
                        ).await()
                        val gltfEntity = GltfModelEntity.create(xrSession, gltfModel)
                        // Make this glTF a child of the Volume
                        gltfEntity.setParent(parentNode)
                        // Change the size of the large glTF to 10%
                        gltfEntity.setScale(0.1f)
                        gltfEntity.startAnimation(
                            loop = true,
                            animationName = "Armature|Take 001|BaseLayer"
                        )
                    } catch (e: Exception) {
                        // Log.e("BugdroidModel", "Error loading or displaying glTF model", e)
                        // Handle or log the exception appropriately
                    }
                }
            }

}
}
}
}
Binary file not shown.
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@
<string name="search_product_name">Search Product Name</string>
<string name="set_virtual_environment">set virtual environment</string>
<string name="set_passthrough">set passthrough</string>
<string name="show_bugdroid">Show bugdroid</string>

</resources>
19 changes: 10 additions & 9 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
[versions]
androidx-runtime = "1.8.0-alpha06"
agp = "8.6.1"
androidx-runtime = "1.8.1"
agp = "8.10.0"
arcore = "1.0.0-alpha04"
compose = "1.0.0-alpha04"
scenecore = "1.0.0-alpha04"
kotlinxCoroutinesGuava = "1.9.0"
kotlin = "2.0.21"
kotlinxCoroutinesGuava = "1.10.2"
kotlin = "2.1.21"
concurrentFuturesKtx = "1.2.0"
activityCompose = "1.10.0-beta01"
composeBom = "2024.11.00"
activityCompose = "1.10.1"
composeBom = "2025.05.00"
material = "1.12.0"
screenshot = "0.0.1-alpha08"
adaptiveAndroid = "1.0.0"
screenshot = "0.0.1-alpha09"
adaptiveAndroid = "1.1.0"
kotlinAndroid = "2.1.21"

[libraries]
androidx-arcore = { module = "androidx.xr.arcore:arcore", version.ref = "arcore" }
Expand All @@ -29,6 +30,6 @@ androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", nam

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.0.21" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinAndroid" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" }
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
#Mon Aug 26 16:14:13 PDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists