diff --git a/.github/workflows/apply_spotless.yml b/.github/workflows/apply_spotless.yml index 5ba1f6fe5..0c8dcce4f 100644 --- a/.github/workflows/apply_spotless.yml +++ b/.github/workflows/apply_spotless.yml @@ -50,6 +50,9 @@ jobs: - name: Run spotlessApply for Misc run: ./gradlew :misc:spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace + - name: Run spotlessApply for XR + run: ./gradlew :xr:spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace + - name: Auto-commit if spotlessApply has changes uses: stefanzweifel/git-auto-commit-action@v5 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 97b36f468..b5124ef05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,3 +47,5 @@ jobs: run: ./gradlew :wear:build - name: Build misc snippets run: ./gradlew :misc:build + - name: Build XR snippets + run: ./gradlew :xr:build diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 15c560a20..033013972 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ hilt = "2.55" horologist = "0.6.22" junit = "4.13.2" kotlin = "2.1.10" +kotlinxCoroutinesGuava = "1.9.0" kotlinxSerializationJson = "1.8.0" ksp = "2.1.10-1.0.30" maps-compose = "6.4.4" @@ -55,6 +56,7 @@ playServicesWearable = "19.0.0" protolayout = "1.2.1" recyclerview = "1.4.0" # @keep +androidx-xr = "1.0.0-alpha02" targetSdk = "34" tiles = "1.4.1" version-catalog-update = "0.8.5" @@ -62,6 +64,7 @@ wear = "1.3.0" wearComposeFoundation = "1.4.1" wearComposeMaterial = "1.4.1" wearToolingPreview = "1.0.0" +activityKtx = "1.10.0" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -136,6 +139,9 @@ androidx-window = { module = "androidx.window:window", version.ref = "androidx-w androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window-core" } androidx-window-java = {module = "androidx.window:window-java", version.ref = "androidx-window-java" } androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.10.0" +androidx-xr-arcore = { module = "androidx.xr.arcore:arcore", version.ref = "androidx-xr" } +androidx-xr-compose = { module = "androidx.xr.compose:compose", version.ref = "androidx-xr" } +androidx-xr-scenecore = { module = "androidx.xr.scenecore:scenecore", version.ref = "androidx-xr" } android-identity-googleid = {module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "android-googleid"} appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } @@ -154,9 +160,11 @@ horologist-compose-material = { module = "com.google.android.horologist:horologi junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuava" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3ca8e6446..6d2212b63 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,5 @@ include( ":views", ":misc", ":identity:credentialmanager", + ":xr", ) diff --git a/xr/.gitignore b/xr/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/xr/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/xr/build.gradle.kts b/xr/build.gradle.kts new file mode 100644 index 000000000..afbff0151 --- /dev/null +++ b/xr/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.xr" + compileSdk = 35 + + defaultConfig { + applicationId = "com.example.xr" + minSdk = 34 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.xr.arcore) + implementation(libs.androidx.xr.scenecore) + implementation(libs.androidx.xr.compose) + implementation(libs.androidx.activity.ktx) + implementation(libs.guava) + implementation(libs.kotlinx.coroutines.guava) + +} \ No newline at end of file diff --git a/xr/src/main/AndroidManifest.xml b/xr/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6d6399c14 --- /dev/null +++ b/xr/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:tools="http://schemas.android.com/tools" + xmlns:android="http://schemas.android.com/apk/res/android"> + + <application + android:label="XR" + tools:ignore="MissingApplicationIcon" /> + +</manifest> \ No newline at end of file diff --git a/xr/src/main/java/com/example/xr/arcore/Hands.kt b/xr/src/main/java/com/example/xr/arcore/Hands.kt new file mode 100644 index 000000000..26cc0ba8e --- /dev/null +++ b/xr/src/main/java/com/example/xr/arcore/Hands.kt @@ -0,0 +1,128 @@ +/* + * 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.xr.arcore + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import androidx.xr.arcore.Hand +import androidx.xr.arcore.HandJointType +import androidx.xr.compose.platform.setSubspaceContent +import androidx.xr.runtime.Session +import androidx.xr.runtime.math.Pose +import androidx.xr.runtime.math.Quaternion +import androidx.xr.runtime.math.Vector3 +import androidx.xr.scenecore.Entity +import androidx.xr.scenecore.GltfModel +import androidx.xr.scenecore.GltfModelEntity +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch + +class SampleHandsActivity : ComponentActivity() { + lateinit var session: Session + lateinit var scenecoreSession: androidx.xr.scenecore.Session + lateinit var sessionHelper: SessionLifecycleHelper + + var palmEntity: Entity? = null + var indexFingerEntity: Entity? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSubspaceContent { } + + scenecoreSession = androidx.xr.scenecore.Session.create(this@SampleHandsActivity) + lifecycleScope.launch { + val model = GltfModel.create(scenecoreSession, "models/saturn_rings.glb").await() + palmEntity = GltfModelEntity.create(scenecoreSession, model).apply { + setScale(0.3f) + setHidden(true) + } + indexFingerEntity = GltfModelEntity.create(scenecoreSession, model).apply { + setScale(0.2f) + setHidden(true) + } + } + + sessionHelper = SessionLifecycleHelper( + onCreateCallback = { session = it }, + onResumeCallback = { + collectHands(session) + } + ) + lifecycle.addObserver(sessionHelper) + } +} + +fun SampleHandsActivity.collectHands(session: Session) { + lifecycleScope.launch { + // [START androidxr_arcore_hand_collect] + Hand.left(session)?.state?.collect { handState -> // or Hand.right(session) + // Hand state has been updated. + // Use the state of hand joints to update an entity's position. + renderPlanetAtHandPalm(handState) + } + // [END androidxr_arcore_hand_collect] + } + lifecycleScope.launch { + Hand.right(session)?.state?.collect { rightHandState -> + renderPlanetAtFingerTip(rightHandState) + } + } +} + +@SuppressLint("RestrictedApi") // HandJointType is mistakenly @Restrict: b/397415504 +fun SampleHandsActivity.renderPlanetAtHandPalm(leftHandState: Hand.State) { + val palmEntity = palmEntity ?: return + // [START androidxr_arcore_hand_entityAtHandPalm] + val palmPose = leftHandState.handJoints[HandJointType.PALM] ?: return + + // the down direction points in the same direction as the palm + val angle = Vector3.angleBetween(palmPose.rotation * Vector3.Down, Vector3.Up) + palmEntity.setHidden(angle > Math.toRadians(40.0)) + + val transformedPose = + scenecoreSession.perceptionSpace.transformPoseTo( + palmPose, + scenecoreSession.activitySpace, + ) + val newPosition = transformedPose.translation + transformedPose.down * 0.05f + palmEntity.setPose(Pose(newPosition, transformedPose.rotation)) + // [END androidxr_arcore_hand_entityAtHandPalm] +} + +@SuppressLint("RestrictedApi") // HandJointType is mistakenly @Restrict: b/397415504 +fun SampleHandsActivity.renderPlanetAtFingerTip(rightHandState: Hand.State) { + val indexFingerEntity = indexFingerEntity ?: return + + // [START androidxr_arcore_hand_entityAtIndexFingerTip] + val tipPose = rightHandState.handJoints[HandJointType.INDEX_TIP] ?: return + + // the forward direction points towards the finger tip. + val angle = Vector3.angleBetween(tipPose.rotation * Vector3.Forward, Vector3.Up) + indexFingerEntity.setHidden(angle > Math.toRadians(40.0)) + + val transformedPose = + scenecoreSession.perceptionSpace.transformPoseTo( + tipPose, + scenecoreSession.activitySpace, + ) + val position = transformedPose.translation + transformedPose.forward * 0.03f + val rotation = Quaternion.fromLookTowards(transformedPose.up, Vector3.Up) + indexFingerEntity.setPose(Pose(position, rotation)) + // [END androidxr_arcore_hand_entityAtIndexFingerTip] +} diff --git a/xr/src/main/java/com/example/xr/arcore/SessionLifecycleHelper.kt b/xr/src/main/java/com/example/xr/arcore/SessionLifecycleHelper.kt new file mode 100644 index 000000000..77462257f --- /dev/null +++ b/xr/src/main/java/com/example/xr/arcore/SessionLifecycleHelper.kt @@ -0,0 +1,30 @@ +/* + * 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.xr.arcore + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.xr.runtime.Session + +/** + * This is a dummy version of [SessionLifecycleHelper](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:xr/arcore/integration-tests/whitebox/src/main/kotlin/androidx/xr/arcore/apps/whitebox/common/SessionLifecycleHelper.kt). + * This will be removed when Session becomes a LifecycleOwner in cl/726643897. + */ +class SessionLifecycleHelper( + val onCreateCallback: (Session) -> Unit, + + val onResumeCallback: (() -> Unit)? = null, +) : DefaultLifecycleObserver